@shipfox/api-auth 9.3.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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +14 -3
  4. package/dist/core/administration.d.ts +19 -2
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +37 -4
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/entities/administrator-read-model.d.ts +21 -0
  9. package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
  10. package/dist/core/entities/administrator-read-model.js +3 -0
  11. package/dist/core/entities/administrator-read-model.js.map +1 -0
  12. package/dist/db/admin-grants.d.ts +25 -1
  13. package/dist/db/admin-grants.d.ts.map +1 -1
  14. package/dist/db/admin-grants.js +51 -14
  15. package/dist/db/admin-grants.js.map +1 -1
  16. package/dist/db/admin-users.d.ts +21 -0
  17. package/dist/db/admin-users.d.ts.map +1 -0
  18. package/dist/db/admin-users.js +32 -0
  19. package/dist/db/admin-users.js.map +1 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/metrics/instance.d.ts +1 -1
  25. package/dist/metrics/instance.d.ts.map +1 -1
  26. package/dist/metrics/instance.js.map +1 -1
  27. package/dist/presentation/routes/administration.d.ts +2 -0
  28. package/dist/presentation/routes/administration.d.ts.map +1 -1
  29. package/dist/presentation/routes/administration.js +113 -10
  30. package/dist/presentation/routes/administration.js.map +1 -1
  31. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  32. package/dist/presentation/routes/rate-limit.js +12 -0
  33. package/dist/presentation/routes/rate-limit.js.map +1 -1
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +7 -7
  36. package/src/core/administration.ts +50 -4
  37. package/src/core/entities/administrator-read-model.ts +23 -0
  38. package/src/db/admin-grants.test.ts +12 -2
  39. package/src/db/admin-grants.ts +85 -39
  40. package/src/db/admin-users.ts +58 -0
  41. package/src/index.test.ts +4 -1
  42. package/src/index.ts +7 -2
  43. package/src/metrics/instance.ts +6 -1
  44. package/src/presentation/routes/administration.test.ts +432 -20
  45. package/src/presentation/routes/administration.ts +109 -10
  46. package/src/presentation/routes/rate-limit.ts +6 -0
  47. package/test/routes.ts +11 -2
  48. 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": "9.3.0",
4
+ "version": "10.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,19 +27,19 @@
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": "9.3.0",
31
- "@shipfox/api-auth-dto": "9.3.0",
32
- "@shipfox/api-email-challenges": "1.1.5",
33
- "@shipfox/api-workspaces-dto": "9.3.0",
30
+ "@shipfox/api-auth-context": "10.1.0",
31
+ "@shipfox/api-auth-dto": "10.1.0",
32
+ "@shipfox/api-email-challenges": "1.1.6",
33
+ "@shipfox/api-workspaces-dto": "10.0.0",
34
34
  "@shipfox/inter-module": "0.2.2",
35
35
  "@shipfox/config": "1.2.4",
36
36
  "@shipfox/node-drizzle": "0.3.4",
37
37
  "@shipfox/node-auth-root-key": "0.2.3",
38
- "@shipfox/node-fastify": "0.3.4",
38
+ "@shipfox/node-fastify": "0.4.0",
39
39
  "@shipfox/node-jwt": "0.3.2",
40
40
  "@shipfox/node-email": "0.3.4",
41
41
  "@shipfox/node-mailer": "0.2.4",
42
- "@shipfox/node-module": "1.0.3",
42
+ "@shipfox/node-module": "1.0.4",
43
43
  "@shipfox/node-opentelemetry": "0.6.3",
44
44
  "@shipfox/node-outbox": "0.2.6",
45
45
  "@shipfox/node-postgres": "0.4.4",
@@ -1,15 +1,22 @@
1
1
  import {createHash, timingSafeEqual} from 'node:crypto';
2
2
  import type {AdminRole} from '@shipfox/api-auth-dto';
3
3
  import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
4
+ import type {TimestampIdCursor} from '@shipfox/node-drizzle';
4
5
  import {config} from '#config.js';
5
6
  import {
6
7
  bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
7
8
  grantAdminRoleWithAudit,
8
- listAdminGrants,
9
+ hasActiveAdminOwner,
10
+ listAdminGrantSummaries,
9
11
  revokeAdminGrantWithAudit,
10
12
  } from '#db/admin-grants.js';
13
+ import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
11
14
  import {requireAdminRole} from './admin-role.js';
12
15
  import type {AdminGrant} from './entities/admin-grant.js';
