@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,20 +26,21 @@
|
|
|
26
26
|
"@node-rs/argon2": "^2.0.2",
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-
|
|
30
|
-
"@shipfox/api-auth-
|
|
31
|
-
"@shipfox/api-
|
|
32
|
-
"@shipfox/api-
|
|
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",
|
|
33
34
|
"@shipfox/inter-module": "0.2.2",
|
|
34
35
|
"@shipfox/config": "1.2.4",
|
|
35
36
|
"@shipfox/node-drizzle": "0.3.4",
|
|
36
37
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
37
|
-
"@shipfox/node-fastify": "0.3.
|
|
38
|
+
"@shipfox/node-fastify": "0.3.4",
|
|
38
39
|
"@shipfox/node-jwt": "0.3.2",
|
|
39
40
|
"@shipfox/node-email": "0.3.4",
|
|
40
|
-
"@shipfox/node-mailer": "0.2.
|
|
41
|
-
"@shipfox/node-module": "1.0.
|
|
42
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
41
|
+
"@shipfox/node-mailer": "0.2.4",
|
|
42
|
+
"@shipfox/node-module": "1.0.3",
|
|
43
|
+
"@shipfox/node-opentelemetry": "0.6.3",
|
|
43
44
|
"@shipfox/node-outbox": "0.2.6",
|
|
44
45
|
"@shipfox/node-postgres": "0.4.4",
|
|
45
46
|
"@shipfox/node-rate-limit": "0.3.2",
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import {bool, createConfig, num, str} from '@shipfox/config';
|
|
2
2
|
|
|
3
3
|
export const config = createConfig({
|
|
4
|
+
ADMIN_BOOTSTRAP_TOKEN: str({
|
|
5
|
+
desc: 'Deployment secret accepted once to create the first administrator owner. Set it before bootstrap and remove or rotate it after successful bootstrap.',
|
|
6
|
+
default: undefined,
|
|
7
|
+
}),
|
|
4
8
|
AUTH_JWT_EXPIRES_IN: str({
|
|
5
9
|
desc: 'How long an access token stays valid. Accepts a duration string such as 15m, 1h, or 7d.',
|
|
6
10
|
default: '15m',
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {createHash, timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
3
|
+
import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
4
|
+
import {config} from '#config.js';
|
|
5
|
+
import {
|
|
6
|
+
bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
|
|
7
|
+
grantAdminRoleWithAudit,
|
|
8
|
+
listAdminGrants,
|
|
9
|
+
revokeAdminGrantWithAudit,
|
|
10
|
+
} from '#db/admin-grants.js';
|
|
11
|
+
import {requireAdminRole} from './admin-role.js';
|
|
12
|
+
import type {AdminGrant} from './entities/admin-grant.js';
|
|
13
|
+
import {
|
|
14
|
+
AdminBootstrapClosedError,
|
|
15
|
+
AdminGrantAlreadyExistsError,
|
|
16
|
+
AdminGrantNotFoundError,
|
|
17
|
+
AdminIdempotencyKeyReuseError,
|
|
18
|
+
InvalidAdminBootstrapTokenError,
|
|
19
|
+
LastAdminOwnerError,
|
|
20
|
+
UserNotFoundError,
|
|
21
|
+
} from './errors.js';
|
|
22
|
+
|
|
23
|
+
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
24
|
+
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
25
|
+
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
26
|
+
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
27
|
+
|
|
28
|
+
export function hashAdministrationValue(value: string): string {
|
|
29
|
+
return createHash('sha256').update(value).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function administrationCommandFingerprint(command: string, input: unknown): string {
|
|
33
|
+
return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
|
|
37
|
+
if (!expected) return false;
|
|
38
|
+
const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
|
|
39
|
+
const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
|
|
40
|
+
return timingSafeEqual(candidateHash, expectedHash);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function administrationEvent(params: {
|
|
44
|
+
actorId: string;
|
|
45
|
+
actorRole: AdminRole;
|
|
46
|
+
command: string;
|
|
47
|
+
targetType: string;
|
|
48
|
+
targetId: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
correlationId: string;
|
|
51
|
+
idempotencyKeyFingerprint: string;
|
|
52
|
+
}) {
|
|
53
|
+
return createAdministrationActionEvent({
|
|
54
|
+
actorId: params.actorId,
|
|
55
|
+
actorRole: params.actorRole,
|
|
56
|
+
requiredRole: ADMIN_OWNER_ROLE,
|
|
57
|
+
command: params.command,
|
|
58
|
+
targetType: params.targetType,
|
|
59
|
+
targetId: params.targetId,
|
|
60
|
+
reason: params.reason,
|
|
61
|
+
result: 'succeeded',
|
|
62
|
+
correlationId: params.correlationId,
|
|
63
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
64
|
+
occurredAt: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AdministrationMutationContext {
|
|
69
|
+
actorId: string;
|
|
70
|
+
idempotencyKey: string;
|
|
71
|
+
correlationId: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function bootstrapFirstAdminOwner(
|
|
75
|
+
params: AdministrationMutationContext & {bootstrapToken: string},
|
|
76
|
+
): Promise<AdminGrant> {
|
|
77
|
+
if (!bootstrapTokenMatches(params.bootstrapToken, config.ADMIN_BOOTSTRAP_TOKEN)) {
|
|
78
|
+
throw new InvalidAdminBootstrapTokenError();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return await bootstrapFirstAdminOwnerInDb({
|
|
82
|
+
userId: params.actorId,
|
|
83
|
+
actorId: params.actorId,
|
|
84
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
85
|
+
requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
|
|
86
|
+
actorId: params.actorId,
|
|
87
|
+
}),
|
|
88
|
+
event: administrationEvent({
|
|
89
|
+
actorId: params.actorId,
|
|
90
|
+
actorRole: ADMIN_OWNER_ROLE,
|
|
91
|
+
command: BOOTSTRAP_COMMAND,
|
|
92
|
+
targetType: 'user',
|
|
93
|
+
targetId: params.actorId,
|
|
94
|
+
reason: 'Initial administrator owner bootstrap',
|
|
95
|
+
correlationId: params.correlationId,
|
|
96
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function listAdministratorGrants(params: {actorId: string}): Promise<AdminGrant[]> {
|
|
102
|
+
await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OWNER_ROLE});
|
|
103
|
+
return await listAdminGrants();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function grantAdministratorRole(
|
|
107
|
+
params: AdministrationMutationContext & {userId: string; role: AdminRole; reason: string},
|
|
108
|
+
): Promise<AdminGrant> {
|
|
109
|
+
const actorRole = await requireAdminRole({
|
|
110
|
+
userId: params.actorId,
|
|
111
|
+
minimumRole: ADMIN_OWNER_ROLE,
|
|
112
|
+
});
|
|
113
|
+
return await grantAdminRoleWithAudit({
|
|
114
|
+
userId: params.userId,
|
|
115
|
+
role: params.role,
|
|
116
|
+
actorId: params.actorId,
|
|
117
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
118
|
+
requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
|
|
119
|
+
userId: params.userId,
|
|
120
|
+
role: params.role,
|
|
121
|
+
reason: params.reason,
|
|
122
|
+
}),
|
|
123
|
+
event: administrationEvent({
|
|
124
|
+
actorId: params.actorId,
|
|
125
|
+
actorRole,
|
|
126
|
+
command: GRANT_COMMAND,
|
|
127
|
+
targetType: 'user',
|
|
128
|
+
targetId: params.userId,
|
|
129
|
+
reason: params.reason,
|
|
130
|
+
correlationId: params.correlationId,
|
|
131
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function revokeAdministratorGrant(
|
|
137
|
+
params: AdministrationMutationContext & {grantId: string; reason: string},
|
|
138
|
+
): Promise<AdminGrant> {
|
|
139
|
+
const actorRole = await requireAdminRole({
|
|
140
|
+
userId: params.actorId,
|
|
141
|
+
minimumRole: ADMIN_OWNER_ROLE,
|
|
142
|
+
});
|
|
143
|
+
return await revokeAdminGrantWithAudit({
|
|
144
|
+
grantId: params.grantId,
|
|
145
|
+
actorId: params.actorId,
|
|
146
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
147
|
+
requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
|
|
148
|
+
grantId: params.grantId,
|
|
149
|
+
reason: params.reason,
|
|
150
|
+
}),
|
|
151
|
+
event: administrationEvent({
|
|
152
|
+
actorId: params.actorId,
|
|
153
|
+
actorRole,
|
|
154
|
+
command: REVOKE_COMMAND,
|
|
155
|
+
targetType: 'admin-grant',
|
|
156
|
+
targetId: params.grantId,
|
|
157
|
+
reason: params.reason,
|
|
158
|
+
correlationId: params.correlationId,
|
|
159
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export {
|
|
165
|
+
AdminBootstrapClosedError,
|
|
166
|
+
AdminGrantAlreadyExistsError,
|
|
167
|
+
AdminGrantNotFoundError,
|
|
168
|
+
AdminIdempotencyKeyReuseError,
|
|
169
|
+
InvalidAdminBootstrapTokenError,
|
|
170
|
+
LastAdminOwnerError,
|
|
171
|
+
UserNotFoundError,
|
|
172
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {provisionUser, signup} from '#core/auth.js';
|
|
2
|
+
|
|
3
|
+
const testConfig = vi.hoisted(() => ({
|
|
4
|
+
AUTH_JWT_EXPIRES_IN: '15m',
|
|
5
|
+
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
6
|
+
AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
|
|
7
|
+
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
8
|
+
AUTH_SIGNUP_GATE_ENABLED: true,
|
|
9
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: 'allowed.example',
|
|
10
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: '',
|
|
11
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined as string | undefined,
|
|
12
|
+
CLIENT_BASE_URL: 'https://app.example.test',
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('#config.js', () => ({config: testConfig}));
|
|
16
|
+
|
|
17
|
+
describe('low-level account creation defaults', () => {
|
|
18
|
+
test('signup uses the environment policy when no policy is supplied', async () => {
|
|
19
|
+
const email = `signup-default-${crypto.randomUUID()}@allowed.example`;
|
|
20
|
+
|
|
21
|
+
const user = await signup({email, password: 'correct horse battery staple'});
|
|
22
|
+
|
|
23
|
+
expect(user.email).toBe(email);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('signup rejects an unlisted email when no policy is supplied', async () => {
|
|
27
|
+
const email = `signup-default-${crypto.randomUUID()}@blocked.example`;
|
|
28
|
+
|
|
29
|
+
await expect(signup({email, password: 'correct horse battery staple'})).rejects.toMatchObject({
|
|
30
|
+
name: 'SignupNotAllowedError',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('provisionUser uses the environment policy when no policy is supplied', async () => {
|
|
35
|
+
const email = `provision-default-${crypto.randomUUID()}@allowed.example`;
|
|
36
|
+
|
|
37
|
+
const user = await provisionUser({email});
|
|
38
|
+
|
|
39
|
+
expect(user.email).toBe(email);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('provisionUser rejects an unlisted email when no policy is supplied', async () => {
|
|
43
|
+
const email = `provision-default-${crypto.randomUUID()}@blocked.example`;
|
|
44
|
+
|
|
45
|
+
await expect(provisionUser({email})).rejects.toMatchObject({
|
|
46
|
+
name: 'SignupNotAllowedError',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('an explicit policy overrides the environment policy for both paths', async () => {
|
|
51
|
+
const signupEmail = `signup-override-${crypto.randomUUID()}@blocked.example`;
|
|
52
|
+
const provisionEmail = `provision-override-${crypto.randomUUID()}@blocked.example`;
|
|
53
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
54
|
+
const signupPolicy = {isSignupAllowed};
|
|
55
|
+
|
|
56
|
+
await signup({
|
|
57
|
+
email: signupEmail,
|
|
58
|
+
password: 'correct horse battery staple',
|
|
59
|
+
signupPolicy,
|
|
60
|
+
});
|
|
61
|
+
await provisionUser({email: provisionEmail, signupPolicy});
|
|
62
|
+
|
|
63
|
+
expect(isSignupAllowed).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/core/auth.test.ts
CHANGED
|
@@ -61,6 +61,10 @@ vi.mock('#config.js', () => ({
|
|
|
61
61
|
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
62
62
|
AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
|
|
63
63
|
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
64
|
+
AUTH_SIGNUP_GATE_ENABLED: false,
|
|
65
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
|
|
66
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: '',
|
|
67
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
|
|
64
68
|
CLIENT_BASE_URL: testConfig.clientBaseUrl,
|
|
65
69
|
},
|
|
66
70
|
mailer: testConfig.mailer,
|
|
@@ -77,6 +81,7 @@ const workspaces = {
|
|
|
77
81
|
preflightInvitationAcceptance: vi.fn(),
|
|
78
82
|
acceptInvitation: vi.fn(),
|
|
79
83
|
requireActiveMembership: vi.fn(),
|
|
84
|
+
getWorkspaceOperatingState: vi.fn(),
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
const login = (params: {email: string; password: string}) => coreLogin({...params, workspaces});
|
package/src/core/auth.ts
CHANGED
|
@@ -49,15 +49,14 @@ import {
|
|
|
49
49
|
import {signUserToken} from './jwt.js';
|
|
50
50
|
import {hashPassword, verifyPassword} from './password.js';
|
|
51
51
|
import type {SignupPolicy} from './ports.js';
|
|
52
|
+
import {createEnvironmentSignupPolicy} from './signup-policy.js';
|
|
52
53
|
|
|
53
54
|
const RESET_TTL_HOURS = 1;
|
|
54
55
|
const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
|
|
55
56
|
const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
|
|
56
57
|
'This Shipfox deployment does not accept new accounts right now.';
|
|
57
58
|
|
|
58
|
-
const defaultSignupPolicy: SignupPolicy =
|
|
59
|
-
isSignupAllowed: async () => ({allowed: true}),
|
|
60
|
-
};
|
|
59
|
+
const defaultSignupPolicy: SignupPolicy = createEnvironmentSignupPolicy();
|
|
61
60
|
|
|
62
61
|
async function assertSignupAllowed(params: {
|
|
63
62
|
signupPolicy?: SignupPolicy | undefined;
|
package/src/core/errors.ts
CHANGED
|
@@ -97,6 +97,41 @@ export class LastAdminOwnerError extends Error {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export class AdminGrantNotFoundError extends Error {
|
|
101
|
+
constructor() {
|
|
102
|
+
super('Administrator grant not found');
|
|
103
|
+
this.name = 'AdminGrantNotFoundError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class AdminGrantAlreadyExistsError extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super('Administrator grant already exists');
|
|
110
|
+
this.name = 'AdminGrantAlreadyExistsError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class AdminBootstrapClosedError extends Error {
|
|
115
|
+
constructor() {
|
|
116
|
+
super('First administrator owner has already been created');
|
|
117
|
+
this.name = 'AdminBootstrapClosedError';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class InvalidAdminBootstrapTokenError extends Error {
|
|
122
|
+
constructor() {
|
|
123
|
+
super('Bootstrap token is invalid');
|
|
124
|
+
this.name = 'InvalidAdminBootstrapTokenError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class AdminIdempotencyKeyReuseError extends Error {
|
|
129
|
+
constructor() {
|
|
130
|
+
super('Idempotency key was already used for a different command');
|
|
131
|
+
this.name = 'AdminIdempotencyKeyReuseError';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
export class TokenInvalidError extends Error {
|
|
101
136
|
constructor(reason?: string) {
|
|
102
137
|
super(reason ? `Invalid token: ${reason}` : 'Invalid token');
|
package/src/db/admin-grants.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
import type {
|
|
3
|
+
AdministrationActionEvent,
|
|
4
|
+
AdministrationActionEventMap,
|
|
5
|
+
} from '@shipfox/api-common-dto';
|
|
6
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
2
7
|
import {and, asc, eq, isNull, sql} from 'drizzle-orm';
|
|
3
8
|
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
4
9
|
import type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
5
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AdminBootstrapClosedError,
|
|
12
|
+
AdminGrantAlreadyExistsError,
|
|
13
|
+
AdminGrantNotFoundError,
|
|
14
|
+
AdminIdempotencyKeyReuseError,
|
|
15
|
+
LastAdminOwnerError,
|
|
16
|
+
UserNotFoundError,
|
|
17
|
+
} from '#core/errors.js';
|
|
6
18
|
import {db} from './db.js';
|
|
19
|
+
import {
|
|
20
|
+
type AdminCommandResultDb,
|
|
21
|
+
adminCommandResults,
|
|
22
|
+
type StoredAdminGrant,
|
|
23
|
+
} from './schema/admin-command-results.js';
|
|
7
24
|
import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
|
|
25
|
+
import {authOutbox} from './schema/outbox.js';
|
|
8
26
|
import {users} from './schema/users.js';
|
|
9
27
|
|
|
28
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
29
|
+
|
|
10
30
|
export interface CreateAdminGrantParams {
|
|
11
31
|
userId: string;
|
|
12
32
|
role: AdminRole;
|
|
@@ -81,3 +101,240 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
|
|
|
81
101
|
return row ? toAdminGrant(row) : undefined;
|
|
82
102
|
});
|
|
83
103
|
}
|
|
104
|
+
|
|
105
|
+
interface AuditedAdminCommandParams {
|
|
106
|
+
actorId: string;
|
|
107
|
+
idempotencyKeyFingerprint: string;
|
|
108
|
+
requestFingerprint: string;
|
|
109
|
+
event: AdministrationActionEvent;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toStoredAdminGrant(grant: AdminGrant): StoredAdminGrant {
|
|
113
|
+
return {
|
|
114
|
+
id: grant.id,
|
|
115
|
+
userId: grant.userId,
|
|
116
|
+
role: grant.role,
|
|
117
|
+
revokedAt: grant.revokedAt?.toISOString() ?? null,
|
|
118
|
+
createdAt: grant.createdAt.toISOString(),
|
|
119
|
+
updatedAt: grant.updatedAt.toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fromStoredAdminGrant(grant: StoredAdminGrant): AdminGrant {
|
|
124
|
+
return {
|
|
125
|
+
id: grant.id,
|
|
126
|
+
userId: grant.userId,
|
|
127
|
+
role: grant.role,
|
|
128
|
+
revokedAt: grant.revokedAt ? new Date(grant.revokedAt) : null,
|
|
129
|
+
createdAt: new Date(grant.createdAt),
|
|
130
|
+
updatedAt: new Date(grant.updatedAt),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function findCommandResult(
|
|
135
|
+
tx: Tx,
|
|
136
|
+
params: Pick<
|
|
137
|
+
AuditedAdminCommandParams,
|
|
138
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
139
|
+
> & {
|
|
140
|
+
command: string;
|
|
141
|
+
},
|
|
142
|
+
): Promise<AdminGrant | undefined> {
|
|
143
|
+
const rows = await tx
|
|
144
|
+
.select()
|
|
145
|
+
.from(adminCommandResults)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(adminCommandResults.actorId, params.actorId),
|
|
149
|
+
eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
.limit(1);
|
|
153
|
+
const result: AdminCommandResultDb | undefined = rows[0];
|
|
154
|
+
if (!result) return undefined;
|
|
155
|
+
if (
|
|
156
|
+
result.command !== params.command ||
|
|
157
|
+
result.requestFingerprint !== params.requestFingerprint
|
|
158
|
+
) {
|
|
159
|
+
throw new AdminIdempotencyKeyReuseError();
|
|
160
|
+
}
|
|
161
|
+
return fromStoredAdminGrant(result.result.grant);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function storeCommandResult(
|
|
165
|
+
tx: Tx,
|
|
166
|
+
params: AuditedAdminCommandParams,
|
|
167
|
+
grant: AdminGrant,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
await tx.insert(adminCommandResults).values({
|
|
170
|
+
actorId: params.actorId,
|
|
171
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
172
|
+
command: params.event.command,
|
|
173
|
+
requestFingerprint: params.requestFingerprint,
|
|
174
|
+
result: {grant: toStoredAdminGrant(grant)},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
179
|
+
await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
|
|
180
|
+
type: 'administration.action.performed',
|
|
181
|
+
payload: event,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function lockAdminCommand(
|
|
186
|
+
tx: Tx,
|
|
187
|
+
params: {actorId: string; idempotencyKeyFingerprint: string},
|
|
188
|
+
) {
|
|
189
|
+
await tx.execute(
|
|
190
|
+
sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function bootstrapFirstAdminOwner(
|
|
195
|
+
params: AuditedAdminCommandParams & {userId: string},
|
|
196
|
+
): Promise<AdminGrant> {
|
|
197
|
+
return await db().transaction(async (tx) => {
|
|
198
|
+
await lockAdminCommand(tx, params);
|
|
199
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
|
|
200
|
+
|
|
201
|
+
const existing = await findCommandResult(tx, {
|
|
202
|
+
...params,
|
|
203
|
+
command: params.event.command,
|
|
204
|
+
});
|
|
205
|
+
if (existing) return existing;
|
|
206
|
+
|
|
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();
|
|
220
|
+
|
|
221
|
+
const userRows = await tx
|
|
222
|
+
.select({id: users.id, status: users.status})
|
|
223
|
+
.from(users)
|
|
224
|
+
.where(eq(users.id, params.userId))
|
|
225
|
+
.limit(1);
|
|
226
|
+
const user = userRows[0];
|
|
227
|
+
if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
|
|
228
|
+
|
|
229
|
+
const rows = await tx
|
|
230
|
+
.insert(adminGrants)
|
|
231
|
+
.values({userId: params.userId, role: 'admin-owner'})
|
|
232
|
+
.returning();
|
|
233
|
+
const row = rows[0];
|
|
234
|
+
if (!row) throw new Error('Bootstrap grant insert returned no rows');
|
|
235
|
+
|
|
236
|
+
const grant = toAdminGrant(row);
|
|
237
|
+
await writeAdminAction(tx, params.event);
|
|
238
|
+
await storeCommandResult(tx, params, grant);
|
|
239
|
+
return grant;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function grantAdminRoleWithAudit(
|
|
244
|
+
params: AuditedAdminCommandParams & {userId: string; role: AdminRole},
|
|
245
|
+
): Promise<AdminGrant> {
|
|
246
|
+
return await db().transaction(async (tx) => {
|
|
247
|
+
await lockAdminCommand(tx, params);
|
|
248
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
|
|
249
|
+
|
|
250
|
+
const existing = await findCommandResult(tx, {
|
|
251
|
+
...params,
|
|
252
|
+
command: params.event.command,
|
|
253
|
+
});
|
|
254
|
+
if (existing) return existing;
|
|
255
|
+
|
|
256
|
+
const userRows = await tx
|
|
257
|
+
.select({id: users.id, status: users.status})
|
|
258
|
+
.from(users)
|
|
259
|
+
.where(eq(users.id, params.userId))
|
|
260
|
+
.limit(1);
|
|
261
|
+
const user = userRows[0];
|
|
262
|
+
if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
|
|
263
|
+
|
|
264
|
+
const activeRows = await tx
|
|
265
|
+
.select({id: adminGrants.id})
|
|
266
|
+
.from(adminGrants)
|
|
267
|
+
.where(
|
|
268
|
+
and(
|
|
269
|
+
eq(adminGrants.userId, params.userId),
|
|
270
|
+
eq(adminGrants.role, params.role),
|
|
271
|
+
isNull(adminGrants.revokedAt),
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
.limit(1);
|
|
275
|
+
if (activeRows.length > 0) throw new AdminGrantAlreadyExistsError();
|
|
276
|
+
|
|
277
|
+
const rows = await tx
|
|
278
|
+
.insert(adminGrants)
|
|
279
|
+
.values({userId: params.userId, role: params.role})
|
|
280
|
+
.returning();
|
|
281
|
+
const row = rows[0];
|
|
282
|
+
if (!row) throw new Error('Administrator grant insert returned no rows');
|
|
283
|
+
|
|
284
|
+
const grant = toAdminGrant(row);
|
|
285
|
+
await writeAdminAction(tx, params.event);
|
|
286
|
+
await storeCommandResult(tx, params, grant);
|
|
287
|
+
return grant;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function revokeAdminGrantWithAudit(
|
|
292
|
+
params: AuditedAdminCommandParams & {grantId: string},
|
|
293
|
+
): Promise<AdminGrant> {
|
|
294
|
+
return await db().transaction(async (tx) => {
|
|
295
|
+
await lockAdminCommand(tx, params);
|
|
296
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
|
|
297
|
+
|
|
298
|
+
const existing = await findCommandResult(tx, {
|
|
299
|
+
...params,
|
|
300
|
+
command: params.event.command,
|
|
301
|
+
});
|
|
302
|
+
if (existing) return existing;
|
|
303
|
+
|
|
304
|
+
const rows = await tx
|
|
305
|
+
.select()
|
|
306
|
+
.from(adminGrants)
|
|
307
|
+
.where(and(eq(adminGrants.id, params.grantId), isNull(adminGrants.revokedAt)))
|
|
308
|
+
.limit(1);
|
|
309
|
+
const grant = rows[0];
|
|
310
|
+
if (!grant) throw new AdminGrantNotFoundError();
|
|
311
|
+
|
|
312
|
+
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
|
+
);
|
|
324
|
+
if (activeOwners.length <= 1) throw new LastAdminOwnerError();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const updated = await tx
|
|
328
|
+
.update(adminGrants)
|
|
329
|
+
.set({revokedAt: new Date(), updatedAt: new Date()})
|
|
330
|
+
.where(and(eq(adminGrants.id, grant.id), isNull(adminGrants.revokedAt)))
|
|
331
|
+
.returning();
|
|
332
|
+
const updatedRow = updated[0];
|
|
333
|
+
if (!updatedRow) throw new AdminGrantNotFoundError();
|
|
334
|
+
|
|
335
|
+
const revoked = toAdminGrant(updatedRow);
|
|
336
|
+
await writeAdminAction(tx, params.event);
|
|
337
|
+
await storeCommandResult(tx, params, revoked);
|
|
338
|
+
return revoked;
|
|
339
|
+
});
|
|
340
|
+
}
|
package/src/db/db.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {adminCommandResults} from './schema/admin-command-results.js';
|
|
3
4
|
import {adminGrants} from './schema/admin-grants.js';
|
|
4
5
|
import {authOutbox} from './schema/outbox.js';
|
|
5
6
|
import {passwordResets} from './schema/password-resets.js';
|
|
@@ -9,6 +10,7 @@ import {users} from './schema/users.js';
|
|
|
9
10
|
|
|
10
11
|
export const schema = {
|
|
11
12
|
adminGrants,
|
|
13
|
+
adminCommandResults,
|
|
12
14
|
users,
|
|
13
15
|
passwordResets,
|
|
14
16
|
refreshTokens,
|