@shipfox/api-auth 10.0.0 → 10.1.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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +5 -1
  4. package/dist/core/administration.d.ts +1 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +4 -1
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/db/admin-grants.d.ts +9 -0
  9. package/dist/db/admin-grants.d.ts.map +1 -1
  10. package/dist/db/admin-grants.js +19 -10
  11. package/dist/db/admin-grants.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/metrics/instance.d.ts +1 -1
  16. package/dist/metrics/instance.d.ts.map +1 -1
  17. package/dist/metrics/instance.js.map +1 -1
  18. package/dist/presentation/routes/administration.d.ts +1 -0
  19. package/dist/presentation/routes/administration.d.ts.map +1 -1
  20. package/dist/presentation/routes/administration.js +24 -2
  21. package/dist/presentation/routes/administration.js.map +1 -1
  22. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  23. package/dist/presentation/routes/rate-limit.js +6 -0
  24. package/dist/presentation/routes/rate-limit.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/core/administration.ts +5 -0
  28. package/src/db/admin-grants.test.ts +17 -1
  29. package/src/db/admin-grants.ts +33 -35
  30. package/src/index.test.ts +4 -1
  31. package/src/index.ts +2 -0
  32. package/src/metrics/instance.ts +6 -1
  33. package/src/presentation/routes/administration.test.ts +128 -0
  34. package/src/presentation/routes/administration.ts +18 -0
  35. package/src/presentation/routes/rate-limit.ts +3 -0
  36. package/test/routes.ts +7 -1
  37. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "10.0.0",
4
+ "version": "10.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,8 +27,8 @@
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
29
  "@shipfox/api-common-dto": "9.2.0",
30
- "@shipfox/api-auth-context": "10.0.0",
31
- "@shipfox/api-auth-dto": "10.0.0",
30
+ "@shipfox/api-auth-context": "10.1.0",
31
+ "@shipfox/api-auth-dto": "10.1.0",
32
32
  "@shipfox/api-email-challenges": "1.1.6",
33
33
  "@shipfox/api-workspaces-dto": "10.0.0",
34
34
  "@shipfox/inter-module": "0.2.2",
@@ -6,6 +6,7 @@ import {config} from '#config.js';
6
6
  import {
7
7
  bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
8
8
  grantAdminRoleWithAudit,
9
+ hasActiveAdminOwner,
9
10
  listAdminGrantSummaries,
10
11
  revokeAdminGrantWithAudit,
11
12
  } from '#db/admin-grants.js';
@@ -105,6 +106,10 @@ export async function bootstrapFirstAdminOwner(
105
106
  });
106
107
  }
107
108
 