16
+ import type {
17
+ AdministratorGrantSummary,
18
+ AdministratorUserSummary,
19
+ } from './entities/administrator-read-model.js';
13
20
  import {
14
21
  AdminBootstrapClosedError,
15
22
  AdminGrantAlreadyExistsError,
@@ -21,6 +28,7 @@ import {
21
28
  } from './errors.js';
22
29
 
23
30
  const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
31
+ const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
24
32
  const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
25
33
  const GRANT_COMMAND = 'auth.admin_grant.grant';
26
34
  const REVOKE_COMMAND = 'auth.admin_grant.revoke';
@@ -98,9 +106,47 @@ export async function bootstrapFirstAdminOwner(
98
106
  });
99
107
  }
100
108
 
101
- export async function listAdministratorGrants(params: {actorId: string}): Promise<AdminGrant[]> {
102
- await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OWNER_ROLE});
103
- return await listAdminGrants();
109
+ export async function getAdminBootstrapState(): Promise<'available' | 'closed'> {
110
+ return (await hasActiveAdminOwner()) ? 'closed' : 'available';
111
+ }
112
+
113
+ export async function findAdministratorUserSummary(
114
+ params: {actorId: string} & ({id: string; email?: never} | {email: string; id?: never}),
115
+ ): Promise<AdministratorUserSummary | undefined> {
116
+ await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OBSERVER_ROLE});
117
+
118
+ const user = await findAdministratorUserInDb(
119
+ 'id' in params ? {id: params.id} : {email: params.email},
120
+ );
121
+ if (!user) return undefined;
122
+
123
+ return user;
124
+ }
125
+
126
+ export async function listAdministratorGrantSummaries(params: {
127
+ actorId: string;
128
+ limit: number;
129
+ cursor?: TimestampIdCursor;
130
+ }): Promise<{
131
+ grants: AdministratorGrantSummary[];
132
+ nextCursor: TimestampIdCursor | null;
133
+ }> {
134
+ await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OBSERVER_ROLE});
135
+
136
+ const result = await listAdminGrantSummaries({
137
+ limit: params.limit,
138
+ ...(params.cursor ? {cursor: params.cursor} : {}),
139
+ });
140
+ return {
141
+ grants: result.rows.map((row) => ({
142
+ grantId: row.id,
143
+ role: row.role,
144
+ createdAt: row.createdAt,
145
+ revokedAt: row.revokedAt,
146
+ user: row.user,
147
+ })),
148
+ nextCursor: result.nextCursor,
149
+ };
104
150
  }
105
151
 
