@shipfox/api-auth 17.0.0 → 19.0.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 +44 -0
- package/README.md +24 -9
- package/dist/core/administration.d.ts +15 -0
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +44 -2
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +5 -3
- package/dist/core/auth.js.map +1 -1
- package/dist/core/entities/agent-access.d.ts +75 -0
- package/dist/core/entities/agent-access.d.ts.map +1 -0
- package/dist/core/entities/agent-access.js +3 -0
- package/dist/core/entities/agent-access.js.map +1 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +2 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +30 -26
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/admin-user-summary.d.ts +22 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -1
- package/dist/db/admin-user-summary.js +75 -1
- package/dist/db/admin-user-summary.js.map +1 -1
- package/dist/db/admin-users.d.ts +11 -0
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +4 -1
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/agent-access.d.ts +159 -0
- package/dist/db/agent-access.d.ts.map +1 -0
- package/dist/db/agent-access.js +267 -0
- package/dist/db/agent-access.js.map +1 -0
- package/dist/db/db.d.ts +2604 -238
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +7 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/agent-access.d.ts +1204 -0
- package/dist/db/schema/agent-access.d.ts.map +1 -0
- package/dist/db/schema/agent-access.js +267 -0
- package/dist/db/schema/agent-access.js.map +1 -0
- package/dist/db/schema/users.d.ts.map +1 -1
- package/dist/db/schema/users.js +8 -3
- package/dist/db/schema/users.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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.map +1 -1
- package/dist/presentation/routes/administration.js +123 -20
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +10 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +59 -50
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0003_brainy_luckman.sql +6 -0
- package/drizzle/0004_yummy_runaways.sql +110 -0
- package/drizzle/meta/0003_snapshot.json +844 -0
- package/drizzle/meta/0004_snapshot.json +1751 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +7 -7
- package/src/core/admin-role.test.ts +70 -0
- package/src/core/administration.ts +78 -1
- package/src/core/auth.ts +3 -5
- package/src/core/entities/agent-access.ts +80 -0
- package/src/core/errors.ts +7 -0
- package/src/core/runner-session-token.test.ts +2 -0
- package/src/core/runner-session-token.ts +1 -0
- package/src/db/admin-user-moderation.ts +53 -45
- package/src/db/admin-user-summary.test.ts +228 -0
- package/src/db/admin-user-summary.ts +143 -1
- package/src/db/admin-users.ts +21 -1
- package/src/db/agent-access.test.ts +370 -0
- package/src/db/agent-access.ts +629 -0
- package/src/db/db.ts +14 -0
- package/src/db/schema/agent-access.ts +273 -0
- package/src/db/schema/users.ts +9 -3
- package/src/index.ts +6 -0
- package/src/metrics/instance.ts +1 -0
- package/src/presentation/routes/administration.test.ts +359 -2
- package/src/presentation/routes/administration.ts +142 -20
- package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
- package/src/presentation/routes/rate-limit.ts +4 -0
- package/src/presentation/routes/registration/signup.ts +52 -41
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
"when": 1785075183023,
|
|
23
23
|
"tag": "0002_burly_whizzer",
|
|
24
24
|
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1788171920746,
|
|
30
|
+
"tag": "0003_brainy_luckman",
|
|
31
|
+
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "7",
|
|
36
|
+
"when": 1788195662870,
|
|
37
|
+
"tag": "0004_yummy_runaways",
|
|
38
|
+
"breakpoints": true
|
|
25
39
|
}
|
|
26
40
|
]
|
|
27
41
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "19.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
29
|
"@shipfox/api-common-dto": "15.0.0",
|
|
30
|
-
"@shipfox/api-auth-context": "
|
|
31
|
-
"@shipfox/api-auth-dto": "
|
|
32
|
-
"@shipfox/api-email-challenges": "1.1.
|
|
30
|
+
"@shipfox/api-auth-context": "19.0.0",
|
|
31
|
+
"@shipfox/api-auth-dto": "19.0.0",
|
|
32
|
+
"@shipfox/api-email-challenges": "1.1.12",
|
|
33
33
|
"@shipfox/api-workspaces-dto": "15.0.0",
|
|
34
34
|
"@shipfox/inter-module": "0.2.3",
|
|
35
35
|
"@shipfox/config": "1.2.4",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"@shipfox/node-jwt": "0.4.0",
|
|
40
40
|
"@shipfox/node-email": "0.3.5",
|
|
41
41
|
"@shipfox/node-mailer": "0.2.6",
|
|
42
|
-
"@shipfox/node-module": "1.0.
|
|
42
|
+
"@shipfox/node-module": "1.0.8",
|
|
43
43
|
"@shipfox/node-opentelemetry": "0.6.5",
|
|
44
|
-
"@shipfox/node-outbox": "0.2.
|
|
44
|
+
"@shipfox/node-outbox": "0.2.7",
|
|
45
45
|
"@shipfox/node-postgres": "0.5.1",
|
|
46
46
|
"@shipfox/node-rate-limit": "0.4.0",
|
|
47
|
-
"@shipfox/node-tokens": "
|
|
47
|
+
"@shipfox/node-tokens": "1.1.0"
|
|
48
48
|
},
|
|
49
49
|
"imports": {
|
|
50
50
|
"#*": "./dist/*"
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {createAdminGrant} from '#db/admin-grants.js';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {users} from '#db/schema/users.js';
|
|
2
4
|
import {userFactory} from '#test/index.js';
|
|
3
5
|
import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
|
|
6
|
+
import {listAdministratorUsers} from './administration.js';
|
|
4
7
|
import type {AdminRoleRequiredError} from './errors.js';
|
|
8
|
+
import {InvalidAdministratorUserDirectoryFilterError} from './errors.js';
|
|
5
9
|
|
|
6
10
|
describe('admin role policy', () => {
|
|
7
11
|
test.each([
|
|
@@ -23,6 +27,72 @@ describe('admin role policy', () => {
|
|
|
23
27
|
expect(highestAdminRole([])).toBeNull();
|
|
24
28
|
});
|
|
25
29
|
|
|
30
|
+
test('protects the administrator user directory with the observer role', async () => {
|
|
31
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
32
|
+
|
|
33
|
+
await expect(listAdministratorUsers({actorId: user.id, limit: 10})).rejects.toEqual(
|
|
34
|
+
expect.objectContaining<Partial<AdminRoleRequiredError>>({
|
|
35
|
+
minimumRole: 'admin-observer',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('bounds administrator user directory search input', async () => {
|
|
41
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
42
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
listAdministratorUsers({
|
|
46
|
+
actorId: user.id,
|
|
47
|
+
limit: 10,
|
|
48
|
+
search: Array.from({length: 11}, () => 'term').join(' '),
|
|
49
|
+
}),
|
|
50
|
+
).rejects.toThrow('at most 10 terms');
|
|
51
|
+
await expect(
|
|
52
|
+
listAdministratorUsers({actorId: user.id, limit: 10, search: 'x'.repeat(101)}),
|
|
53
|
+
).rejects.toThrow('at most 100 characters');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('rejects an active eligibility filter combined with a non-active status', async () => {
|
|
57
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
58
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
listAdministratorUsers({
|
|
62
|
+
actorId: user.id,
|
|
63
|
+
limit: 10,
|
|
64
|
+
eligible: true,
|
|
65
|
+
status: 'suspended',
|
|
66
|
+
}),
|
|
67
|
+
).rejects.toBeInstanceOf(InvalidAdministratorUserDirectoryFilterError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('caps the directory page size in the core operation', async () => {
|
|
71
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
72
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
73
|
+
const marker = `admin-directory-cap-${crypto.randomUUID()}`;
|
|
74
|
+
await db()
|
|
75
|
+
.insert(users)
|
|
76
|
+
.values(
|
|
77
|
+
Array.from({length: 101}, (_, index) => ({
|
|
78
|
+
email: `${marker}-${index}@example.com`,
|
|
79
|
+
name: `Directory User ${index}`,
|
|
80
|
+
hashedPassword: null,
|
|
81
|
+
emailVerifiedAt: new Date(),
|
|
82
|
+
})),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await listAdministratorUsers({
|
|
86
|
+
actorId: user.id,
|
|
87
|
+
limit: 1_000,
|
|
88
|
+
search: marker,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.users).toHaveLength(100);
|
|
92
|
+
expect(result.rows).toEqual(result.users);
|
|
93
|
+
expect(result.nextCursor).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
26
96
|
test('evaluates the current role from Auth storage for every required minimum', async () => {
|
|
27
97
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
28
98
|
await createAdminGrant({userId: user.id, role: 'admin-operator'});
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
suspendUserWithAudit,
|
|
19
19
|
type UserModerationResult,
|
|
20
20
|
} from '#db/admin-user-moderation.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
findAdministratorUser as findAdministratorUserInDb,
|
|
23
|
+
listAdministratorUsers as listAdministratorUsersInDb,
|
|
24
|
+
} from '#db/admin-users.js';
|
|
22
25
|
import {
|
|
23
26
|
type ImpersonationResult,
|
|
24
27
|
impersonateUserWithAudit,
|
|
@@ -45,6 +48,7 @@ import {
|
|
|
45
48
|
ImpersonationExpiredError,
|
|
46
49
|
ImpersonationTargetNotActiveError,
|
|
47
50
|
InvalidAdminBootstrapTokenError,
|
|
51
|
+
InvalidAdministratorUserDirectoryFilterError,
|
|
48
52
|
InvalidCredentialsError,
|
|
49
53
|
LastAdminOwnerError,
|
|
50
54
|
UserNotFoundError,
|
|
@@ -53,6 +57,10 @@ import {
|
|
|
53
57
|
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
54
58
|
const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
|
|
55
59
|
const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
|
|
60
|
+
const ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE = 100;
|
|
61
|
+
const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS = 10;
|
|
62
|
+
const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH = 100;
|
|
63
|
+
const ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR = /\s+/g;
|
|
56
64
|
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
57
65
|
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
58
66
|
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
@@ -192,6 +200,75 @@ export async function findAdministratorUserSummary(
|
|
|
192
200
|
return user;
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
export interface ListAdministratorUsersParams {
|
|
204
|
+
actorId: string;
|
|
205
|
+
limit: number;
|
|
206
|
+
cursor?: TimestampIdCursor | undefined;
|
|
207
|
+
search?: string | undefined;
|
|
208
|
+
status?: AdministratorUserSummary['status'] | undefined;
|
|
209
|
+
eligible?: boolean | undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ListAdministratorUsersResult {
|
|
213
|
+
/** Retained for compatibility with the original database-shaped result. */
|
|
214
|
+
rows: AdministratorUserSummary[];
|
|
215
|
+
users: AdministratorUserSummary[];
|
|
216
|
+
nextCursor: TimestampIdCursor | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeAdministratorUserDirectorySearch(search: string | undefined): string | undefined {
|
|
220
|
+
const normalizedSearch = search
|
|
221
|
+
?.trim()
|
|
222
|
+
.replace(ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR, ' ');
|
|
223
|
+
if (!normalizedSearch) return;
|
|
224
|
+
|
|
225
|
+
const terms = normalizedSearch.split(' ');
|
|
226
|
+
if (terms.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS) {
|
|
227
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
228
|
+
`Administrator user directory search accepts at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS} terms`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (terms.some((term) => term.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH)) {
|
|
233
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
234
|
+
`Administrator user directory search terms must be at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH} characters`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return normalizedSearch;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateAdministratorUserDirectoryFilters(
|
|
242
|
+
params: ListAdministratorUsersParams,
|
|
243
|
+
): string | undefined {
|
|
244
|
+
const normalizedSearch = normalizeAdministratorUserDirectorySearch(params.search);
|
|
245
|
+
if (params.eligible === true && params.status && params.status !== 'active') {
|
|
246
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
247
|
+
'eligible users must have active status',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return normalizedSearch;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function listAdministratorUsers(
|
|
254
|
+
params: ListAdministratorUsersParams,
|
|
255
|
+
): Promise<ListAdministratorUsersResult> {
|
|
256
|
+
await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OBSERVER_ROLE});
|
|
257
|
+
const normalizedSearch = validateAdministratorUserDirectoryFilters(params);
|
|
258
|
+
const result = await listAdministratorUsersInDb({
|
|
259
|
+
...params,
|
|
260
|
+
limit: Math.min(params.limit, ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE),
|
|
261
|
+
search: normalizedSearch,
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
rows: result.rows,
|
|
265
|
+
// Keep the original `rows` result while exposing the domain-named field
|
|
266
|
+
// used by the directory route.
|
|
267
|
+
users: result.rows,
|
|
268
|
+
nextCursor: result.nextCursor,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
195
272
|
export async function listAdministratorGrantSummaries(params: {
|
|
196
273
|
actorId: string;
|
|
197
274
|
limit: number;
|
package/src/core/auth.ts
CHANGED
|
@@ -442,11 +442,9 @@ export type CreateSessionForUserError =
|
|
|
442
442
|
export async function createSessionForUser(
|
|
443
443
|
params: CreateSessionForUserParams,
|
|
444
444
|
): Promise<CreateSessionForUserResult> {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
? await findUserByEmail({email: emailSchema.parse(params.email)})
|
|
449
|
-
: undefined;
|
|
445
|
+
let user: User | undefined;
|
|
446
|
+
if (params.userId) user = await findUserById({id: params.userId});
|
|
447
|
+
else if (params.email) user = await findUserByEmail({email: emailSchema.parse(params.email)});
|
|
450
448
|
|
|
451
449
|
if (!user) {
|
|
452
450
|
throw new UserNotFoundError(params.userId ?? params.email ?? 'unknown');
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type AgentClientKind = 'registered' | 'cimd';
|
|
2
|
+
|
|
3
|
+
export interface AgentClient {
|
|
4
|
+
id: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
redirectUris: string[];
|
|
8
|
+
kind: AgentClientKind;
|
|
9
|
+
lastSeenAt: Date | null;
|
|
10
|
+
unreferencedAt: Date | null;
|
|
11
|
+
createdAt: Date;
|
|
12
|
+
updatedAt: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentAuthorizationRequest {
|
|
16
|
+
id: string;
|
|
17
|
+
clientId: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
scopes: string[];
|
|
21
|
+
codeChallenge: string;
|
|
22
|
+
state: string | null;
|
|
23
|
+
expiresAt: Date;
|
|
24
|
+
consumedAt: Date | null;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
updatedAt: Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgentGrant {
|
|
30
|
+
id: string;
|
|
31
|
+
userId: string;
|
|
32
|
+
workspaceId: string;
|
|
33
|
+
clientId: string;
|
|
34
|
+
scopes: string[];
|
|
35
|
+
lastUsedAt: Date | null;
|
|
36
|
+
revokedAt: Date | null;
|
|
37
|
+
terminalAt: Date | null;
|
|
38
|
+
createdAt: Date;
|
|
39
|
+
updatedAt: Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AgentAuthorizationCode {
|
|
43
|
+
id: string;
|
|
44
|
+
grantId: string;
|
|
45
|
+
hashedCode: string;
|
|
46
|
+
codeChallenge: string;
|
|
47
|
+
redirectUri: string;
|
|
48
|
+
resource: string;
|
|
49
|
+
expiresAt: Date;
|
|
50
|
+
consumedAt: Date | null;
|
|
51
|
+
createdAt: Date;
|
|
52
|
+
updatedAt: Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AgentRefreshToken {
|
|
56
|
+
id: string;
|
|
57
|
+
grantId: string;
|
|
58
|
+
hashedToken: string;
|
|
59
|
+
expiresAt: Date;
|
|
60
|
+
rotatedAt: Date | null;
|
|
61
|
+
revokedAt: Date | null;
|
|
62
|
+
lastUsedAt: Date | null;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
updatedAt: Date;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AgentPersonalAccessToken {
|
|
68
|
+
id: string;
|
|
69
|
+
userId: string;
|
|
70
|
+
workspaceId: string;
|
|
71
|
+
hashedToken: string;
|
|
72
|
+
prefix: string;
|
|
73
|
+
name: string;
|
|
74
|
+
scopes: string[];
|
|
75
|
+
expiresAt: Date;
|
|
76
|
+
lastUsedAt: Date | null;
|
|
77
|
+
revokedAt: Date | null;
|
|
78
|
+
createdAt: Date;
|
|
79
|
+
updatedAt: Date;
|
|
80
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -95,6 +95,13 @@ export class AdminRoleRequiredError extends Error {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export class InvalidAdministratorUserDirectoryFilterError extends Error {
|
|
99
|
+
constructor(message: string) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = 'InvalidAdministratorUserDirectoryFilterError';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
export class LastAdminOwnerError extends Error {
|
|
99
106
|
constructor() {
|
|
100
107
|
super('Cannot remove the final active administrator owner');
|
|
@@ -14,6 +14,7 @@ function claims() {
|
|
|
14
14
|
scope: 'workspace' as const,
|
|
15
15
|
labels: ['linux', 'x64'],
|
|
16
16
|
maxClaims: null,
|
|
17
|
+
lifecycleCapabilities: ['local_execution_fence_v1'],
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -30,6 +31,7 @@ describe('runner-session-token', () => {
|
|
|
30
31
|
expect(verified?.scope).toBe(input.scope);
|
|
31
32
|
expect(verified?.labels).toEqual(input.labels);
|
|
32
33
|
expect(verified?.maxClaims).toBeNull();
|
|
34
|
+
expect(verified?.lifecycleCapabilities).toEqual(input.lifecycleCapabilities);
|
|
33
35
|
expect(verified?.aud).toBe(RUNNER_SESSION_TOKEN_AUDIENCE);
|
|
34
36
|
expect(verified?.iat).toBeTypeOf('number');
|
|
35
37
|
expect(verified?.exp).toBeGreaterThan(verified?.iat ?? 0);
|
|
@@ -21,6 +21,7 @@ export async function issueRunnerSessionToken(
|
|
|
21
21
|
scope: claims.scope,
|
|
22
22
|
labels: claims.labels,
|
|
23
23
|
maxClaims: claims.maxClaims,
|
|
24
|
+
lifecycleCapabilities: claims.lifecycleCapabilities,
|
|
24
25
|
},
|
|
25
26
|
secret: runnerSessionTokenKey(),
|
|
26
27
|
expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,
|
|
@@ -139,6 +139,58 @@ async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
|
|
|
139
139
|
return new Set(revoked.map(({sessionId}) => sessionId)).size;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
async function suspendUser(tx: Tx, user: Awaited<ReturnType<typeof readTargetUserForUpdate>>) {
|
|
143
|
+
if (user.status !== 'active') return;
|
|
144
|
+
const activeOwners = await tx
|
|
145
|
+
.select({id: adminGrants.id})
|
|
146
|
+
.from(adminGrants)
|
|
147
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
148
|
+
.where(
|
|
149
|
+
and(
|
|
150
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
151
|
+
isNull(adminGrants.revokedAt),
|
|
152
|
+
eq(users.status, 'active'),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
.limit(2);
|
|
156
|
+
const targetIsActiveOwner = await tx
|
|
157
|
+
.select({id: adminGrants.id})
|
|
158
|
+
.from(adminGrants)
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(adminGrants.userId, user.id),
|
|
162
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
163
|
+
isNull(adminGrants.revokedAt),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
.limit(1);
|
|
167
|
+
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) throw new LastAdminOwnerError();
|
|
168
|
+
await tx
|
|
169
|
+
.update(users)
|
|
170
|
+
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
171
|
+
.where(eq(users.id, user.id));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function applyModerationOperation(
|
|
175
|
+
tx: Tx,
|
|
176
|
+
user: Awaited<ReturnType<typeof readTargetUserForUpdate>>,
|
|
177
|
+
operation: 'suspend' | 'reactivate' | 'revoke-sessions',
|
|
178
|
+
): Promise<number> {
|
|
179
|
+
if (operation === 'suspend') {
|
|
180
|
+
await suspendUser(tx, user);
|
|
181
|
+
return await revokeActiveSessions(tx, user.id);
|
|
182
|
+
}
|
|
183
|
+
if (operation === 'reactivate' && user.status === 'suspended') {
|
|
184
|
+
await tx
|
|
185
|
+
.update(users)
|
|
186
|
+
.set({status: 'active', updatedAt: sql`now()`})
|
|
187
|
+
.where(eq(users.id, user.id));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
if (operation === 'revoke-sessions') return await revokeActiveSessions(tx, user.id);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
142
194
|
async function executeUserModerationCommand(
|
|
143
195
|
params: UserModerationCommandParams & {
|
|
144
196
|
operation: 'suspend' | 'reactivate' | 'revoke-sessions';
|
|
@@ -157,51 +209,7 @@ async function executeUserModerationCommand(
|
|
|
157
209
|
await lockUserSessionMutations(tx, params.userId);
|
|
158
210
|
const user = await readTargetUserForUpdate(tx, params.userId);
|
|
159
211
|
await requireActiveAdminOperator(tx, params.actorId);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (params.operation === 'suspend') {
|
|
163
|
-
if (user.status === 'active') {
|
|
164
|
-
const activeOwners = await tx
|
|
165
|
-
.select({id: adminGrants.id})
|
|
166
|
-
.from(adminGrants)
|
|
167
|
-
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
168
|
-
.where(
|
|
169
|
-
and(
|
|
170
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
171
|
-
isNull(adminGrants.revokedAt),
|
|
172
|
-
eq(users.status, 'active'),
|
|
173
|
-
),
|
|
174
|
-
)
|
|
175
|
-
.limit(2);
|
|
176
|
-
const targetIsActiveOwner = await tx
|
|
177
|
-
.select({id: adminGrants.id})
|
|
178
|
-
.from(adminGrants)
|
|
179
|
-
.where(
|
|
180
|
-
and(
|
|
181
|
-
eq(adminGrants.userId, user.id),
|
|
182
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
183
|
-
isNull(adminGrants.revokedAt),
|
|
184
|
-
),
|
|
185
|
-
)
|
|
186
|
-
.limit(1);
|
|
187
|
-
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
|
|
188
|
-
throw new LastAdminOwnerError();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await tx
|
|
192
|
-
.update(users)
|
|
193
|
-
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
194
|
-
.where(eq(users.id, user.id));
|
|
195
|
-
}
|
|
196
|
-
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
197
|
-
} else if (params.operation === 'reactivate' && user.status === 'suspended') {
|
|
198
|
-
await tx
|
|
199
|
-
.update(users)
|
|
200
|
-
.set({status: 'active', updatedAt: sql`now()`})
|
|
201
|
-
.where(eq(users.id, user.id));
|
|
202
|
-
} else if (params.operation === 'revoke-sessions') {
|
|
203
|
-
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
204
|
-
}
|
|
212
|
+
const sessionsRevoked = await applyModerationOperation(tx, user, params.operation);
|
|
205
213
|
|
|
206
214
|
const summary = await findUserSummary(tx, user.id);
|
|
207
215
|
const result: StoredAdminUserModerationResult = {
|