@shipfox/api-auth 9.2.0 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +25 -8
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/administration.d.ts +27 -0
- package/dist/core/administration.d.ts.map +1 -0
- package/dist/core/administration.js +122 -0
- package/dist/core/administration.js.map +1 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +2 -5
- package/dist/core/auth.js.map +1 -1
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +30 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/db/admin-grants.d.ts +18 -0
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +145 -1
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/db.d.ts +256 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +142 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -0
- package/dist/db/schema/admin-command-results.js +21 -0
- package/dist/db/schema/admin-command-results.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +3 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -0
- package/dist/presentation/routes/administration.js +186 -0
- package/dist/presentation/routes/administration.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts +1 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +20 -1
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_burly_whizzer.sql +12 -0
- package/drizzle/meta/0002_snapshot.json +791 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +10 -9
- package/src/config.ts +4 -0
- package/src/core/administration.ts +172 -0
- package/src/core/auth-default-signup-policy.test.ts +65 -0
- package/src/core/auth.test.ts +5 -0
- package/src/core/auth.ts +2 -3
- package/src/core/errors.ts +35 -0
- package/src/db/admin-grants.ts +258 -1
- package/src/db/db.ts +2 -0
- package/src/db/schema/admin-command-results.ts +41 -0
- package/src/index.test.ts +6 -2
- package/src/index.ts +20 -2
- package/src/metrics/instance.ts +1 -1
- package/src/presentation/auth/refresh-cookie.test.ts +4 -0
- package/src/presentation/e2eRoutes/index.test.ts +1 -0
- package/src/presentation/routes/administration.test.ts +253 -0
- package/src/presentation/routes/administration.ts +195 -0
- package/src/presentation/routes/index.test.ts +1 -0
- package/src/presentation/routes/rate-limit.ts +23 -2
- package/test/globalSetup.ts +6 -2
- package/test/routes.ts +7 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
3
|
+
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
import {users} from './users.js';
|
|
6
|
+
|
|
7
|
+
export interface StoredAdminGrant {
|
|
8
|
+
id: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
role: AdminRole;
|
|
11
|
+
revokedAt: string | null;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StoredAdminCommandResult {
|
|
17
|
+
grant: StoredAdminGrant;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const adminCommandResults = pgTable(
|
|
21
|
+
'admin_command_results',
|
|
22
|
+
{
|
|
23
|
+
id: uuidv7PrimaryKey(),
|
|
24
|
+
actorId: uuid('actor_id')
|
|
25
|
+
.notNull()
|
|
26
|
+
.references(() => users.id, {onDelete: 'cascade'}),
|
|
27
|
+
idempotencyKeyFingerprint: text('idempotency_key_fingerprint').notNull(),
|
|
28
|
+
command: text('command').notNull(),
|
|
29
|
+
requestFingerprint: text('request_fingerprint').notNull(),
|
|
30
|
+
result: jsonb('result').$type<StoredAdminCommandResult>().notNull(),
|
|
31
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
32
|
+
},
|
|
33
|
+
(table) => [
|
|
34
|
+
uniqueIndex('auth_admin_command_results_actor_key_unique').on(
|
|
35
|
+
table.actorId,
|
|
36
|
+
table.idempotencyKeyFingerprint,
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export type AdminCommandResultDb = typeof adminCommandResults.$inferSelect;
|
package/src/index.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
AUTH_USER_SIGNED_UP,
|
|
4
4
|
authEventSchemas,
|
|
5
5
|
} from '@shipfox/api-auth-dto';
|
|
6
|
+
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
6
7
|
import {createAuthModule} from './index.js';
|
|
7
8
|
import {passwordLoginMethods} from './login-methods.js';
|
|
8
9
|
|
|
@@ -55,6 +56,7 @@ describe('authModule', () => {
|
|
|
55
56
|
preflightInvitationAcceptance: vi.fn(),
|
|
56
57
|
acceptInvitation: vi.fn(),
|
|
57
58
|
requireActiveMembership: vi.fn(),
|
|
59
|
+
getWorkspaceOperatingState: vi.fn(),
|
|
58
60
|
},
|
|
59
61
|
signupPolicy,
|
|
60
62
|
});
|
|
@@ -73,9 +75,10 @@ describe('authModule', () => {
|
|
|
73
75
|
preflightInvitationAcceptance: vi.fn(),
|
|
74
76
|
acceptInvitation: vi.fn(),
|
|
75
77
|
requireActiveMembership: vi.fn(),
|
|
78
|
+
getWorkspaceOperatingState: vi.fn(),
|
|
76
79
|
},
|
|
77
80
|
});
|
|
78
|
-
expect(module.routes).toHaveLength(
|
|
81
|
+
expect(module.routes).toHaveLength(2);
|
|
79
82
|
const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
|
|
80
83
|
|
|
81
84
|
expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
|
|
@@ -95,8 +98,9 @@ describe('authModule', () => {
|
|
|
95
98
|
const publisher = authModule.publishers?.find((pub) => pub.name === 'auth');
|
|
96
99
|
const events = authModule.subscribers?.map((subscriber) => subscriber.event);
|
|
97
100
|
|
|
98
|
-
expect(publisher?.eventSchemas).toBe(authEventSchemas);
|
|
101
|
+
expect(publisher?.eventSchemas).not.toBe(authEventSchemas);
|
|
99
102
|
expect(Object.keys(publisher?.eventSchemas ?? {}).sort()).toEqual([
|
|
103
|
+
ADMINISTRATION_ACTION_PERFORMED,
|
|
100
104
|
AUTH_PASSWORD_RESET_SEND_REQUESTED,
|
|
101
105
|
AUTH_USER_SIGNED_UP,
|
|
102
106
|
]);
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type AuthEventMap,
|
|
4
4
|
authEventSchemas,
|
|
5
5
|
} from '@shipfox/api-auth-dto';
|
|
6
|
+
import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
|
|
6
7
|
import type {ShipfoxModule} from '@shipfox/node-module';
|
|
7
8
|
import {subscriberFactory} from '@shipfox/node-module';
|
|
8
9
|
import {config} from '#config.js';
|
|
@@ -16,10 +17,13 @@ import {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js
|
|
|
16
17
|
import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-auth.js';
|
|
17
18
|
import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
|
|
18
19
|
import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
|
|
20
|
+
import {administrationRoutes} from '#presentation/routes/administration.js';
|
|
19
21
|
import {buildAuthRoutes} from '#presentation/routes/index.js';
|
|
20
22
|
import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
|
|
21
23
|
import {passwordLoginMethods} from './login-methods.js';
|
|
22
24
|
|
|
25
|
+
const authPublisherEventSchemas = {...authEventSchemas, ...administrationActionEventSchemas};
|
|
26
|
+
|
|
23
27
|
export type {AdminRole, JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
|
|
24
28
|
export {
|
|
25
29
|
ADMIN_ROLES,
|
|
@@ -29,6 +33,12 @@ export {
|
|
|
29
33
|
requireAdminRole,
|
|
30
34
|
revokeAdminGrant,
|
|
31
35
|
} from '#core/admin-role.js';
|
|
36
|
+
export {
|
|
37
|
+
bootstrapFirstAdminOwner,
|
|
38
|
+
grantAdministratorRole,
|
|
39
|
+
listAdministratorGrants,
|
|
40
|
+
revokeAdministratorGrant,
|
|
41
|
+
} from '#core/administration.js';
|
|
32
42
|
export type {
|
|
33
43
|
CreateSessionForUserError,
|
|
34
44
|
CreateSessionForUserParams,
|
|
@@ -41,9 +51,14 @@ export {findUserByEmail} from '#core/email-owner.js';
|
|
|
41
51
|
export type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
42
52
|
export type {User, UserStatus} from '#core/entities/user.js';
|
|
43
53
|
export {
|
|
54
|
+
AdminBootstrapClosedError,
|
|
55
|
+
AdminGrantAlreadyExistsError,
|
|
56
|
+
AdminGrantNotFoundError,
|
|
57
|
+
AdminIdempotencyKeyReuseError,
|
|
44
58
|
AdminRoleRequiredError,
|
|
45
59
|
AuthDependencyUnavailableError,
|
|
46
60
|
EmailNotVerifiedError,
|
|
61
|
+
InvalidAdminBootstrapTokenError,
|
|
47
62
|
InvalidCredentialsError,
|
|
48
63
|
LastAdminOwnerError,
|
|
49
64
|
SignupNotAllowedError,
|
|
@@ -95,9 +110,12 @@ export function createAuthModule({
|
|
|
95
110
|
database: {db, migrationsPath, databaseNamespace: 'auth'},
|
|
96
111
|
auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
|
|
97
112
|
loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
|
|
98
|
-
routes: [
|
|
113
|
+
routes: [
|
|
114
|
+
buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
|
|
115
|
+
administrationRoutes,
|
|
116
|
+
],
|
|
99
117
|
e2eRoutes: [createAuthE2eRoutes(workspaces)],
|
|
100
|
-
publishers: [{name: 'auth', table: authOutbox, db, eventSchemas:
|
|
118
|
+
publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
|
|
101
119
|
subscribers: [subscriber(AUTH_PASSWORD_RESET_SEND_REQUESTED, onPasswordResetSendRequested)],
|
|
102
120
|
interModulePresentations: [createAuthInterModulePresentation()],
|
|
103
121
|
};
|
package/src/metrics/instance.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {instanceMetrics} from '@shipfox/node-opentelemetry';
|
|
|
3
3
|
export type AuthTokenType = 'session' | 'job_lease' | 'runner_session';
|
|
4
4
|
export type AuthTokenVerificationOutcome = 'ok' | 'rejected';
|
|
5
5
|
export type AuthTokenRefreshOutcome = 'rotated' | 'grace' | 'rejected';
|
|
6
|
-
export type AuthRateLimitAction = 'login' | 'email-send';
|
|
6
|
+
export type AuthRateLimitAction = 'login' | 'email-send' | 'bootstrap';
|
|
7
7
|
export type AuthRateLimitScope = 'ip' | 'email';
|
|
8
8
|
export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
|
|
9
9
|
|
|
@@ -6,6 +6,10 @@ vi.mock('#config.js', () => ({
|
|
|
6
6
|
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
7
7
|
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
8
8
|
AUTH_PASSWORD_ENABLED: true,
|
|
9
|
+
AUTH_SIGNUP_GATE_ENABLED: false,
|
|
10
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
|
|
11
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: '',
|
|
12
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
|
|
9
13
|
},
|
|
10
14
|
}));
|
|
11
15
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
3
|
+
import type {FastifyInstance} from 'fastify';
|
|
4
|
+
import {db} from '#db/db.js';
|
|
5
|
+
import {authOutbox} from '#db/schema/outbox.js';
|
|
6
|
+
import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
|
|
7
|
+
|
|
8
|
+
const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
|
|
9
|
+
|
|
10
|
+
async function resetAdministrationState(): Promise<void> {
|
|
11
|
+
await db().execute(
|
|
12
|
+
sql`TRUNCATE auth_admin_command_results, auth_admin_grants, auth_outbox, auth_rate_limits CASCADE`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function authHeaders(token: string, idempotencyKey: string) {
|
|
17
|
+
return {
|
|
18
|
+
authorization: `Bearer ${token}`,
|
|
19
|
+
'idempotency-key': idempotencyKey,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Auth administration routes', () => {
|
|
24
|
+
let app: FastifyInstance;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
app = await createAuthTestApp();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
resetCapturedMail();
|
|
32
|
+
await resetAdministrationState();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await resetAdministrationState();
|
|
37
|
+
await app.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('requires an authenticated session for bootstrap', async () => {
|
|
41
|
+
const response = await app.inject({
|
|
42
|
+
method: 'POST',
|
|
43
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
44
|
+
headers: {'idempotency-key': 'anonymous-bootstrap'},
|
|
45
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(response.statusCode).toBe(401);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rejects an invalid bootstrap token without writing a grant or event', async () => {
|
|
52
|
+
const account = await createVerifiedSession('admin-bootstrap-invalid');
|
|
53
|
+
|
|
54
|
+
const response = await app.inject({
|
|
55
|
+
method: 'POST',
|
|
56
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
57
|
+
headers: authHeaders(account.token, 'invalid-bootstrap'),
|
|
58
|
+
payload: {bootstrap_token: 'wrong-token'},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(response.statusCode).toBe(403);
|
|
62
|
+
expect(response.json().code).toBe('bootstrap-token-invalid');
|
|
63
|
+
await expect(db().select().from(authOutbox)).resolves.toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('rate-limits repeated bootstrap attempts by source IP', async () => {
|
|
67
|
+
const account = await createVerifiedSession('admin-bootstrap-rate-limit');
|
|
68
|
+
|
|
69
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
70
|
+
const response = await app.inject({
|
|
71
|
+
method: 'POST',
|
|
72
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
73
|
+
headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
|
|
74
|
+
payload: {bootstrap_token: 'wrong-token'},
|
|
75
|
+
});
|
|
76
|
+
expect(response.statusCode).toBe(403);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const blocked = await app.inject({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
82
|
+
headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
|
|
83
|
+
payload: {bootstrap_token: 'wrong-token'},
|
|
84
|
+
});
|
|
85
|
+
expect(blocked.statusCode).toBe(429);
|
|
86
|
+
expect(blocked.json().code).toBe('rate-limited');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('bootstraps exactly one owner and permanently closes bootstrap', async () => {
|
|
90
|
+
const first = await createVerifiedSession('admin-bootstrap-first');
|
|
91
|
+
const second = await createVerifiedSession('admin-bootstrap-second');
|
|
92
|
+
|
|
93
|
+
const response = await app.inject({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
96
|
+
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
97
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(response.statusCode).toBe(201);
|
|
101
|
+
expect(response.json()).toMatchObject({user_id: first.userId, role: 'admin-owner'});
|
|
102
|
+
|
|
103
|
+
const repeated = await app.inject({
|
|
104
|
+
method: 'POST',
|
|
105
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
106
|
+
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
107
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
108
|
+
});
|
|
109
|
+
expect(repeated.statusCode).toBe(201);
|
|
110
|
+
expect(repeated.json().id).toBe(response.json().id);
|
|
111
|
+
|
|
112
|
+
const secondAttempt = await app.inject({
|
|
113
|
+
method: 'POST',
|
|
114
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
115
|
+
headers: authHeaders(second.token, 'bootstrap-second'),
|
|
116
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
117
|
+
});
|
|
118
|
+
expect(secondAttempt.statusCode).toBe(409);
|
|
119
|
+
expect(secondAttempt.json().code).toBe('bootstrap-closed');
|
|
120
|
+
|
|
121
|
+
const events = await db().select().from(authOutbox);
|
|
122
|
+
expect(events).toHaveLength(1);
|
|
123
|
+
expect(events[0]?.eventType).toBe(ADMINISTRATION_ACTION_PERFORMED);
|
|
124
|
+
expect(events[0]?.payload).toMatchObject({
|
|
125
|
+
actorId: first.userId,
|
|
126
|
+
actorRole: 'admin-owner',
|
|
127
|
+
requiredRole: 'admin-owner',
|
|
128
|
+
command: 'auth.admin_grant.bootstrap',
|
|
129
|
+
result: 'succeeded',
|
|
130
|
+
});
|
|
131
|
+
expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
|
|
135
|
+
const owner = await createVerifiedSession('admin-grant-owner');
|
|
136
|
+
const target = await createVerifiedSession('admin-grant-target');
|
|
137
|
+
|
|
138
|
+
const bootstrap = await app.inject({
|
|
139
|
+
method: 'POST',
|
|
140
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
141
|
+
headers: authHeaders(owner.token, 'grant-bootstrap'),
|
|
142
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
143
|
+
});
|
|
144
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
145
|
+
|
|
146
|
+
const grant = await app.inject({
|
|
147
|
+
method: 'POST',
|
|
148
|
+
url: '/admin/v1/auth/admin-grants',
|
|
149
|
+
headers: authHeaders(owner.token, 'grant-observer'),
|
|
150
|
+
payload: {
|
|
151
|
+
user_id: target.userId,
|
|
152
|
+
role: 'admin-observer',
|
|
153
|
+
reason: 'Support investigation',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
expect(grant.statusCode).toBe(201);
|
|
157
|
+
|
|
158
|
+
const repeatedGrant = await app.inject({
|
|
159
|
+
method: 'POST',
|
|
160
|
+
url: '/admin/v1/auth/admin-grants',
|
|
161
|
+
headers: authHeaders(owner.token, 'grant-observer'),
|
|
162
|
+
payload: {
|
|
163
|
+
user_id: target.userId,
|
|
164
|
+
role: 'admin-observer',
|
|
165
|
+
reason: 'Support investigation',
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
expect(repeatedGrant.statusCode).toBe(201);
|
|
169
|
+
expect(repeatedGrant.json().id).toBe(grant.json().id);
|
|
170
|
+
|
|
171
|
+
const grants = await app.inject({
|
|
172
|
+
method: 'GET',
|
|
173
|
+
url: '/admin/v1/auth/admin-grants',
|
|
174
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
175
|
+
});
|
|
176
|
+
expect(grants.statusCode).toBe(200);
|
|
177
|
+
expect(grants.json().grants).toEqual(
|
|
178
|
+
expect.arrayContaining([expect.objectContaining({user_id: target.userId})]),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const revoked = await app.inject({
|
|
182
|
+
method: 'DELETE',
|
|
183
|
+
url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
|
|
184
|
+
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
185
|
+
payload: {reason: 'Support investigation complete'},
|
|
186
|
+
});
|
|
187
|
+
expect(revoked.statusCode).toBe(200);
|
|
188
|
+
expect(revoked.json()).toMatchObject({id: grant.json().id, revoked_at: expect.any(String)});
|
|
189
|
+
|
|
190
|
+
const repeatedRevoke = await app.inject({
|
|
191
|
+
method: 'DELETE',
|
|
192
|
+
url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
|
|
193
|
+
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
194
|
+
payload: {reason: 'Support investigation complete'},
|
|
195
|
+
});
|
|
196
|
+
expect(repeatedRevoke.statusCode).toBe(200);
|
|
197
|
+
expect(repeatedRevoke.json().revoked_at).toBe(revoked.json().revoked_at);
|
|
198
|
+
|
|
199
|
+
const events = await db().select().from(authOutbox);
|
|
200
|
+
expect(events).toHaveLength(3);
|
|
201
|
+
expect(events.map((event) => event.eventType)).toEqual(
|
|
202
|
+
Array(3).fill(ADMINISTRATION_ACTION_PERFORMED),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('protects the final active owner and rejects idempotency-key reuse', async () => {
|
|
207
|
+
const owner = await createVerifiedSession('admin-final-owner');
|
|
208
|
+
const target = await createVerifiedSession('admin-final-target');
|
|
209
|
+
|
|
210
|
+
await app.inject({
|
|
211
|
+
method: 'POST',
|
|
212
|
+
url: '/admin/v1/auth/admin-grants/bootstrap',
|
|
213
|
+
headers: authHeaders(owner.token, 'final-bootstrap'),
|
|
214
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const firstGrant = await app.inject({
|
|
218
|
+
method: 'POST',
|
|
219
|
+
url: '/admin/v1/auth/admin-grants',
|
|
220
|
+
headers: authHeaders(owner.token, 'role-key'),
|
|
221
|
+
payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
|
|
222
|
+
});
|
|
223
|
+
expect(firstGrant.statusCode).toBe(201);
|
|
224
|
+
|
|
225
|
+
const reusedKey = await app.inject({
|
|
226
|
+
method: 'POST',
|
|
227
|
+
url: '/admin/v1/auth/admin-grants',
|
|
228
|
+
headers: authHeaders(owner.token, 'role-key'),
|
|
229
|
+
payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
|
|
230
|
+
});
|
|
231
|
+
expect(reusedKey.statusCode).toBe(409);
|
|
232
|
+
expect(reusedKey.json().code).toBe('idempotency-key-reused');
|
|
233
|
+
|
|
234
|
+
const finalOwnerRevoke = await app.inject({
|
|
235
|
+
method: 'DELETE',
|
|
236
|
+
url: `/admin/v1/auth/admin-grants/${
|
|
237
|
+
(
|
|
238
|
+
await app.inject({
|
|
239
|
+
method: 'GET',
|
|
240
|
+
url: '/admin/v1/auth/admin-grants',
|
|
241
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
.json()
|
|
245
|
+
.grants.find((grant: {user_id: string}) => grant.user_id === owner.userId).id
|
|
246
|
+
}`,
|
|
247
|
+
headers: authHeaders(owner.token, 'revoke-final-owner'),
|
|
248
|
+
payload: {reason: 'Attempt to remove final owner'},
|
|
249
|
+
});
|
|
250
|
+
expect(finalOwnerRevoke.statusCode).toBe(409);
|
|
251
|
+
expect(finalOwnerRevoke.json().code).toBe('last-owner');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {AUTH_USER} from '@shipfox/api-auth-context';
|
|
2
|
+
import {
|
|
3
|
+
bootstrapAdminOwnerBodySchema,
|
|
4
|
+
bootstrapAdminOwnerResponseSchema,
|
|
5
|
+
grantAdminRoleBodySchema,
|
|
6
|
+
grantAdminRoleResponseSchema,
|
|
7
|
+
listAdminGrantsResponseSchema,
|
|
8
|
+
revokeAdminGrantBodySchema,
|
|
9
|
+
revokeAdminGrantResponseSchema,
|
|
10
|
+
} from '@shipfox/api-auth-dto';
|
|
11
|
+
import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
12
|
+
import type {FastifyRequest} from 'fastify';
|
|
13
|
+
import {z} from 'zod';
|
|
14
|
+
import {
|
|
15
|
+
bootstrapFirstAdminOwner,
|
|
16
|
+
grantAdministratorRole,
|
|
17
|
+
listAdministratorGrants,
|
|
18
|
+
revokeAdministratorGrant,
|
|
19
|
+
} from '#core/administration.js';
|
|
20
|
+
import type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
21
|
+
import {
|
|
22
|
+
AdminBootstrapClosedError,
|
|
23
|
+
AdminGrantAlreadyExistsError,
|
|
24
|
+
AdminGrantNotFoundError,
|
|
25
|
+
AdminIdempotencyKeyReuseError,
|
|
26
|
+
AdminRoleRequiredError,
|
|
27
|
+
InvalidAdminBootstrapTokenError,
|
|
28
|
+
LastAdminOwnerError,
|
|
29
|
+
UserNotFoundError,
|
|
30
|
+
} from '#core/errors.js';
|
|
31
|
+
import {getClientContext} from '#presentation/auth/jwt-auth.js';
|
|
32
|
+
import {createAuthIpRateLimitPreHandler} from './rate-limit.js';
|
|
33
|
+
|
|
34
|
+
const idempotencyKeyMaxLength = 256;
|
|
35
|
+
|
|
36
|
+
function requireActorId(request: FastifyRequest): string {
|
|
37
|
+
const client = getClientContext(request);
|
|
38
|
+
if (!client) {
|
|
39
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
40
|
+
}
|
|
41
|
+
return client.userId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireIdempotencyKey(request: FastifyRequest): string {
|
|
45
|
+
const value = request.headers['idempotency-key'];
|
|
46
|
+
const key = Array.isArray(value) ? value[0] : value;
|
|
47
|
+
if (!key || key.trim().length === 0 || key.length > idempotencyKeyMaxLength) {
|
|
48
|
+
throw new ClientError('Idempotency-Key header is required', 'idempotency-key-required', {
|
|
49
|
+
status: 400,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toAdminGrantDto(grant: AdminGrant) {
|
|
56
|
+
return {
|
|
57
|
+
id: grant.id,
|
|
58
|
+
user_id: grant.userId,
|
|
59
|
+
role: grant.role,
|
|
60
|
+
revoked_at: grant.revokedAt?.toISOString() ?? null,
|
|
61
|
+
created_at: grant.createdAt.toISOString(),
|
|
62
|
+
updated_at: grant.updatedAt.toISOString(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function translateAdministrationError(error: unknown): never {
|
|
67
|
+
if (error instanceof AdminRoleRequiredError) {
|
|
68
|
+
throw new ClientError('Administrator owner role required', 'forbidden', {
|
|
69
|
+
status: 403,
|
|
70
|
+
details: {required_role: error.minimumRole},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (error instanceof InvalidAdminBootstrapTokenError) {
|
|
74
|
+
throw new ClientError('Bootstrap token is invalid', 'bootstrap-token-invalid', {
|
|
75
|
+
status: 403,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (error instanceof AdminBootstrapClosedError) {
|
|
79
|
+
throw new ClientError('First administrator owner already exists', 'bootstrap-closed', {
|
|
80
|
+
status: 409,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (error instanceof AdminGrantAlreadyExistsError) {
|
|
84
|
+
throw new ClientError('Administrator grant already exists', 'grant-already-exists', {
|
|
85
|
+
status: 409,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (error instanceof AdminGrantNotFoundError) {
|
|
89
|
+
throw new ClientError('Administrator grant not found', 'not-found', {status: 404});
|
|
90
|
+
}
|
|
91
|
+
if (error instanceof UserNotFoundError) {
|
|
92
|
+
throw new ClientError('User not found', 'not-found', {status: 404});
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof LastAdminOwnerError) {
|
|
95
|
+
throw new ClientError('Cannot remove the final active administrator owner', 'last-owner', {
|
|
96
|
+
status: 409,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (error instanceof AdminIdempotencyKeyReuseError) {
|
|
100
|
+
throw new ClientError(
|
|
101
|
+
'Idempotency-Key was already used for a different command',
|
|
102
|
+
'idempotency-key-reused',
|
|
103
|
+
{status: 409},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bootstrapRoute = defineRoute({
|
|
110
|
+
method: 'POST',
|
|
111
|
+
path: '/bootstrap',
|
|
112
|
+
description: 'Claim the first administrator owner role with the deployment bootstrap token.',
|
|
113
|
+
schema: {
|
|
114
|
+
body: bootstrapAdminOwnerBodySchema,
|
|
115
|
+
response: {201: bootstrapAdminOwnerResponseSchema},
|
|
116
|
+
},
|
|
117
|
+
preHandler: createAuthIpRateLimitPreHandler('bootstrap'),
|
|
118
|
+
errorHandler: translateAdministrationError,
|
|
119
|
+
handler: async (request, reply) => {
|
|
120
|
+
const actorId = requireActorId(request);
|
|
121
|
+
const grant = await bootstrapFirstAdminOwner({
|
|
122
|
+
actorId,
|
|
123
|
+
bootstrapToken: request.body.bootstrap_token,
|
|
124
|
+
idempotencyKey: requireIdempotencyKey(request),
|
|
125
|
+
correlationId: request.id,
|
|
126
|
+
});
|
|
127
|
+
reply.code(201);
|
|
128
|
+
return toAdminGrantDto(grant);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const listRoute = defineRoute({
|
|
133
|
+
method: 'GET',
|
|
134
|
+
path: '/',
|
|
135
|
+
description: 'List local administrator grants.',
|
|
136
|
+
schema: {response: {200: listAdminGrantsResponseSchema}},
|
|
137
|
+
errorHandler: translateAdministrationError,
|
|
138
|
+
handler: async (request) => ({
|
|
139
|
+
grants: (await listAdministratorGrants({actorId: requireActorId(request)})).map(
|
|
140
|
+
toAdminGrantDto,
|
|
141
|
+
),
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const grantRoute = defineRoute({
|
|
146
|
+
method: 'POST',
|
|
147
|
+
path: '/',
|
|
148
|
+
description: 'Grant a local administrator role to an active user.',
|
|
149
|
+
schema: {
|
|
150
|
+
body: grantAdminRoleBodySchema,
|
|
151
|
+
response: {201: grantAdminRoleResponseSchema},
|
|
152
|
+
},
|
|
153
|
+
errorHandler: translateAdministrationError,
|
|
154
|
+
handler: async (request, reply) => {
|
|
155
|
+
const actorId = requireActorId(request);
|
|
156
|
+
const grant = await grantAdministratorRole({
|
|
157
|
+
actorId,
|
|
158
|
+
userId: request.body.user_id,
|
|
159
|
+
role: request.body.role,
|
|
160
|
+
reason: request.body.reason,
|
|
161
|
+
idempotencyKey: requireIdempotencyKey(request),
|
|
162
|
+
correlationId: request.id,
|
|
163
|
+
});
|
|
164
|
+
reply.code(201);
|
|
165
|
+
return toAdminGrantDto(grant);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const revokeRoute = defineRoute({
|
|
170
|
+
method: 'DELETE',
|
|
171
|
+
path: '/:grantId',
|
|
172
|
+
description: 'Revoke a local administrator grant.',
|
|
173
|
+
schema: {
|
|
174
|
+
params: z.object({grantId: z.string().uuid()}),
|
|
175
|
+
body: revokeAdminGrantBodySchema,
|
|
176
|
+
response: {200: revokeAdminGrantResponseSchema},
|
|
177
|
+
},
|
|
178
|
+
errorHandler: translateAdministrationError,
|
|
179
|
+
handler: async (request) => {
|
|
180
|
+
const grant = await revokeAdministratorGrant({
|
|
181
|
+
actorId: requireActorId(request),
|
|
182
|
+
grantId: request.params.grantId,
|
|
183
|
+
reason: request.body.reason,
|
|
184
|
+
idempotencyKey: requireIdempotencyKey(request),
|
|
185
|
+
correlationId: request.id,
|
|
186
|
+
});
|
|
187
|
+
return toAdminGrantDto(grant);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const administrationRoutes: RouteGroup = {
|
|
192
|
+
prefix: '/admin/v1/auth/admin-grants',
|
|
193
|
+
auth: AUTH_USER,
|
|
194
|
+
routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
|
|
195
|
+
};
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
checkAuthRateLimit,
|
|
9
9
|
} from '#core/rate-limit.js';
|
|
10
10
|
|
|
11
|
-
const policies: Record<
|
|
11
|
+
const policies: Record<
|
|
12
|
+
AuthRateLimitAction,
|
|
13
|
+
Partial<Record<AuthRateLimitScope, AuthRateLimitPolicy>>
|
|
14
|
+
> = {
|
|
12
15
|
login: {
|
|
13
16
|
ip: {limit: 60, windowSeconds: 5 * 60},
|
|
14
17
|
email: {limit: 10, windowSeconds: 15 * 60},
|
|
@@ -17,6 +20,9 @@ const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateL
|
|
|
17
20
|
ip: {limit: 30, windowSeconds: 60 * 60},
|
|
18
21
|
email: {limit: 3, windowSeconds: 60 * 60},
|
|
19
22
|
},
|
|
23
|
+
bootstrap: {
|
|
24
|
+
ip: {limit: 5, windowSeconds: 15 * 60},
|
|
25
|
+
},
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
interface EmailBody {
|
|
@@ -34,12 +40,15 @@ async function enforceRateLimit(params: {
|
|
|
34
40
|
scope: AuthRateLimitScope;
|
|
35
41
|
identifier: string;
|
|
36
42
|
}): Promise<void> {
|
|
43
|
+
const policy = policies[params.action][params.scope];
|
|
44
|
+
if (!policy) return;
|
|
45
|
+
|
|
37
46
|
try {
|
|
38
47
|
await checkAuthRateLimit({
|
|
39
48
|
action: params.action,
|
|
40
49
|
scope: params.scope,
|
|
41
50
|
identifier: params.identifier,
|
|
42
|
-
...
|
|
51
|
+
...policy,
|
|
43
52
|
});
|
|
44
53
|
} catch (error) {
|
|
45
54
|
if (error instanceof AuthRateLimitExceededError) {
|
|
@@ -117,3 +126,15 @@ export function createAuthRateLimitPreHandler(action: AuthRateLimitAction) {
|
|
|
117
126
|
});
|
|
118
127
|
};
|
|
119
128
|
}
|
|
129
|
+
|
|
130
|
+
export function createAuthIpRateLimitPreHandler(action: AuthRateLimitAction) {
|
|
131
|
+
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
132
|
+
await enforceRateLimit({
|
|
133
|
+
request,
|
|
134
|
+
reply,
|
|
135
|
+
action,
|
|
136
|
+
scope: 'ip',
|
|
137
|
+
identifier: request.ip,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|