@bevel-software/platform-core-backend 0.27.0 → 0.28.1
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 +14 -2
- 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 +83 -42
- 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/kb-template/access.md +2 -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 +24 -3
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- 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__/change-read-gate.test.ts +36 -5
- 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 +15 -2
- package/src/modules/access/access-control.service.ts +93 -49
- 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
|
@@ -637,6 +637,18 @@ describe('Admin fixed members — the deployment admin', () => {
|
|
|
637
637
|
expect(roster.find((r) => r.canonical === 'sales')!.fixedMembers).toEqual([]);
|
|
638
638
|
});
|
|
639
639
|
|
|
640
|
+
it('sends the note with the roster, and the refusal leads with that same note', async () => {
|
|
641
|
+
// The page prints `fixedMembersNote` as sent, so this is the one copy of
|
|
642
|
+
// the wording: the note and the API refusal cannot drift apart.
|
|
643
|
+
const svc = serviceWith([OWNER]);
|
|
644
|
+
const roster = await svc.getRoster();
|
|
645
|
+
const note = roster.find((r) => r.canonical === 'admin')!.fixedMembersNote;
|
|
646
|
+
expect(note).toBe('Owner; cannot be removed here');
|
|
647
|
+
expect(roster.find((r) => r.canonical === 'sales')!.fixedMembersNote).toBeNull();
|
|
648
|
+
const refusal = await svc.addMember(ADMIN, 'admin', OWNER).catch((e: Error) => e);
|
|
649
|
+
expect((refusal as Error).message.startsWith(`${note}. `)).toBe(true);
|
|
650
|
+
});
|
|
651
|
+
|
|
640
652
|
it('canonicalises the configured address and drops a blank one', async () => {
|
|
641
653
|
// The composition root passes `[config.adminEmail]` unconditionally, so an
|
|
642
654
|
// unset ADMIN_EMAIL arrives as [''] — that must not become a member "".
|
|
@@ -665,7 +677,7 @@ describe('Admin fixed members — the deployment admin', () => {
|
|
|
665
677
|
status: 422,
|
|
666
678
|
payload: { kind: 'fixed-deployment-admin' },
|
|
667
679
|
message: expect.stringContaining(
|
|
668
|
-
'
|
|
680
|
+
'Owner; cannot be removed here',
|
|
669
681
|
),
|
|
670
682
|
});
|
|
671
683
|
// Nothing written: the file is byte-identical.
|
|
@@ -685,7 +697,7 @@ describe('Admin fixed members — the deployment admin', () => {
|
|
|
685
697
|
status: 422,
|
|
686
698
|
payload: { kind: 'fixed-deployment-admin' },
|
|
687
699
|
message: expect.stringContaining(
|
|
688
|
-
'
|
|
700
|
+
'Owner; cannot be removed here',
|
|
689
701
|
),
|
|
690
702
|
});
|
|
691
703
|
expect(await fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8')).toContain(OWNER);
|
|
@@ -700,6 +712,7 @@ describe('Admin fixed members — the deployment admin', () => {
|
|
|
700
712
|
it('with no ADMIN_EMAIL configured, no role has a fixed member', async () => {
|
|
701
713
|
const roster = await serviceWith([]).getRoster();
|
|
702
714
|
expect(roster.every((r) => r.fixedMembers.length === 0)).toBe(true);
|
|
715
|
+
expect(roster.every((r) => r.fixedMembersNote === null)).toBe(true);
|
|
703
716
|
// ...and ordinary Admin membership stays editable.
|
|
704
717
|
await expect(serviceWith([]).addMember(ADMIN, 'admin', OWNER)).resolves.toBeDefined();
|
|
705
718
|
});
|
|
@@ -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
|
}
|
|
@@ -149,8 +149,11 @@ export type AccessDecisionSource = {
|
|
|
149
149
|
* `via` names the tier that matched and `principal` the display name of the
|
|
150
150
|
* group, role or plugin principal it matched on (the caller's own email for
|
|
151
151
|
* `person`, `everyone` for the built-in). `admin-floor` is Admin's write at
|
|
152
|
-
* the repository root,
|
|
153
|
-
*
|
|
152
|
+
* the repository root, and the read that write implies, which no rule there
|
|
153
|
+
* can take away; its source is the scope the floor decided at — the root's
|
|
154
|
+
* rules (`folder`), or the own `frontmatter` of a file directly in the root,
|
|
155
|
+
* whose entries are part of the root and cannot take the floor away either —
|
|
156
|
+
* or null when the walk found no scope at all.
|
|
154
157
|
*/
|
|
155
158
|
export interface AccessDecision {
|
|
156
159
|
allowed: boolean;
|
|
@@ -678,6 +681,16 @@ export interface IAccessControl {
|
|
|
678
681
|
*/
|
|
679
682
|
holdsAdminRootWrite(workspaceId: string, userEmail: string): Promise<boolean>;
|
|
680
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Every address the resolver treats as an Admin in the working-tree model,
|
|
686
|
+
* each once: the Admin role's members — through a group too — and the
|
|
687
|
+
* deployment owners. The same membership answer {@link holdsAdminRootWrite}
|
|
688
|
+
* gives one address at a time, enumerated. When the model cannot be loaded
|
|
689
|
+
* (a `roles.yaml` that does not parse makes nobody an Admin), the
|
|
690
|
+
* deployment owners alone.
|
|
691
|
+
*/
|
|
692
|
+
adminEmails(workspaceId: string): Promise<string[]>;
|
|
693
|
+
|
|
681
694
|
/**
|
|
682
695
|
* Whether `userEmail` may put a misplaced platform file back at
|
|
683
696
|
* `destinationRelativePath` — the ONE write that is allowed to land on a
|
|
@@ -130,15 +130,16 @@ interface AccessModel {
|
|
|
130
130
|
* Canonical emails that count as Admin whatever `roles.yaml` says — the
|
|
131
131
|
* deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
|
|
132
132
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* `ADMIN_EMAIL` itself, so
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
133
|
+
* An owner IS an Admin, for every verb: resolution gives them the Admin
|
|
134
|
+
* role's own keys (`principalKeysOf`), so they read, download, write and
|
|
135
|
+
* own exactly where a member of the role does and are excluded exactly
|
|
136
|
+
* where the role is, and they hold the two hardcoded `write` rescues and
|
|
137
|
+
* the root floor as the role does. The owner is the person who can already
|
|
138
|
+
* change `ADMIN_EMAIL` itself, so none of this concedes anything they could
|
|
139
|
+
* not already take — while withholding it turns a `roles.yaml` that has
|
|
140
|
+
* lost its last Admin into a knowledge base nobody can repair through the
|
|
141
|
+
* app, and an owner the file does not name into someone the write floor
|
|
142
|
+
* admits everywhere and the read gate admits nowhere.
|
|
142
143
|
*/
|
|
143
144
|
deploymentOwners: ReadonlySet<string>;
|
|
144
145
|
}
|
|
@@ -478,19 +479,38 @@ function resolveAtPath(
|
|
|
478
479
|
/**
|
|
479
480
|
* Every principal key a caller holds: the tokens their email is a member of,
|
|
480
481
|
* plus the PUBLIC keys every caller holds regardless (a plugin principal
|
|
481
|
-
* whose plugin grants `everyone`)
|
|
482
|
+
* whose plugin grants `everyone`), plus — for a deployment owner — the Admin
|
|
483
|
+
* role's own keys, whether or not `roles.yaml` names them: the owner IS an
|
|
484
|
+
* Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
|
|
485
|
+
* role binds them, for every verb. Undefined when there is nothing at all,
|
|
482
486
|
* matching the plain `byEmail.get` the tier-2 check used to read.
|
|
483
487
|
*/
|
|
484
488
|
function principalKeysOf(model: AccessModel, email: string): Set<string> | undefined {
|
|
485
489
|
const own = model.roles.byEmail.get(email);
|
|
486
490
|
const pub = model.roles.publicKeys;
|
|
487
|
-
|
|
488
|
-
|
|
491
|
+
const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
|
|
492
|
+
if (!pub?.size && !asOwner) return own;
|
|
493
|
+
return new Set([
|
|
494
|
+
...(own ?? []),
|
|
495
|
+
...(pub ?? []),
|
|
496
|
+
...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
|
|
497
|
+
]);
|
|
489
498
|
}
|
|
490
499
|
|
|
491
500
|
/** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
|
|
492
501
|
const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
|
|
493
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The verbs the Admin floor at the repository root confers: write, and the
|
|
505
|
+
* read that write implies (`VERB_REQUIRES`). An admin who may change
|
|
506
|
+
* everything under the root may see it; otherwise the read-before-write gate
|
|
507
|
+
* refuses them the very change the floor grants. Download is not implied by
|
|
508
|
+
* write and stays with the access tree.
|
|
509
|
+
*/
|
|
510
|
+
function holdsAdminFloor(verb: Verb): boolean {
|
|
511
|
+
return verb === 'write' || verb === 'read';
|
|
512
|
+
}
|
|
513
|
+
|
|
494
514
|
/**
|
|
495
515
|
* Add to `keys` what holding them confers on top: every plugin principal
|
|
496
516
|
* expanded from one of the keys, and the public keys every caller holds.
|
|
@@ -540,9 +560,10 @@ const NO_KEYS: ReadonlySet<string> = new Set();
|
|
|
540
560
|
/**
|
|
541
561
|
* The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
|
|
542
562
|
* of principal KEYS with no person behind them — no direct-email tier, no
|
|
543
|
-
* admin rescue, no machine-owned rule — only the Admin write
|
|
544
|
-
* keys include the Admin role. Closest scope
|
|
545
|
-
* grant to any key wins over a denial to another,
|
|
563
|
+
* admin rescue, no machine-owned rule — only the Admin floor (write, and the
|
|
564
|
+
* read it implies), when the keys include the Admin role. Closest scope
|
|
565
|
+
* first; within a scope a grant to any key wins over a denial to another,
|
|
566
|
+
* exactly as for a person.
|
|
546
567
|
*/
|
|
547
568
|
function hasPermissionForKeys(
|
|
548
569
|
model: AccessModel,
|
|
@@ -551,7 +572,7 @@ function hasPermissionForKeys(
|
|
|
551
572
|
relativePath: string,
|
|
552
573
|
fileOwn?: OwnEntries | null,
|
|
553
574
|
): boolean {
|
|
554
|
-
const adminFloor = verb
|
|
575
|
+
const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
|
|
555
576
|
for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
|
|
556
577
|
if (adminFloor && isAdminFloorScope(scope, relativePath)) return true;
|
|
557
578
|
let grant = false;
|
|
@@ -620,17 +641,20 @@ function isAdminEmail(model: AccessModel, email: string): boolean {
|
|
|
620
641
|
* owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
|
|
621
642
|
* authority they did not have; it only gives it a door.
|
|
622
643
|
*
|
|
623
|
-
* One floor for `write
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* whatever the root file says, so no root
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
644
|
+
* One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
|
|
645
|
+
* during the walk: an admin (same definition) always holds both at the
|
|
646
|
+
* repository root. When the walk reaches the root `access.md` (or the own
|
|
647
|
+
* frontmatter of a file directly in the root) — or runs out of scopes without
|
|
648
|
+
* a verdict — an admin is granted, whatever the root file says, so no root
|
|
649
|
+
* rule can lock the deployment out of its own tree, and nobody who may change
|
|
650
|
+
* everything under the root is refused the sight of it. A NEARER scope still
|
|
651
|
+
* decides first: a subfolder that denies Admin (or denies `everyone` without
|
|
652
|
+
* naming Admin) excludes admins there like anyone else. The root file's own
|
|
653
|
+
* entries are not rewritten — the floor is a verdict, not a grant line — so
|
|
654
|
+
* `grantSources` stays file-backed.
|
|
632
655
|
*
|
|
633
|
-
* No special-
|
|
656
|
+
* No special-case for `download` (or `owner`) — they fall through to scope
|
|
657
|
+
* resolution; write implies read, not download.
|
|
634
658
|
*/
|
|
635
659
|
function hasPermissionResolved(
|
|
636
660
|
model: AccessModel,
|
|
@@ -676,14 +700,12 @@ function decidePermissionResolved(
|
|
|
676
700
|
}
|
|
677
701
|
}
|
|
678
702
|
|
|
679
|
-
const adminFloor = verb
|
|
680
|
-
//
|
|
681
|
-
// Admin rules along the way bind them too
|
|
682
|
-
// the owner exactly as it excludes the role's
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
|
|
686
|
-
}
|
|
703
|
+
const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
|
|
704
|
+
// The deployment owner IS Admin: `principalKeysOf` gives them the role's
|
|
705
|
+
// keys, so the Admin rules along the way bind them too — a subfolder
|
|
706
|
+
// denying Admin excludes the owner exactly as it excludes the role's
|
|
707
|
+
// members, and one granting Admin admits them.
|
|
708
|
+
const userRoles = principalKeysOf(model, email);
|
|
687
709
|
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
688
710
|
|
|
689
711
|
for (const scope of scopes) {
|
|
@@ -1090,12 +1112,11 @@ function canEveryoneReadResolved(
|
|
|
1090
1112
|
// Every *named* principal must also still read. A collapsed deny may have
|
|
1091
1113
|
// been shadowed by a closer-scope `everyone` grant, so re-resolve each
|
|
1092
1114
|
// candidate through the closeness-first gate rather than trusting the
|
|
1093
|
-
// collapsed deny state.
|
|
1094
|
-
//
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
for (const email of byEmail
|
|
1098
|
-
for (const email of candidates) {
|
|
1115
|
+
// collapsed deny state. The candidates are the same set the eligible-email
|
|
1116
|
+
// lists enumerate — roles.yaml members, inline-named users, and the
|
|
1117
|
+
// deployment owner, whom a `deny Admin` carves out like any member of the
|
|
1118
|
+
// role whether or not roles.yaml names them.
|
|
1119
|
+
for (const email of namedCandidateEmails(model, byEmail)) {
|
|
1099
1120
|
if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn)) return false;
|
|
1100
1121
|
}
|
|
1101
1122
|
return true;
|
|
@@ -1106,11 +1127,14 @@ function canEveryoneReadResolved(
|
|
|
1106
1127
|
*
|
|
1107
1128
|
* Read is default-deny: a path is readable only when resolution grants the
|
|
1108
1129
|
* caller `read` directly, via one of their roles, via the built-in `everyone`
|
|
1109
|
-
* role, or via `owner:` (
|
|
1130
|
+
* role, or via `write:`/`owner:` (both imply read).
|
|
1110
1131
|
*
|
|
1111
|
-
* No admin rescue
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1132
|
+
* No admin rescue on root files (that is write's), but the Admin floor
|
|
1133
|
+
* holds: an admin — a member of the role or the deployment owner — reads
|
|
1134
|
+
* wherever no nearer scope excludes Admin, whether or not the root names it,
|
|
1135
|
+
* because the floor gives them write there and write implies read. A
|
|
1136
|
+
* subfolder that denies Admin, or everyone, takes the read away as it does
|
|
1137
|
+
* for any principal.
|
|
1114
1138
|
*/
|
|
1115
1139
|
function canReadResolved(
|
|
1116
1140
|
model: AccessModel,
|
|
@@ -1183,9 +1207,10 @@ function eligibleHoldersResolved(
|
|
|
1183
1207
|
const record = model.roles.byCanonical.get(canonical);
|
|
1184
1208
|
addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
|
|
1185
1209
|
}
|
|
1186
|
-
// The Admin
|
|
1187
|
-
// whenever no nearer scope excludes it, whether or not
|
|
1188
|
-
|
|
1210
|
+
// The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
|
|
1211
|
+
// read it implies, here whenever no nearer scope excludes it, whether or not
|
|
1212
|
+
// any file names it.
|
|
1213
|
+
if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
|
|
1189
1214
|
const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY)!;
|
|
1190
1215
|
if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY))) addPrincipal(adminRole.displayName, 'role');
|
|
1191
1216
|
}
|
|
@@ -1232,8 +1257,8 @@ function eligibleHoldersResolved(
|
|
|
1232
1257
|
|
|
1233
1258
|
/**
|
|
1234
1259
|
* Every individual the model names: `roles.yaml` members, emails named inline
|
|
1235
|
-
* at this path's scope, and the deployment owner (
|
|
1236
|
-
* even when `roles.yaml` does not list them).
|
|
1260
|
+
* at this path's scope, and the deployment owner (bound by every rule that
|
|
1261
|
+
* names Admin even when `roles.yaml` does not list them).
|
|
1237
1262
|
*/
|
|
1238
1263
|
function namedCandidateEmails(model: AccessModel, byEmail: CollapsedScope['byEmail']): Set<string> {
|
|
1239
1264
|
const candidates = new Set<string>(model.deploymentOwners);
|
|
@@ -2164,6 +2189,25 @@ export class AccessControlService implements IAccessControl {
|
|
|
2164
2189
|
return isAdminEmail(model, canonicalEmail(userEmail));
|
|
2165
2190
|
}
|
|
2166
2191
|
|
|
2192
|
+
async adminEmails(workspaceId: string): Promise<string[]> {
|
|
2193
|
+
let model: AccessModel;
|
|
2194
|
+
try {
|
|
2195
|
+
model = await this.loadModel(workspaceId);
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
// As canRestorePlatformFile: with no model to ask, the deployment owners
|
|
2198
|
+
// are the only admins left. A `roles.yaml` that does not parse is that
|
|
2199
|
+
// by design; anything else (a clone being read, the disk) is said, so a
|
|
2200
|
+
// list that names fewer admins than there are is diagnosable.
|
|
2201
|
+
if (!(err instanceof AccessConfigError)) {
|
|
2202
|
+
log.warn('the access model could not be loaded; the admins list names the deployment owners only:', { err });
|
|
2203
|
+
}
|
|
2204
|
+
return [...this.deploymentOwners];
|
|
2205
|
+
}
|
|
2206
|
+
const candidates = new Set<string>(model.deploymentOwners);
|
|
2207
|
+
for (const email of model.roles.byEmail.keys()) candidates.add(email);
|
|
2208
|
+
return [...candidates].filter((email) => isAdminEmail(model, email));
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2167
2211
|
async canRestorePlatformFile(
|
|
2168
2212
|
workspaceId: string,
|
|
2169
2213
|
userEmail: string,
|