@bevel-software/platform-core-backend 0.27.0 → 0.28.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +5 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +9 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +20 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +4 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +66 -7
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +35 -6
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +36 -9
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/user-access-removal.service.js +1 -1
- package/dist/modules/access/user-access-removal.service.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +46 -0
- package/dist/modules/auth/account-hooks.d.ts.map +1 -0
- package/dist/modules/auth/account-hooks.js +25 -0
- package/dist/modules/auth/account-hooks.js.map +1 -0
- package/dist/modules/auth/account-switch-guard.d.ts +15 -0
- package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-guard.js +2 -0
- package/dist/modules/auth/account-switch-guard.js.map +1 -0
- package/dist/modules/auth/account-switch-lock.d.ts +34 -0
- package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-lock.js +48 -0
- package/dist/modules/auth/account-switch-lock.js.map +1 -0
- package/dist/modules/auth/account.routes.d.ts +4 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +100 -10
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +101 -6
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +246 -51
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/password-hash.d.ts +5 -2
- package/dist/modules/auth/password-hash.d.ts.map +1 -1
- package/dist/modules/auth/password-hash.js +5 -2
- package/dist/modules/auth/password-hash.js.map +1 -1
- package/dist/modules/database/pg-error.d.ts +9 -0
- package/dist/modules/database/pg-error.d.ts.map +1 -0
- package/dist/modules/database/pg-error.js +17 -0
- package/dist/modules/database/pg-error.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +2 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/pg-scratch.ts +48 -0
- package/src/core/create-core-server.ts +2 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
- package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
- package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
- package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
- package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
- package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
- package/src/modules/access/__tests__/users-db-double.ts +12 -3
- package/src/modules/access/access-control.interface.ts +10 -0
- package/src/modules/access/access-control.service.ts +19 -0
- package/src/modules/access/access.routes.ts +67 -6
- package/src/modules/access/roles-admin.service.ts +51 -11
- package/src/modules/access/user-access-removal.service.ts +1 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
- package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
- package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
- package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
- package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
- package/src/modules/auth/account-hooks.ts +69 -0
- package/src/modules/auth/account-switch-guard.ts +14 -0
- package/src/modules/auth/account-switch-lock.ts +58 -0
- package/src/modules/auth/account.routes.ts +112 -10
- package/src/modules/auth/auth.service.ts +268 -52
- package/src/modules/auth/password-hash.ts +5 -2
- package/src/modules/database/__tests__/pg-error.test.ts +21 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
- package/src/modules/database/pg-error.ts +15 -0
- package/src/modules/workflow/workflow.service.ts +2 -1
|
@@ -4,12 +4,14 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import express from 'express';
|
|
7
|
+
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
8
|
+
import type { SQL } from 'drizzle-orm';
|
|
7
9
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
8
10
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
9
11
|
|
|
10
12
|
import type { IAccessControl } from '../access-control.interface.js';
|
|
11
13
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
12
|
-
import
|
|
14
|
+
import { AuthService } from '../../auth/auth.service.js';
|
|
13
15
|
import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
14
16
|
import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
15
17
|
import type { FileTreeEntry } from '@bevel-software/platform-shared';
|
|
@@ -40,7 +42,9 @@ const ROLES = `roles:
|
|
|
40
42
|
/** Temp workspace dirs to clean up after each test. */
|
|
41
43
|
const tmpDirs: string[] = [];
|
|
42
44
|
|
|
43
|
-
async function makeHarness(
|
|
45
|
+
async function makeHarness(
|
|
46
|
+
opts: { isAdmin?: boolean; switchOffOnRolesLock?: string } = {},
|
|
47
|
+
): Promise<{ server: Server; baseUrl: string; txLog: string[] }> {
|
|
44
48
|
const isAdmin = opts.isAdmin ?? true;
|
|
45
49
|
|
|
46
50
|
// Back the harness with a REAL temp workspace dir: the service's single-file
|
|
@@ -103,8 +107,6 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
103
107
|
}),
|
|
104
108
|
} as unknown as WorkspaceService;
|
|
105
109
|
|
|
106
|
-
const authService = { getUserById: vi.fn(async () => ADMIN) } as unknown as AuthService;
|
|
107
|
-
|
|
108
110
|
// Lock holder tracked so the service's getLock pre-check passes; the single-
|
|
109
111
|
// file write itself lands on disk via LockingFilesystem → LocalFilesystem.
|
|
110
112
|
// Strict per-path locks — a live row is contended even for the same user,
|
|
@@ -119,6 +121,8 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
119
121
|
acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: { id: string; name: string }) => {
|
|
120
122
|
const h = locks.get(p);
|
|
121
123
|
if (h) return { acquired: false, lock: lockRow(h) };
|
|
124
|
+
// An account switched off while the promotion waited for the lock.
|
|
125
|
+
if (p.endsWith('roles.yaml')) rolesLocked = true;
|
|
122
126
|
locks.set(p, user);
|
|
123
127
|
return { acquired: true, lock: lockRow(user) };
|
|
124
128
|
}),
|
|
@@ -130,7 +134,47 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
130
134
|
} as unknown as WorkflowService;
|
|
131
135
|
|
|
132
136
|
const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
|
|
133
|
-
const
|
|
137
|
+
const accounts = [
|
|
138
|
+
{ email: 'off@example.com', deactivatedAt: new Date('2026-09-01T00:00:00Z') },
|
|
139
|
+
'on@example.com',
|
|
140
|
+
];
|
|
141
|
+
const before = usersDbDouble(accounts);
|
|
142
|
+
const after = usersDbDouble(
|
|
143
|
+
accounts.map((a) =>
|
|
144
|
+
a === opts.switchOffOnRolesLock ? { email: a, deactivatedAt: new Date('2026-10-10T00:00:00Z') } : a,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
let rolesLocked = false;
|
|
148
|
+
const current = () => (rolesLocked ? after : before);
|
|
149
|
+
// What happened inside each users transaction, in order: the lock taken
|
|
150
|
+
// with `execute` (rendered), and what roles.yaml held when it ended.
|
|
151
|
+
const txLog: string[] = [];
|
|
152
|
+
const rolesOnDisk = () => fs.readFile(path.join(repoDir, 'roles.yaml'), 'utf-8');
|
|
153
|
+
const db = {
|
|
154
|
+
select: () => current().select(),
|
|
155
|
+
transaction: async <T>(cb: (tx: unknown) => Promise<T>): Promise<T> => {
|
|
156
|
+
txLog.push('begin');
|
|
157
|
+
const tx = {
|
|
158
|
+
execute: async (query: SQL) => {
|
|
159
|
+
const { sql, params } = new PgDialect().sqlToQuery(query);
|
|
160
|
+
txLog.push(`${sql} ${JSON.stringify(params)}`);
|
|
161
|
+
return [];
|
|
162
|
+
},
|
|
163
|
+
select: () => current().select(),
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
return await cb(tx);
|
|
167
|
+
} finally {
|
|
168
|
+
const onDisk = await rolesOnDisk();
|
|
169
|
+
txLog.push(`end: ${['on@example.com', 'new@example.com'].filter((e) => onDisk.includes(e)).join(', ') || 'none'}`);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
} as unknown as typeof before;
|
|
173
|
+
|
|
174
|
+
// The real auth service over the same double, so the promotion's switch
|
|
175
|
+
// lock and read run as they do in production (`whileSwitchedOn`).
|
|
176
|
+
const authService = new AuthService(db, { jwtSecret: 'test', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
|
|
177
|
+
vi.spyOn(authService, 'getUserById').mockImplementation(async () => ADMIN);
|
|
134
178
|
|
|
135
179
|
const app = express();
|
|
136
180
|
app.use(express.json());
|
|
@@ -138,13 +182,13 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
138
182
|
(req as unknown as { userId: string }).userId = ADMIN.id;
|
|
139
183
|
next();
|
|
140
184
|
});
|
|
141
|
-
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
185
|
+
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
142
186
|
|
|
143
187
|
const server = await new Promise<Server>((resolve) => {
|
|
144
188
|
const s = app.listen(0, () => resolve(s));
|
|
145
189
|
});
|
|
146
190
|
const addr = server.address() as AddressInfo;
|
|
147
|
-
return { server, baseUrl: `http://127.0.0.1:${addr.port}
|
|
191
|
+
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, txLog };
|
|
148
192
|
}
|
|
149
193
|
|
|
150
194
|
function close(s: Server): Promise<void> {
|
|
@@ -258,4 +302,85 @@ describe('/api/access/roles routes', () => {
|
|
|
258
302
|
const body = (await add.json()) as { roles: { canonical: string; members: string[] }[] };
|
|
259
303
|
expect(body.roles.find((r) => r.canonical === 'sales')!.members).toContain('mkt@example.com');
|
|
260
304
|
});
|
|
305
|
+
|
|
306
|
+
// The invite's promotion (`ifActive`) reads whether the account is switched
|
|
307
|
+
// off inside the roles lock, not from the list the dialog read before the
|
|
308
|
+
// send nor before the lock was waited for.
|
|
309
|
+
async function addAdmin(baseUrl: string, body: unknown) {
|
|
310
|
+
return fetch(`${baseUrl}/api/access/roles/admin/members`, {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
headers: { 'Content-Type': 'application/json' },
|
|
313
|
+
body: JSON.stringify(body),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
it('ifActive refuses a switched-off account (409 deactivated) and writes nothing', async () => {
|
|
318
|
+
const h = await makeHarness();
|
|
319
|
+
server = h.server;
|
|
320
|
+
const res = await addAdmin(h.baseUrl, { email: 'Off@Example.com', ifActive: true });
|
|
321
|
+
expect(res.status).toBe(409);
|
|
322
|
+
expect(await res.json()).toEqual({ error: 'This account is switched off', kind: 'deactivated' });
|
|
323
|
+
const roster = (await (await fetch(`${h.baseUrl}/api/access/roles`)).json()) as {
|
|
324
|
+
roles: { canonical: string; members: string[] }[];
|
|
325
|
+
};
|
|
326
|
+
expect(roster.roles.find((r) => r.canonical === 'admin')!.members).not.toContain('off@example.com');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('ifActive promotes a switched-on account, and one with no account yet', async () => {
|
|
330
|
+
const h = await makeHarness();
|
|
331
|
+
server = h.server;
|
|
332
|
+
expect((await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive: true })).status).toBe(200);
|
|
333
|
+
const res = await addAdmin(h.baseUrl, { email: 'new@example.com', ifActive: true });
|
|
334
|
+
expect(res.status).toBe(200);
|
|
335
|
+
const body = (await res.json()) as { roles: { canonical: string; members: string[] }[] };
|
|
336
|
+
expect(body.roles.find((r) => r.canonical === 'admin')!.members).toEqual(
|
|
337
|
+
expect.arrayContaining(['on@example.com', 'new@example.com']),
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('ifActive reads the account once the roles lock is held: one switched off while it waited is refused', async () => {
|
|
342
|
+
const h = await makeHarness({ switchOffOnRolesLock: 'on@example.com' });
|
|
343
|
+
server = h.server;
|
|
344
|
+
const res = await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive: true });
|
|
345
|
+
expect(res.status).toBe(409);
|
|
346
|
+
expect(await res.json()).toEqual({ error: 'This account is switched off', kind: 'deactivated' });
|
|
347
|
+
const roster = (await (await fetch(`${h.baseUrl}/api/access/roles`)).json()) as {
|
|
348
|
+
roles: { canonical: string; members: string[] }[];
|
|
349
|
+
};
|
|
350
|
+
expect(roster.roles.find((r) => r.canonical === 'admin')!.members).not.toContain('on@example.com');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Every switch-off (and an account created switched off) takes the same
|
|
354
|
+
// lock first; see account-deactivation.test for that side.
|
|
355
|
+
it.each([
|
|
356
|
+
['an existing account', 'On@Example.com', 'on@example.com'],
|
|
357
|
+
['an address with no account yet', 'new@example.com', 'new@example.com'],
|
|
358
|
+
])(
|
|
359
|
+
'ifActive holds the address switch lock until roles.yaml is written, for %s',
|
|
360
|
+
async (_label, email, canonical) => {
|
|
361
|
+
const h = await makeHarness();
|
|
362
|
+
server = h.server;
|
|
363
|
+
expect((await addAdmin(h.baseUrl, { email, ifActive: true })).status).toBe(200);
|
|
364
|
+
expect(h.txLog).toEqual([
|
|
365
|
+
'begin',
|
|
366
|
+
`select set_config('lock_timeout', $1, true) ["15000"]`,
|
|
367
|
+
`select pg_advisory_xact_lock(hashtext($1)) ["account-switch:${canonical}"]`,
|
|
368
|
+
`end: ${canonical}`,
|
|
369
|
+
]);
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
it('without ifActive, adding a switched-off account works as before', async () => {
|
|
374
|
+
const h = await makeHarness();
|
|
375
|
+
server = h.server;
|
|
376
|
+
expect((await addAdmin(h.baseUrl, { email: 'off@example.com' })).status).toBe(200);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('refuses a non-boolean ifActive (400)', async () => {
|
|
380
|
+
const h = await makeHarness();
|
|
381
|
+
server = h.server;
|
|
382
|
+
for (const ifActive of ['true', 1, null]) {
|
|
383
|
+
expect((await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive })).status).toBe(400);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
261
386
|
});
|
|
@@ -329,11 +329,11 @@ describe('UserAccessRemovalService', () => {
|
|
|
329
329
|
}
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
-
it('refuses the
|
|
332
|
+
it('refuses the owner and the last Admin, writing nothing', async () => {
|
|
333
333
|
const { service, workflow } = build(stubWorkflow(), ['Lee@x.io']);
|
|
334
334
|
const report = await service.report(LEE);
|
|
335
335
|
expect(report.removable).toBe(false);
|
|
336
|
-
expect(report.blockedReason).
|
|
336
|
+
expect(report.blockedReason).toBe('The owner stays in roles and access rules.');
|
|
337
337
|
await expect(service.remove(ADMIN, LEE, 'deleted-1')).rejects.toMatchObject({ status: 409 });
|
|
338
338
|
|
|
339
339
|
const other = build(stubWorkflow(), []);
|
|
@@ -48,12 +48,13 @@ function boundValues(node: unknown, depth = 0): string[] {
|
|
|
48
48
|
const canonical = (email: string) => email.trim().toLowerCase();
|
|
49
49
|
|
|
50
50
|
export function usersDbDouble(
|
|
51
|
-
accounts: readonly (string | { email: string; name?: string })[] = [],
|
|
51
|
+
accounts: readonly (string | { email: string; name?: string; deactivatedAt?: Date | null })[] = [],
|
|
52
52
|
): Database {
|
|
53
53
|
const rows = accounts.map((a) => {
|
|
54
54
|
const email = typeof a === 'string' ? a : a.email;
|
|
55
55
|
const name = typeof a === 'string' ? undefined : a.name;
|
|
56
|
-
|
|
56
|
+
const deactivatedAt = typeof a === 'string' ? null : (a.deactivatedAt ?? null);
|
|
57
|
+
return { id: `u-${email}`, email, name: name ?? email.split('@')[0], deactivatedAt };
|
|
57
58
|
});
|
|
58
59
|
const from = () => {
|
|
59
60
|
// Thenable so a bare `await db.select().from(users)` resolves, with
|
|
@@ -68,5 +69,13 @@ export function usersDbDouble(
|
|
|
68
69
|
};
|
|
69
70
|
return query;
|
|
70
71
|
};
|
|
71
|
-
|
|
72
|
+
const db = {
|
|
73
|
+
select: () => ({ from }),
|
|
74
|
+
// One connection, no isolation to model: the callback runs on the same
|
|
75
|
+
// double.
|
|
76
|
+
transaction: async <T>(cb: (tx: unknown) => Promise<T>) => cb(db),
|
|
77
|
+
// Locks taken with `execute` (advisory locks) answer nothing.
|
|
78
|
+
execute: async () => [],
|
|
79
|
+
};
|
|
80
|
+
return db as unknown as Database;
|
|
72
81
|
}
|
|
@@ -678,6 +678,16 @@ export interface IAccessControl {
|
|
|
678
678
|
*/
|
|
679
679
|
holdsAdminRootWrite(workspaceId: string, userEmail: string): Promise<boolean>;
|
|
680
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Every address the resolver treats as an Admin in the working-tree model,
|
|
683
|
+
* each once: the Admin role's members — through a group too — and the
|
|
684
|
+
* deployment owners. The same membership answer {@link holdsAdminRootWrite}
|
|
685
|
+
* gives one address at a time, enumerated. When the model cannot be loaded
|
|
686
|
+
* (a `roles.yaml` that does not parse makes nobody an Admin), the
|
|
687
|
+
* deployment owners alone.
|
|
688
|
+
*/
|
|
689
|
+
adminEmails(workspaceId: string): Promise<string[]>;
|
|
690
|
+
|
|
681
691
|
/**
|
|
682
692
|
* Whether `userEmail` may put a misplaced platform file back at
|
|
683
693
|
* `destinationRelativePath` — the ONE write that is allowed to land on a
|
|
@@ -2164,6 +2164,25 @@ export class AccessControlService implements IAccessControl {
|
|
|
2164
2164
|
return isAdminEmail(model, canonicalEmail(userEmail));
|
|
2165
2165
|
}
|
|
2166
2166
|
|
|
2167
|
+
async adminEmails(workspaceId: string): Promise<string[]> {
|
|
2168
|
+
let model: AccessModel;
|
|
2169
|
+
try {
|
|
2170
|
+
model = await this.loadModel(workspaceId);
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
// As canRestorePlatformFile: with no model to ask, the deployment owners
|
|
2173
|
+
// are the only admins left. A `roles.yaml` that does not parse is that
|
|
2174
|
+
// by design; anything else (a clone being read, the disk) is said, so a
|
|
2175
|
+
// list that names fewer admins than there are is diagnosable.
|
|
2176
|
+
if (!(err instanceof AccessConfigError)) {
|
|
2177
|
+
log.warn('the access model could not be loaded; the admins list names the deployment owners only:', { err });
|
|
2178
|
+
}
|
|
2179
|
+
return [...this.deploymentOwners];
|
|
2180
|
+
}
|
|
2181
|
+
const candidates = new Set<string>(model.deploymentOwners);
|
|
2182
|
+
for (const email of model.roles.byEmail.keys()) candidates.add(email);
|
|
2183
|
+
return [...candidates].filter((email) => isAdminEmail(model, email));
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2167
2186
|
async canRestorePlatformFile(
|
|
2168
2187
|
workspaceId: string,
|
|
2169
2188
|
userEmail: string,
|
|
@@ -21,6 +21,7 @@ import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
|
21
21
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
22
22
|
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
23
23
|
import type { AuthService } from '../auth/auth.service.js';
|
|
24
|
+
import type { IAccountSwitchGuard } from '../auth/account-switch-guard.js';
|
|
24
25
|
import type { WorkflowService } from '../workflow/workflow.service.js';
|
|
25
26
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
26
27
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
@@ -51,6 +52,7 @@ import { RolesAdminService } from './roles-admin.service.js';
|
|
|
51
52
|
import type { Principal } from '../access-model/access-splice.js';
|
|
52
53
|
import type { Database } from '../database/connection.js';
|
|
53
54
|
import { users } from '../database/schema.js';
|
|
55
|
+
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
54
56
|
import '../auth/auth.middleware.js';
|
|
55
57
|
|
|
56
58
|
/** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
|
|
@@ -85,6 +87,8 @@ export function createAccessRoutes(
|
|
|
85
87
|
accessControl: IAccessControl,
|
|
86
88
|
workspaceService: WorkspaceService,
|
|
87
89
|
authService: AuthService,
|
|
90
|
+
/** Auth's switch-off coordination, for the invite's promotion (`ifActive`). */
|
|
91
|
+
switchGuard: IAccountSwitchGuard,
|
|
88
92
|
workflowService: WorkflowService,
|
|
89
93
|
eventBus: WorkflowEventBus,
|
|
90
94
|
db: Database,
|
|
@@ -431,12 +435,15 @@ export function createAccessRoutes(
|
|
|
431
435
|
|
|
432
436
|
/**
|
|
433
437
|
* POST /api/workspace/:id/access/batch
|
|
434
|
-
* Body: `{ paths: string[], verb?: 'write' | 'owner' }`. Returns
|
|
438
|
+
* Body: `{ paths: string[], verb?: 'write' | 'owner' | 'read' }`. Returns
|
|
435
439
|
* `{ results: { [path]: boolean } }`.
|
|
436
440
|
*
|
|
437
441
|
* `verb` defaults to `write`, the question every existing caller asks.
|
|
438
442
|
* `owner` answers from the `owner:` lists alone — no admin rescue, and a
|
|
439
|
-
* writer is not an owner — which is what an Owner pill has to mean.
|
|
443
|
+
* writer is not an owner — which is what an Owner pill has to mean. `read`
|
|
444
|
+
* is the verdict the single lookup's `canRead` gives, for many paths at
|
|
445
|
+
* once: what New page asks on a draft branch, where a page may go wherever
|
|
446
|
+
* its author may read, of every Knowledge folder in one request.
|
|
440
447
|
*/
|
|
441
448
|
router.post('/workspace/:id/access/batch', async (req, res) => {
|
|
442
449
|
const user = await requireUser(req, res);
|
|
@@ -451,13 +458,18 @@ export function createAccessRoutes(
|
|
|
451
458
|
res.status(400).json({ error: 'paths cannot exceed 500 entries per request' });
|
|
452
459
|
return;
|
|
453
460
|
}
|
|
454
|
-
if (verb !== 'write' && verb !== 'owner') {
|
|
455
|
-
res.status(400).json({ error: 'verb must be "write" or "
|
|
461
|
+
if (verb !== 'write' && verb !== 'owner' && verb !== 'read') {
|
|
462
|
+
res.status(400).json({ error: 'verb must be "write", "owner" or "read"' });
|
|
456
463
|
return;
|
|
457
464
|
}
|
|
458
465
|
|
|
459
466
|
try {
|
|
460
|
-
const batch =
|
|
467
|
+
const batch =
|
|
468
|
+
verb === 'owner'
|
|
469
|
+
? accessControl.canOwnerBatch
|
|
470
|
+
: verb === 'read'
|
|
471
|
+
? accessControl.canReadBatch
|
|
472
|
+
: accessControl.canWriteBatch;
|
|
461
473
|
const result = await batch.call(
|
|
462
474
|
accessControl,
|
|
463
475
|
req.params.id,
|
|
@@ -1228,6 +1240,39 @@ export function createAccessRoutes(
|
|
|
1228
1240
|
}
|
|
1229
1241
|
});
|
|
1230
1242
|
|
|
1243
|
+
// Who the admins are, for anyone signed in: the top bar tells someone who
|
|
1244
|
+
// is not an admin to ask one, and names them. Names and emails ONLY — the
|
|
1245
|
+
// roster above (every role, its members, groups and references) stays
|
|
1246
|
+
// admin-only. Names come from the accounts table; an admin with no account
|
|
1247
|
+
// yet (a deployment admin before first sign-in, say) is named by address.
|
|
1248
|
+
// A role member whose account is switched off cannot sign in to invite
|
|
1249
|
+
// anyone, so is left out; a deployment admin is not (it signs in with the
|
|
1250
|
+
// environment's password regardless).
|
|
1251
|
+
router.get('/access/admins', async (req, res) => {
|
|
1252
|
+
const user = await requireUser(req, res);
|
|
1253
|
+
if (!user) return;
|
|
1254
|
+
try {
|
|
1255
|
+
const emails = await rolesAdmin.getAdminEmails();
|
|
1256
|
+
const rows =
|
|
1257
|
+
emails.length === 0
|
|
1258
|
+
? []
|
|
1259
|
+
: await db
|
|
1260
|
+
.select({ email: users.email, name: users.name, deactivatedAt: users.deactivatedAt })
|
|
1261
|
+
.from(users)
|
|
1262
|
+
.where(inArray(users.emailBidx, emails));
|
|
1263
|
+
const names = new Map(rows.map((r) => [canonicalEmail(r.email), r.name?.trim() ?? '']));
|
|
1264
|
+
const off = new Set(rows.filter((r) => r.deactivatedAt).map((r) => canonicalEmail(r.email)));
|
|
1265
|
+
const admins = emails
|
|
1266
|
+
.filter((email) => !off.has(email) || rolesAdmin.isFixedAdmin(email))
|
|
1267
|
+
.map((email) => ({ name: names.get(email) || email, email }))
|
|
1268
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.email.localeCompare(b.email));
|
|
1269
|
+
res.json({ admins });
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
const { status, body } = toHttpError(err);
|
|
1272
|
+
res.status(status).json(body);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1231
1276
|
// NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
|
|
1232
1277
|
// (rename), and DELETE /access/roles/:canonical are GONE — roles are
|
|
1233
1278
|
// app-defined capabilities, not user-editable objects.
|
|
@@ -1239,7 +1284,23 @@ export function createAccessRoutes(
|
|
|
1239
1284
|
await assertRolesAdmin(user.email);
|
|
1240
1285
|
const canonical = canonicalRoleName(req.params.canonical);
|
|
1241
1286
|
const email = requireNonEmptyString((req.body ?? {}).email, 'email');
|
|
1242
|
-
|
|
1287
|
+
// `ifActive`: the invite's promotion, which leaves a switched-off
|
|
1288
|
+
// account unchanged. Inside the roles file's lock, auth reads the
|
|
1289
|
+
// account under the address's switch lock and keeps that transaction
|
|
1290
|
+
// open until roles.yaml is written and committed
|
|
1291
|
+
// (`IAccountSwitchGuard.whileSwitchedOn`). So a switch-off either committed
|
|
1292
|
+
// before the read (the promotion is refused) or waits until the
|
|
1293
|
+
// promotion is written (it lands after, as for any Admin switched off
|
|
1294
|
+
// later: the place in roles stays, the credentials stop being honoured).
|
|
1295
|
+
const ifActive = (req.body ?? {}).ifActive;
|
|
1296
|
+
if (ifActive !== undefined && typeof ifActive !== 'boolean') {
|
|
1297
|
+
res.status(400).json({ error: 'ifActive must be a boolean' });
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const whileSwitchedOn = (write: () => Promise<void>): Promise<void> => switchGuard.whileSwitchedOn(email, write);
|
|
1301
|
+
res.json({
|
|
1302
|
+
roles: await rolesAdmin.addMember(user, canonical, email, ifActive ? { aroundWrite: whileSwitchedOn } : undefined),
|
|
1303
|
+
});
|
|
1243
1304
|
} catch (err) {
|
|
1244
1305
|
const { status, body } = toHttpError(err);
|
|
1245
1306
|
res.status(status).json(body);
|
|
@@ -103,6 +103,13 @@ export interface RoleRosterEntry {
|
|
|
103
103
|
* email once, as fixed.
|
|
104
104
|
*/
|
|
105
105
|
fixedMembers: string[];
|
|
106
|
+
/**
|
|
107
|
+
* The note the page prints beside {@link fixedMembers}, and the sentence
|
|
108
|
+
* both Admin-membership refusals lead with. Sent with the roster so the
|
|
109
|
+
* page keeps no copy of the wording. Null when the role has no fixed
|
|
110
|
+
* members.
|
|
111
|
+
*/
|
|
112
|
+
fixedMembersNote: string | null;
|
|
106
113
|
/** Canonical names of groups this role is assigned to (`group:` members). */
|
|
107
114
|
groups: string[];
|
|
108
115
|
isAdmin: boolean;
|
|
@@ -165,11 +172,12 @@ export class RolesAdminService {
|
|
|
165
172
|
private readonly fixedAdminEmails: readonly string[];
|
|
166
173
|
/**
|
|
167
174
|
* The one explanation both Admin-role refusals carry, verbatim: the note the
|
|
168
|
-
* page prints beside a fixed member
|
|
169
|
-
*
|
|
175
|
+
* page prints beside a fixed member, which it reads from the roster's
|
|
176
|
+
* `fixedMembersNote`. One constant, so the refusal an API caller reads and
|
|
177
|
+
* the note a UI reader sees cannot drift apart.
|
|
170
178
|
*/
|
|
171
179
|
private static readonly FIXED_ADMIN_NOTE =
|
|
172
|
-
'
|
|
180
|
+
'Owner; cannot be removed here';
|
|
173
181
|
|
|
174
182
|
constructor(
|
|
175
183
|
private readonly workspaceService: IWorkspaceService,
|
|
@@ -227,9 +235,10 @@ export class RolesAdminService {
|
|
|
227
235
|
/**
|
|
228
236
|
* Is `email` a deployment admin — an address the server configuration makes
|
|
229
237
|
* an Admin, ahead of `roles.yaml`? Drives both the fixed member rows and
|
|
230
|
-
* the add/remove refusals on the Admin role
|
|
238
|
+
* the add/remove refusals on the Admin role, and keeps a switched-off
|
|
239
|
+
* deployment admin among the admins people are told to ask.
|
|
231
240
|
*/
|
|
232
|
-
|
|
241
|
+
isFixedAdmin(email: string): boolean {
|
|
233
242
|
return this.fixedAdminEmails.includes(canonicalEmail(email));
|
|
234
243
|
}
|
|
235
244
|
|
|
@@ -326,6 +335,8 @@ export class RolesAdminService {
|
|
|
326
335
|
// Only Admin has fixed members: `deploymentOwners` is an Admin rescue
|
|
327
336
|
// in the resolver, never a general grant, so no other role gains one.
|
|
328
337
|
fixedMembers: isAdmin ? [...this.fixedAdminEmails] : [],
|
|
338
|
+
fixedMembersNote:
|
|
339
|
+
isAdmin && this.fixedAdminEmails.length > 0 ? RolesAdminService.FIXED_ADMIN_NOTE : null,
|
|
329
340
|
groups: role.members
|
|
330
341
|
.filter(isGroupRefMember)
|
|
331
342
|
.map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
|
|
@@ -342,6 +353,18 @@ export class RolesAdminService {
|
|
|
342
353
|
return out;
|
|
343
354
|
}
|
|
344
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The addresses the app treats as an Admin, each once — the resolver's own
|
|
358
|
+
* membership answer ({@link IAccessControl.adminEmails}), so who this names
|
|
359
|
+
* and who the admin gates admit cannot drift apart. Nothing else about
|
|
360
|
+
* roles: this backs a read any signed-in person may make (who to ask for an
|
|
361
|
+
* invite), unlike the roster.
|
|
362
|
+
*/
|
|
363
|
+
async getAdminEmails(): Promise<string[]> {
|
|
364
|
+
const workspaceId = await this.ensureWorkspace();
|
|
365
|
+
return this.accessControl.adminEmails(workspaceId);
|
|
366
|
+
}
|
|
367
|
+
|
|
345
368
|
/**
|
|
346
369
|
* Whether the default-branch roles.yaml parses. Drives the "roles file
|
|
347
370
|
* corrupted" banner. Auth-only (never admin-gated): a corrupted file resolves
|
|
@@ -446,10 +469,24 @@ export class RolesAdminService {
|
|
|
446
469
|
* admits it ahead of `roles.yaml` — so the write would add a row that means
|
|
447
470
|
* nothing, next to the fixed row the page already shows. Other roles are
|
|
448
471
|
* unaffected: a deployment admin is an ordinary member of "Sales".
|
|
472
|
+
*
|
|
473
|
+
* `aroundWrite`, when given, runs once the roles file's lock is held and
|
|
474
|
+
* is handed the edit itself (read, check, write, commit) to run. What it
|
|
475
|
+
* does around that call brackets the whole write: the invite holds the
|
|
476
|
+
* address's account-switch lock (a transaction-scoped advisory lock keyed
|
|
477
|
+
* on the canonical email, see `AuthService.whileSwitchedOn`) across it, so
|
|
478
|
+
* a switch-off cannot commit in between, whether or not the account exists
|
|
479
|
+
* yet (see the members route). It may throw instead of calling `write`;
|
|
480
|
+
* nothing is written then.
|
|
449
481
|
*/
|
|
450
|
-
async addMember(
|
|
482
|
+
async addMember(
|
|
483
|
+
actor: AuthUser,
|
|
484
|
+
canonical: string,
|
|
485
|
+
email: string,
|
|
486
|
+
opts?: { aroundWrite?: (write: () => Promise<void>) => Promise<void> },
|
|
487
|
+
): Promise<RoleRosterEntry[]> {
|
|
451
488
|
this.assertNotFixedAdmin(canonical, email);
|
|
452
|
-
await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
|
|
489
|
+
await this.runEdit(actor, (text) => editAddMember(text, canonical, email), opts?.aroundWrite);
|
|
453
490
|
return this.getRoster();
|
|
454
491
|
}
|
|
455
492
|
|
|
@@ -682,17 +719,20 @@ export class RolesAdminService {
|
|
|
682
719
|
* `pre` produces the candidate (and may throw RolesAdminError for invariant
|
|
683
720
|
* violations before any write). Skips on a no-op. Every candidate passes
|
|
684
721
|
* the resolver's own parser (assertLoadable) plus the pre-disk validator
|
|
685
|
-
* before a byte lands.
|
|
722
|
+
* before a byte lands. `aroundWrite` wraps the locked edit (see
|
|
723
|
+
* {@link addMember}).
|
|
686
724
|
*/
|
|
687
725
|
private async runEdit(
|
|
688
726
|
actor: AuthUser,
|
|
689
727
|
pre: (currentText: string) => EditResult,
|
|
728
|
+
aroundWrite?: (write: () => Promise<void>) => Promise<void>,
|
|
690
729
|
): Promise<void> {
|
|
691
730
|
const workspaceId = await this.ensureWorkspace();
|
|
692
731
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
693
|
-
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
|
|
694
|
-
this.runEditLocked(workspaceId, actor, pre)
|
|
695
|
-
|
|
732
|
+
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => {
|
|
733
|
+
const write = () => this.runEditLocked(workspaceId, actor, pre);
|
|
734
|
+
return aroundWrite ? aroundWrite(write) : write();
|
|
735
|
+
});
|
|
696
736
|
}
|
|
697
737
|
|
|
698
738
|
private async runEditLocked(
|
|
@@ -492,7 +492,7 @@ export class UserAccessRemovalService {
|
|
|
492
492
|
/** Why `email` may not be removed from access files, or null when it may. */
|
|
493
493
|
private blockedReason(rolesText: string | undefined, email: string): string | null {
|
|
494
494
|
if (this.deploymentOwners.some((o) => canonicalEmail(o) === email)) {
|
|
495
|
-
return '
|
|
495
|
+
return 'The owner stays in roles and access rules.';
|
|
496
496
|
}
|
|
497
497
|
const admin = rolesText
|
|
498
498
|
? (() => {
|