109
+ export async function getAdminBootstrapState(): Promise<'available' | 'closed'> {
110
+ return (await hasActiveAdminOwner()) ? 'closed' : 'available';
111
+ }
112
+
108
113
  export async function findAdministratorUserSummary(
109
114
  params: {actorId: string} & ({id: string; email?: never} | {email: string; id?: never}),
110
115
  ): Promise<AdministratorUserSummary | undefined> {
@@ -1,7 +1,12 @@
1
1
  import {eq} from 'drizzle-orm';
2
2
  import {LastAdminOwnerError} from '#core/errors.js';
3
3
  import {userFactory} from '#test/index.js';
4
- import {createAdminGrant, findCurrentAdminRole, revokeAdminGrant} from './admin-grants.js';
4
+ import {
5
+ createAdminGrant,
6
+ findCurrentAdminRole,
7
+ hasActiveAdminOwner,
8
+ revokeAdminGrant,
9
+ } from './admin-grants.js';
5
10
  import {db} from './db.js';
6
11
  import {users} from './schema/users.js';
7
12
 
@@ -26,6 +31,17 @@ describe('admin grants db', () => {
26
31
  expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
27
32
  });
28
33
 
34
+ test('reopens bootstrap when the sole owner user is suspended', async () => {
35
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
36
+ await createAdminGrant({userId: user.id, role: 'admin-owner'});
37
+
38
+ await expect(hasActiveAdminOwner()).resolves.toBe(true);
39
+
40
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
41
+
42
+ await expect(hasActiveAdminOwner()).resolves.toBe(false);
43
+ });
44
+
29
45
  test('prevents revoking the final active owner but permits replacement first', async () => {
30
46
  const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
31
47
  const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
@@ -32,6 +32,24 @@ import {authOutbox} from './schema/outbox.js';
32
32
  import {users} from './schema/users.js';
33
33
 
34
34
  type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
35
+ type AdminOwnerQueryExecutor = ReturnType<typeof db> | Tx;
36
+
37
+ function activeAdminOwnerWhere() {
38
+ return and(
39
+ eq(adminGrants.role, 'admin-owner'),
40
+ isNull(adminGrants.revokedAt),
41
+ eq(users.status, 'active'),
42
+ );
43
+ }
44
+
45
+ async function listActiveAdminOwners(executor: AdminOwnerQueryExecutor) {
46
+ return await executor
47
+ .select({id: adminGrants.id})
48
+ .from(adminGrants)
49
+ .innerJoin(users, eq(adminGrants.userId, users.id))
50
+ .where(activeAdminOwnerWhere())
51
+ .limit(2);
52
+ }
35
53
 
36
54
  export interface CreateAdminGrantParams {
37
55
  userId: string;
@@ -111,6 +129,18 @@ export async function findCurrentAdminRole(params: {userId: string}): Promise<Ad
111
129
  return highestAdminRole(rows.map(({role}) => role));
112
130
  }
113
131
 
132
+ /**
133
+ * An owner is active only while both the grant and its user account remain
134
+ * active. A suspended owner's grant is retained, but it intentionally does
135
+ * not prevent deployment-bound bootstrap recovery.
136
+ */
137
+ export async function hasActiveAdminOwner(
138
+ executor: AdminOwnerQueryExecutor = db(),
139
+ ): Promise<boolean> {
140
+ const rows = await listActiveAdminOwners(executor);
141
+ return rows.length > 0;
142
+ }
143
+
114
144
  export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
115
145
  return await db().transaction(async (tx) => {
116
146
  // All owner grant changes share one lock so concurrent revocations cannot
@@ -126,17 +156,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
126
156
  if (!grant) return undefined;
127
157
 
128
158
  if (grant.role === 'admin-owner') {
129
- const activeOwners = await tx
130
- .select({id: adminGrants.id})
131
- .from(adminGrants)
132
- .innerJoin(users, eq(adminGrants.userId, users.id))
133
- .where(
134
- and(
135
- eq(adminGrants.role, 'admin-owner'),
136
- isNull(adminGrants.revokedAt),
137
- eq(users.status, 'active'),
138
- ),
139
- );
159
+ const activeOwners = await listActiveAdminOwners(tx);
140
160
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
141
161
  }
142
162
 
@@ -252,19 +272,7 @@ export async function bootstrapFirstAdminOwner(
252
272
  });
253
273
  if (existing) return existing;
254
274
 
255
- const activeOwners = await tx
256
- .select({id: adminGrants.id})
257
- .from(adminGrants)
258
- .innerJoin(users, eq(adminGrants.userId, users.id))
259
- .where(
260
- and(
261
- eq(adminGrants.role, 'admin-owner'),
262
- isNull(adminGrants.revokedAt),
263
- eq(users.status, 'active'),
264
- ),
265
- )
266
- .limit(1);
267
- if (activeOwners.length > 0) throw new AdminBootstrapClosedError();
275
+ if (await hasActiveAdminOwner(tx)) throw new AdminBootstrapClosedError();
268
276
 
269
277
  const userRows = await tx
270
278
  .select({id: users.id, status: users.status})
@@ -358,17 +366,7 @@ export async function revokeAdminGrantWithAudit(
358
366
  if (!grant) throw new AdminGrantNotFoundError();
359
367
 
360
368
  if (grant.role === 'admin-owner') {
361
- const activeOwners = await tx
362
- .select({id: adminGrants.id})
363
- .from(adminGrants)
364
- .innerJoin(users, eq(adminGrants.userId, users.id))
365
- .where(
366
- and(
367
- eq(adminGrants.role, 'admin-owner'),
368
- isNull(adminGrants.revokedAt),
369
- eq(users.status, 'active'),
370
- ),
371
- );
369
+ const activeOwners = await listActiveAdminOwners(tx);
372
370
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
373
371
  }
374
372
 
package/src/index.test.ts CHANGED
@@ -78,7 +78,10 @@ describe('authModule', () => {
78
78
  getWorkspaceOperatingState: vi.fn(),
79
79
  },
80
80
  });
81
- expect(module.routes).toHaveLength(3);
81
+ expect(module.routes).toHaveLength(4);
82
+ expect(module.routes).toEqual(
83
+ expect.arrayContaining([expect.objectContaining({prefix: '/admin/auth'})]),
84
+ );
82
85
  const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
83
86
 
84
87
  expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-a
18
18
  import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
19
19
  import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
20
20
  import {
21
+ administrationBootstrapRoutes,
21
22
  administrationRoutes,
22
23
  administrationUserRoutes,
23
24
  } from '#presentation/routes/administration.js';
@@ -114,6 +115,7 @@ export function createAuthModule({
114
115
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
115
116
  routes: [
116
117
  buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
118
+ administrationBootstrapRoutes,
117
119
  administrationRoutes,
118
120
  administrationUserRoutes,
119
121
  ],
@@ -3,7 +3,12 @@ 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' | 'bootstrap' | 'lookup';
6
+ export type AuthRateLimitAction =
7
+ | 'login'
8
+ | 'email-send'
9
+ | 'bootstrap'
10
+ | 'bootstrap-state'
11
+ | 'lookup';
7
12
  export type AuthRateLimitScope = 'ip' | 'email';
8
13
  export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
9
14
 
@@ -1,8 +1,10 @@
1
1
  import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
2
2
  import {sql} from 'drizzle-orm';
3
3
  import type {FastifyInstance} from 'fastify';
4
+ import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
4
5
  import {db} from '#db/db.js';
5
6
  import {authOutbox} from '#db/schema/outbox.js';
7
+ import {authRateLimits} from '#db/schema/rate-limits.js';
6
8
  import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
7
9
 
8
10
  const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
@@ -20,6 +22,30 @@ function authHeaders(token: string, idempotencyKey: string) {
20
22
  };
21
23
  }
22
24
 
25
+ async function seedExhaustedIpBucket(params: {
26
+ action: AuthRateLimitAction;
27
+ identifier: string;
28
+ limit: number;
29
+ windowSeconds: number;
30
+ }): Promise<void> {
31
+ const windowMs = params.windowSeconds * 1000;
32
+ const windowStart = new Date(Math.floor(Date.now() / windowMs) * windowMs);
33
+ await db()
34
+ .insert(authRateLimits)
35
+ .values({
36
+ action: params.action,
37
+ scope: 'ip',
38
+ identifierHmac: hashAuthRateLimitIdentifier({
39
+ action: params.action,
40
+ scope: 'ip',
41
+ identifier: params.identifier,
42
+ }),
43
+ windowStart,
44
+ count: params.limit,
45
+ expiresAt: new Date(windowStart.getTime() + windowMs),
46
+ });
47
+ }
48
+
23
49
  describe('Auth administration routes', () => {
24
50
  let app: FastifyInstance;
25
51
 
@@ -48,6 +74,78 @@ describe('Auth administration routes', () => {
48
74
  expect(response.statusCode).toBe(401);
49
75
  });
50
76
 
77
+ test('requires an authenticated session for bootstrap state', async () => {
78
+ const response = await app.inject({
79
+ method: 'GET',
80
+ url: '/admin/auth/bootstrap-state',
81
+ });
82
+
83
+ expect(response.statusCode).toBe(401);
84
+ });
85
+
86
+ test('reports only available or closed bootstrap state', async () => {
87
+ const account = await createVerifiedSession('admin-bootstrap-state');
88
+
89
+ const available = await app.inject({
90
+ method: 'GET',
91
+ url: '/admin/auth/bootstrap-state',
92
+ headers: {authorization: `Bearer ${account.token}`},
93
+ });
94
+
95
+ expect(available.statusCode).toBe(200);
96
+ expect(available.json()).toEqual({state: 'available'});
97
+
98
+ const bootstrap = await app.inject({
99
+ method: 'POST',
100
+ url: '/admin/auth/admin-grants/bootstrap',
101
+ headers: authHeaders(account.token, 'bootstrap-state-bootstrap'),
102
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
103
+ });
104
+ expect(bootstrap.statusCode).toBe(201);
105
+
106
+ const closed = await app.inject({
107
+ method: 'GET',
108
+ url: '/admin/auth/bootstrap-state',
109
+ headers: {authorization: `Bearer ${account.token}`},
110
+ });
111
+
112
+ expect(closed.statusCode).toBe(200);
113
+ expect(closed.json()).toEqual({state: 'closed'});
114
+ expect(JSON.stringify(closed.json())).not.toContain(BOOTSTRAP_TOKEN);
115
+ });
116
+
117
+ test('keeps bootstrap-state reads separate from the bootstrap write limit', async () => {
118
+ const account = await createVerifiedSession('admin-bootstrap-state-rate-limit');
119
+ const ip = '127.0.0.1';
120
+
121
+ await seedExhaustedIpBucket({
122
+ action: 'bootstrap',
123
+ identifier: ip,
124
+ limit: 5,
125
+ windowSeconds: 15 * 60,
126
+ });
127
+ const state = await app.inject({
128
+ method: 'GET',
129
+ url: '/admin/auth/bootstrap-state',
130
+ headers: {authorization: `Bearer ${account.token}`},
131
+ });
132
+ expect(state.statusCode).toBe(200);
133
+
134
+ await resetAdministrationState();
135
+ await seedExhaustedIpBucket({
136
+ action: 'bootstrap-state',
137
+ identifier: ip,
138
+ limit: 60,
139
+ windowSeconds: 5 * 60,
140
+ });
141
+ const blocked = await app.inject({
142
+ method: 'GET',
143
+ url: '/admin/auth/bootstrap-state',
144
+ headers: {authorization: `Bearer ${account.token}`},
145
+ });
146
+ expect(blocked.statusCode).toBe(429);
147
+ });
148
+
51
149
  test('does not register the removed versioned administration namespace', async () => {
52
150
  const response = await app.inject({
53
151
  method: 'GET',
@@ -140,6 +238,36 @@ describe('Auth administration routes', () => {
140
238
  expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
141
239
  });
142
240
 
241
+ test('serializes concurrent bootstrap attempts to one active first owner', async () => {
242
+ const first = await createVerifiedSession('admin-bootstrap-race-first');
243
+ const second = await createVerifiedSession('admin-bootstrap-race-second');
244
+
245
+ const responses = await Promise.all(
246
+ [first, second].map((account, index) =>
247
+ app.inject({
248
+ method: 'POST',
249
+ url: '/admin/auth/admin-grants/bootstrap',
250
+ headers: authHeaders(account.token, `bootstrap-race-${index}`),
251
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
252
+ }),
253
+ ),
254
+ );
255
+
256
+ expect(responses.map((response) => response.statusCode).sort()).toEqual([201, 409]);
257
+ const activeOwnerRoles = await Promise.all(
258
+ [first, second].map(async (account) => {
259
+ const response = await app.inject({
260
+ method: 'GET',
261
+ url: `/admin/auth/users?user_id=${account.userId}`,
262
+ headers: {authorization: `Bearer ${account.token}`},
263
+ });
264
+ return response.statusCode === 200 && response.json().admin_role === 'admin-owner';
265
+ }),
266
+ );
267
+
268
+ expect(activeOwnerRoles.filter(Boolean)).toHaveLength(1);
269
+ });
270
+
143
271
  test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
144
272
  const owner = await createVerifiedSession('admin-grant-owner');
145
273
  const target = await createVerifiedSession('admin-grant-target');
@@ -1,5 +1,6 @@
1
1
  import {AUTH_USER} from '@shipfox/api-auth-context';
2
2
  import {
3
+ adminBootstrapStateSchema,
3
4
  administratorUserLookupQuerySchema,
4
5
  administratorUserSummarySchema,
5
6
  bootstrapAdminOwnerBodySchema,
@@ -19,6 +20,7 @@ import {requireAdminRole} from '#core/admin-role.js';
19
20
  import {
20
21
  bootstrapFirstAdminOwner,
21
22
  findAdministratorUserSummary,
23
+ getAdminBootstrapState,
22
24
  grantAdministratorRole,
23
25
  listAdministratorGrantSummaries,
24
26
  revokeAdministratorGrant,
@@ -165,6 +167,16 @@ const bootstrapRoute = defineRoute({
165
167
  },
166
168
  });
167
169
 
170
+ const bootstrapStateRoute = defineRoute({
171
+ method: 'GET',
172
+ path: '/bootstrap-state',
173
+ description: 'Read whether first administrator owner bootstrap is available.',
174
+ schema: {response: {200: adminBootstrapStateSchema}},
175
+ preHandler: createAuthIpRateLimitPreHandler('bootstrap-state'),
176
+ errorHandler: translateAdministrationError,
177
+ handler: async () => ({state: await getAdminBootstrapState()}),
178
+ });
179
+
168
180
  const listRoute = defineRoute({
169
181
  method: 'GET',
170
182
  path: '/',
@@ -269,6 +281,12 @@ export const administrationRoutes: RouteGroup = {
269
281
  routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
270
282
  };
271
283
 
284
+ export const administrationBootstrapRoutes: RouteGroup = {
285
+ prefix: '/admin/auth',
286
+ auth: AUTH_USER,
287
+ routes: [bootstrapStateRoute],
288
+ };
289
+
272
290
  export const administrationUserRoutes: RouteGroup = {
273
291
  prefix: '/admin/auth/users',
274
292
  auth: AUTH_USER,
@@ -23,6 +23,9 @@ const policies: Record<
23
23
  bootstrap: {
24
24
  ip: {limit: 5, windowSeconds: 15 * 60},
25
25
  },
26
+ 'bootstrap-state': {
27
+ ip: {limit: 60, windowSeconds: 5 * 60},
28
+ },
26
29
  lookup: {
27
30
  ip: {limit: 60, windowSeconds: 5 * 60},
28
31
  },
package/test/routes.ts CHANGED
@@ -9,6 +9,7 @@ import {db} from '#db/db.js';
9
9
  import {authOutbox} from '#db/schema/outbox.js';
10
10
  import {createJwtAuthMethod} from '#presentation/auth/jwt-auth.js';
11
11
  import {
12
+ administrationBootstrapRoutes,
12
13
  administrationRoutes,
13
14
  administrationUserRoutes,
14
15
  } from '#presentation/routes/administration.js';
@@ -156,7 +157,12 @@ export async function createAuthTestApp(params?: {
156
157
  }): Promise<FastifyInstance> {
157
158
  const appConfig: AppConfig = {
158
159
  auth: [createJwtAuthMethod()],
159
- routes: [buildAuthRoutes(true, workspaces), administrationRoutes, administrationUserRoutes],
160
+ routes: [
161
+ buildAuthRoutes(true, workspaces),
162
+ administrationBootstrapRoutes,
163
+ administrationRoutes,
164
+ administrationUserRoutes,
165
+ ],
160
166
  swagger: false,
161
167
  };
162
168
  if (params?.fastifyOptions) appConfig.fastifyOptions = params.fastifyOptions;