106
152
  export async function grantAdministratorRole(
@@ -0,0 +1,23 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import type {UserStatus} from './user.js';
3
+
4
+ export interface AdministratorUserIdentity {
5
+ id: string;
6
+ email: string;
7
+ name: string | null;
8
+ status: UserStatus;
9
+ }
10
+
11
+ export interface AdministratorUserSummary extends AdministratorUserIdentity {
12
+ emailVerifiedAt: Date | null;
13
+ createdAt: Date;
14
+ adminRole: AdminRole | null;
15
+ }
16
+
17
+ export interface AdministratorGrantSummary {
18
+ grantId: string;
19
+ role: AdminRole;
20
+ createdAt: Date;
21
+ revokedAt: Date | null;
22
+ user: AdministratorUserIdentity;
23
+ }
@@ -4,7 +4,7 @@ import {userFactory} from '#test/index.js';
4
4
  import {
5
5
  createAdminGrant,
6
6
  findCurrentAdminRole,
7
- listAdminGrants,
7
+ hasActiveAdminOwner,
8
8
  revokeAdminGrant,
9
9
  } from './admin-grants.js';
10
10
  import {db} from './db.js';
@@ -18,7 +18,6 @@ describe('admin grants db', () => {
18
18
 
19
19
  expect(observer.userId).toBe(user.id);
20
20
  expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-operator');
21
- expect((await listAdminGrants()).filter((grant) => grant.userId === user.id)).toHaveLength(2);
22
21
  });
23
22
 
24
23
  test('does not evaluate revoked grants or grants for suspended users', async () => {
@@ -32,6 +31,17 @@ describe('admin grants db', () => {
32
31
  expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
33
32
  });
34
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
+
35
45
  test('prevents revoking the final active owner but permits replacement first', async () => {
36
46
  const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
37
47
  const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
@@ -3,10 +3,16 @@ import type {
3
3
  AdministrationActionEvent,
4
4
  AdministrationActionEventMap,
5
5
  } from '@shipfox/api-common-dto';
6
+ import {
7
+ paginateTimestampIdRows,
8
+ type TimestampIdCursor,
9
+ timestampIdCursorWhere,
10
+ } from '@shipfox/node-drizzle';
6
11
  import {writeOutboxEvent} from '@shipfox/node-outbox';
7
- import {and, asc, eq, isNull, sql} from 'drizzle-orm';
12
+ import {and, desc, eq, isNull, sql} from 'drizzle-orm';
8
13
  import {highestAdminRole} from '#core/admin-role-model.js';
9
14
  import type {AdminGrant} from '#core/entities/admin-grant.js';
15
+ import type {AdministratorGrantSummary} from '#core/entities/administrator-read-model.js';
10
16
  import {
11
17
  AdminBootstrapClosedError,
12
18
  AdminGrantAlreadyExistsError,
@@ -26,6 +32,24 @@ import {authOutbox} from './schema/outbox.js';
26
32
  import {users} from './schema/users.js';
27
33
 
28
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
+ }
29
53
 
30
54
  export interface CreateAdminGrantParams {
31
55
  userId: string;
@@ -42,9 +66,51 @@ export async function createAdminGrant(params: CreateAdminGrantParams): Promise<
42
66
  return toAdminGrant(row);
43
67
  }
44
68
 
45
- export async function listAdminGrants(): Promise<AdminGrant[]> {
46
- const rows = await db().select().from(adminGrants).orderBy(asc(adminGrants.createdAt));
47
- return rows.map(toAdminGrant);
69
+ export interface AdministratorGrantSummaryRecord {
70
+ id: string;
71
+ role: AdminRole;
72
+ createdAt: Date;
73
+ revokedAt: Date | null;
74
+ user: AdministratorGrantSummary['user'];
75
+ }
76
+
77
+ export async function listAdminGrantSummaries(params: {
78
+ limit: number;
79
+ cursor?: TimestampIdCursor;
80
+ }): Promise<{
81
+ rows: AdministratorGrantSummaryRecord[];
82
+ nextCursor: TimestampIdCursor | null;
83
+ }> {
84
+ const cursorCondition = timestampIdCursorWhere({
85
+ timestampColumn: adminGrants.createdAt,
86
+ idColumn: adminGrants.id,
87
+ cursor: params.cursor,
88
+ });
89
+ const conditions = cursorCondition ? [cursorCondition] : [];
90
+ const rows = await db()
91
+ .select({
92
+ id: adminGrants.id,
93
+ role: adminGrants.role,
94
+ createdAt: adminGrants.createdAt,
95
+ revokedAt: adminGrants.revokedAt,
96
+ user: {
97
+ id: users.id,
98
+ email: users.email,
99
+ name: users.name,
100
+ status: users.status,
101
+ },
102
+ })
103
+ .from(adminGrants)
104
+ .innerJoin(users, eq(adminGrants.userId, users.id))
105
+ .where(and(...conditions))
106
+ .orderBy(desc(adminGrants.createdAt), desc(adminGrants.id))
107
+ .limit(params.limit + 1);
108
+
109
+ const page = paginateTimestampIdRows({rows, limit: params.limit, timestampKey: 'createdAt'});
110
+ return {
111
+ rows: page.pageRows,
112
+ nextCursor: page.nextCursor,
113
+ };
48
114
  }
49
115
 
50
116
  export async function findCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
@@ -63,6 +129,18 @@ export async function findCurrentAdminRole(params: {userId: string}): Promise<Ad
63
129
  return highestAdminRole(rows.map(({role}) => role));
64
130
  }
65
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
+
66
144
  export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
67
145
  return await db().transaction(async (tx) => {
68
146
  // All owner grant changes share one lock so concurrent revocations cannot
@@ -78,17 +156,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
78
156
  if (!grant) return undefined;
79
157
 
80
158
  if (grant.role === 'admin-owner') {
81
- const activeOwners = await tx
82
- .select({id: adminGrants.id})
83
- .from(adminGrants)
84
- .innerJoin(users, eq(adminGrants.userId, users.id))
85
- .where(
86
- and(
87
- eq(adminGrants.role, 'admin-owner'),
88
- isNull(adminGrants.revokedAt),
89
- eq(users.status, 'active'),
90
- ),
91
- );
159
+ const activeOwners = await listActiveAdminOwners(tx);
92
160
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
93
161
  }
94
162
 
@@ -204,19 +272,7 @@ export async function bootstrapFirstAdminOwner(
204
272
  });
205
273
  if (existing) return existing;
206
274
 
207
- const activeOwners = await tx
208
- .select({id: adminGrants.id})
209
- .from(adminGrants)
210
- .innerJoin(users, eq(adminGrants.userId, users.id))
211
- .where(
212
- and(
213
- eq(adminGrants.role, 'admin-owner'),
214
- isNull(adminGrants.revokedAt),
215
- eq(users.status, 'active'),
216
- ),
217
- )
218
- .limit(1);
219
- if (activeOwners.length > 0) throw new AdminBootstrapClosedError();
275
+ if (await hasActiveAdminOwner(tx)) throw new AdminBootstrapClosedError();
220
276
 
221
277
  const userRows = await tx
222
278
  .select({id: users.id, status: users.status})
@@ -310,17 +366,7 @@ export async function revokeAdminGrantWithAudit(
310
366
  if (!grant) throw new AdminGrantNotFoundError();
311
367
 
312
368
  if (grant.role === 'admin-owner') {
313
- const activeOwners = await tx
314
- .select({id: adminGrants.id})
315
- .from(adminGrants)
316
- .innerJoin(users, eq(adminGrants.userId, users.id))
317
- .where(
318
- and(
319
- eq(adminGrants.role, 'admin-owner'),
320
- isNull(adminGrants.revokedAt),
321
- eq(users.status, 'active'),
322
- ),
323
- );
369
+ const activeOwners = await listActiveAdminOwners(tx);
324
370
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
325
371
  }
326
372
 
@@ -0,0 +1,58 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import {and, eq, isNull} from 'drizzle-orm';
3
+ import {highestAdminRole} from '#core/admin-role-model.js';
4
+ import type {UserStatus} from '#core/entities/user.js';
5
+ import {db} from './db.js';
6
+ import {adminGrants} from './schema/admin-grants.js';
7
+ import {users} from './schema/users.js';
8
+
9
+ export interface AdministratorUserRecord {
10
+ id: string;
11
+ email: string;
12
+ name: string | null;
13
+ emailVerifiedAt: Date | null;
14
+ status: UserStatus;
15
+ createdAt: Date;
16
+ adminRole: AdminRole | null;
17
+ }
18
+
19
+ type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
20
+
21
+ export async function findAdministratorUser(
22
+ params: AdministratorUserLookup,
23
+ ): Promise<AdministratorUserRecord | undefined> {
24
+ const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
25
+ const rows = await db()
26
+ .select({
27
+ id: users.id,
28
+ email: users.email,
29
+ name: users.name,
30
+ emailVerifiedAt: users.emailVerifiedAt,
31
+ status: users.status,
32
+ createdAt: users.createdAt,
33
+ adminRole: adminGrants.role,
34
+ })
35
+ .from(users)
36
+ .leftJoin(
37
+ adminGrants,
38
+ and(
39
+ eq(adminGrants.userId, users.id),
40
+ isNull(adminGrants.revokedAt),
41
+ eq(users.status, 'active'),
42
+ ),
43
+ )
44
+ .where(identifier);
45
+
46
+ const first = rows[0];
47
+ if (!first) return undefined;
48
+
49
+ return {
50
+ id: first.id,
51
+ email: first.email,
52
+ name: first.name,
53
+ emailVerifiedAt: first.emailVerifiedAt,
54
+ status: first.status,
55
+ createdAt: first.createdAt,
56
+ adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
57
+ };
58
+ }
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(2);
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
@@ -17,7 +17,11 @@ import {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js
17
17
  import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-auth.js';
18
18
  import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
19
19
  import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
20
- import {administrationRoutes} from '#presentation/routes/administration.js';
20
+ import {
21
+ administrationBootstrapRoutes,
22
+ administrationRoutes,
23
+ administrationUserRoutes,
24
+ } from '#presentation/routes/administration.js';
21
25
  import {buildAuthRoutes} from '#presentation/routes/index.js';
22
26
  import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
23
27
  import {passwordLoginMethods} from './login-methods.js';
@@ -36,7 +40,6 @@ export {
36
40
  export {
37
41
  bootstrapFirstAdminOwner,
38
42
  grantAdministratorRole,
39
- listAdministratorGrants,
40
43
  revokeAdministratorGrant,
41
44
  } from '#core/administration.js';
42
45
  export type {
@@ -112,7 +115,9 @@ export function createAuthModule({
112
115
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
113
116
  routes: [
114
117
  buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
118
+ administrationBootstrapRoutes,
115
119
  administrationRoutes,
120
+ administrationUserRoutes,
116
121
  ],
117
122
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
118
123
  publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
@@ -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';
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