@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bevel-software/platform-core-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
|
56
56
|
"yaml": "^2.9.0",
|
|
57
57
|
"zod": "^3.24.0",
|
|
58
|
-
"@bevel-software/platform-
|
|
59
|
-
"@bevel-software/platform-
|
|
58
|
+
"@bevel-software/platform-mcp-core": "0.28.0",
|
|
59
|
+
"@bevel-software/platform-shared": "0.28.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/adm-zip": "^0.5.8",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one fixture for tests that cross a real Postgres. They run where
|
|
6
|
+
* `TEST_DATABASE_URL` names a server this role may create databases on (CI
|
|
7
|
+
* carries one as a service container) and are skipped elsewhere
|
|
8
|
+
* (`describe.skipIf(!PG_ADMIN_URL)`). Each test works in a scratch database
|
|
9
|
+
* of its own, made by {@link scratchDatabaseUrl} and dropped by
|
|
10
|
+
* {@link dropScratchDatabases}; the database the URL names is only connected
|
|
11
|
+
* to, never written.
|
|
12
|
+
*/
|
|
13
|
+
export const PG_ADMIN_URL = process.env.TEST_DATABASE_URL;
|
|
14
|
+
|
|
15
|
+
/** Long enough for migrations, and for dropping every scratch database one after another. */
|
|
16
|
+
export const PG_TEST_TIMEOUT = 120_000;
|
|
17
|
+
|
|
18
|
+
const created: string[] = [];
|
|
19
|
+
|
|
20
|
+
/** Run `fn` on a connection to the admin URL, closed afterwards. */
|
|
21
|
+
export async function withPgAdmin<T>(fn: (admin: pg.Client) => Promise<T>): Promise<T> {
|
|
22
|
+
const admin = new pg.Client({ connectionString: PG_ADMIN_URL });
|
|
23
|
+
await admin.connect();
|
|
24
|
+
try {
|
|
25
|
+
return await fn(admin);
|
|
26
|
+
} finally {
|
|
27
|
+
await admin.end();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A fresh database for this test, named with `prefix`; its URL. */
|
|
32
|
+
export async function scratchDatabaseUrl(prefix: string): Promise<string> {
|
|
33
|
+
const name = `${prefix}_${randomBytes(6).toString('hex')}`;
|
|
34
|
+
await withPgAdmin((admin) => admin.query(`create database ${name}`));
|
|
35
|
+
created.push(name);
|
|
36
|
+
const url = new URL(PG_ADMIN_URL!);
|
|
37
|
+
url.pathname = `/${name}`;
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Drop every scratch database this file made; for `afterAll`, with {@link PG_TEST_TIMEOUT}. */
|
|
42
|
+
export async function dropScratchDatabases(): Promise<void> {
|
|
43
|
+
const names = created.splice(0);
|
|
44
|
+
if (names.length === 0) return;
|
|
45
|
+
await withPgAdmin(async (admin) => {
|
|
46
|
+
for (const name of names) await admin.query(`drop database if exists ${name} with (force)`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -671,6 +671,7 @@ export async function createCoreServer(
|
|
|
671
671
|
core.accessControl,
|
|
672
672
|
core.workspaceService,
|
|
673
673
|
core.authService,
|
|
674
|
+
core.authService,
|
|
674
675
|
core.workflowService,
|
|
675
676
|
core.eventBus,
|
|
676
677
|
core.db,
|
|
@@ -759,6 +760,7 @@ export async function createCoreServer(
|
|
|
759
760
|
core.authService,
|
|
760
761
|
core.adminAccess,
|
|
761
762
|
core.accountErasureService,
|
|
763
|
+
core.accountHooks,
|
|
762
764
|
core.userAccessRemovalService,
|
|
763
765
|
));
|
|
764
766
|
// Connection keys across the deployment (list per account, revoke any) —
|
|
@@ -65,6 +65,7 @@ import type { Request } from 'express';
|
|
|
65
65
|
import { DocExtractService } from '../modules/workspace/file-readers/doc-extract.service.js';
|
|
66
66
|
import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
|
|
67
67
|
import { AuthService } from '../modules/auth/auth.service.js';
|
|
68
|
+
import { AccountHooks, type IAccountHooks } from '../modules/auth/account-hooks.js';
|
|
68
69
|
import { AccountErasureService } from '../modules/auth/account-erasure.service.js';
|
|
69
70
|
import { OidcAuthProvider, oidcSettingsFrom } from '../modules/auth/oidc-auth-provider.js';
|
|
70
71
|
import { createAuthMiddleware, readAuthCookie } from '../modules/auth/auth.middleware.js';
|
|
@@ -302,6 +303,12 @@ export interface CoreServices {
|
|
|
302
303
|
githubFacade: GitHubFacade;
|
|
303
304
|
githubFacadeCredentials: GitHubFacadeCredentialsService;
|
|
304
305
|
authService: AuthService;
|
|
306
|
+
/**
|
|
307
|
+
* The seam for "an account was invited": a deployment registers here how
|
|
308
|
+
* the person is told (the cloud's mail, say), and the invite's answer says
|
|
309
|
+
* whether somebody did. Core registers nothing.
|
|
310
|
+
*/
|
|
311
|
+
accountHooks: IAccountHooks;
|
|
305
312
|
authMiddleware: ReturnType<typeof createAuthMiddleware>;
|
|
306
313
|
accountErasureService: AccountErasureService;
|
|
307
314
|
gitService: GitService;
|
|
@@ -815,6 +822,9 @@ export async function createCoreServices(
|
|
|
815
822
|
ports.accountAdmission,
|
|
816
823
|
);
|
|
817
824
|
const authMiddleware = createAuthMiddleware(authService);
|
|
825
|
+
// Who tells an invited person: the composition root of a distribution
|
|
826
|
+
// registers its listener here, after this returns (see `AccountHooks`).
|
|
827
|
+
const accountHooks = new AccountHooks();
|
|
818
828
|
|
|
819
829
|
// Shared mutex so git and diff operations on the same workspace serialize
|
|
820
830
|
// against each other — a backup-reseed races a concurrent commit otherwise.
|
|
@@ -1608,6 +1618,7 @@ export async function createCoreServices(
|
|
|
1608
1618
|
githubFacade,
|
|
1609
1619
|
githubFacadeCredentials,
|
|
1610
1620
|
authService,
|
|
1621
|
+
accountHooks,
|
|
1611
1622
|
authMiddleware,
|
|
1612
1623
|
accountErasureService,
|
|
1613
1624
|
gitService,
|
|
@@ -280,6 +280,7 @@ describe('Admin write floor at the repository root', () => {
|
|
|
280
280
|
service(),
|
|
281
281
|
{ ...ws, getOrCreateForBranch: async () => ({ id: WS, kbDirName: KB }) } as unknown as WorkspaceService,
|
|
282
282
|
{ getUserById: async (id: string) => users[id as 'u-admin'] ?? null } as unknown as AuthService,
|
|
283
|
+
{ whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
|
|
283
284
|
{
|
|
284
285
|
getLock: async () => null,
|
|
285
286
|
acquireLock: async () => ({ acquired: true, lock: {} }),
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { AccessControlService } from '../access-control.service.js';
|
|
10
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
11
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
12
|
+
import type { AuthService } from '../../auth/auth.service.js';
|
|
13
|
+
import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
14
|
+
import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
15
|
+
import { createAccessRoutes } from '../access.routes.js';
|
|
16
|
+
import { usersDbDouble } from './users-db-double.js';
|
|
17
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `GET /api/access/admins`: who the admins are, for anyone signed in. It
|
|
21
|
+
* answers the Admin role's members — through a group too — and the deployment
|
|
22
|
+
* admins, each once, sorted by name, with names from the accounts table; and
|
|
23
|
+
* nothing else about roles. The roster stays admin-only.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const KB = 'knowledge-base';
|
|
27
|
+
const MEMBER = { id: 'u-lena', email: 'lena@acme.com', name: 'Lena Park' };
|
|
28
|
+
|
|
29
|
+
const ROLES = `roles:
|
|
30
|
+
Admin:
|
|
31
|
+
- sam.ortiz@acme.com
|
|
32
|
+
- group:Ops
|
|
33
|
+
Sales:
|
|
34
|
+
- felix@acme.com
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const GROUPS = `groups:
|
|
38
|
+
Ops:
|
|
39
|
+
- dana@acme.com
|
|
40
|
+
- sam.ortiz@acme.com
|
|
41
|
+
Product:
|
|
42
|
+
- priya@acme.com
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const tmpDirs: string[] = [];
|
|
46
|
+
|
|
47
|
+
async function makeHarness(
|
|
48
|
+
opts: {
|
|
49
|
+
roles?: string;
|
|
50
|
+
groups?: string;
|
|
51
|
+
signedIn?: boolean;
|
|
52
|
+
deploymentAdmins?: string[];
|
|
53
|
+
accounts?: { email: string; name?: string; deactivatedAt?: Date | null }[];
|
|
54
|
+
} = {},
|
|
55
|
+
): Promise<{ server: Server; baseUrl: string }> {
|
|
56
|
+
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-admins-route-'));
|
|
57
|
+
tmpDirs.push(workspaceDir);
|
|
58
|
+
const repoDir = path.join(workspaceDir, KB);
|
|
59
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
60
|
+
await fs.writeFile(path.join(repoDir, 'roles.yaml'), opts.roles ?? ROLES, 'utf-8');
|
|
61
|
+
await fs.writeFile(path.join(repoDir, 'groups.yaml'), opts.groups ?? GROUPS, 'utf-8');
|
|
62
|
+
|
|
63
|
+
const workspaceService = {
|
|
64
|
+
getOrCreateForBranch: vi.fn(async () => ({})),
|
|
65
|
+
getWorkspacePath: vi.fn(async () => workspaceDir),
|
|
66
|
+
readFile: vi.fn(async (_id: string, wsRel: string) => fs.readFile(path.join(workspaceDir, wsRel), 'utf-8')),
|
|
67
|
+
} as unknown as WorkspaceService;
|
|
68
|
+
// The real resolver: the admins read is its own membership answer. Nobody
|
|
69
|
+
// signed in here is an Admin, so nobody may read the roster either.
|
|
70
|
+
const deploymentAdmins = opts.deploymentAdmins ?? ['Owner@Acme.com'];
|
|
71
|
+
const accessControl = new AccessControlService(workspaceService, KB, new NodeFs(), deploymentAdmins);
|
|
72
|
+
const authService = { getUserById: vi.fn(async () => MEMBER) } as unknown as AuthService;
|
|
73
|
+
const db = usersDbDouble(
|
|
74
|
+
opts.accounts ?? [
|
|
75
|
+
{ email: 'dana@acme.com', name: 'Dana Admin' },
|
|
76
|
+
{ email: 'sam.ortiz@acme.com', name: 'Sam Ortiz' },
|
|
77
|
+
{ email: 'felix@acme.com', name: 'Felix Sales' },
|
|
78
|
+
{ email: 'priya@acme.com', name: 'Priya Product' },
|
|
79
|
+
MEMBER,
|
|
80
|
+
],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const app = express();
|
|
84
|
+
app.use(express.json());
|
|
85
|
+
app.use('/api', (req, _res, next) => {
|
|
86
|
+
if (opts.signedIn !== false) (req as unknown as { userId: string }).userId = MEMBER.id;
|
|
87
|
+
next();
|
|
88
|
+
});
|
|
89
|
+
app.use(
|
|
90
|
+
'/api',
|
|
91
|
+
createAccessRoutes(
|
|
92
|
+
accessControl,
|
|
93
|
+
workspaceService,
|
|
94
|
+
authService,
|
|
95
|
+
authService,
|
|
96
|
+
{} as unknown as WorkflowService,
|
|
97
|
+
{ emit: vi.fn() } as unknown as WorkflowEventBus,
|
|
98
|
+
db,
|
|
99
|
+
testKbContext({ kbDirName: KB }),
|
|
100
|
+
deploymentAdmins,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
const server = await new Promise<Server>((resolve) => {
|
|
104
|
+
const s = app.listen(0, () => resolve(s));
|
|
105
|
+
});
|
|
106
|
+
return { server, baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function close(s: Server): Promise<void> {
|
|
110
|
+
return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('GET /api/access/admins', () => {
|
|
114
|
+
let server: Server | null = null;
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
if (server) await close(server);
|
|
117
|
+
server = null;
|
|
118
|
+
await Promise.all(tmpDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('tells a Member every admin — direct, through a group, and the deployment admin — once each, sorted by name', async () => {
|
|
122
|
+
const h = await makeHarness();
|
|
123
|
+
server = h.server;
|
|
124
|
+
const res = await fetch(`${h.baseUrl}/api/access/admins`);
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
expect(await res.json()).toEqual({
|
|
127
|
+
admins: [
|
|
128
|
+
{ name: 'Dana Admin', email: 'dana@acme.com' },
|
|
129
|
+
// No account yet: named by address.
|
|
130
|
+
{ name: 'owner@acme.com', email: 'owner@acme.com' },
|
|
131
|
+
{ name: 'Sam Ortiz', email: 'sam.ortiz@acme.com' },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('reveals nothing else: no other role, group or member, and no other field', async () => {
|
|
137
|
+
const h = await makeHarness();
|
|
138
|
+
server = h.server;
|
|
139
|
+
const text = await (await fetch(`${h.baseUrl}/api/access/admins`)).text();
|
|
140
|
+
for (const leak of ['felix', 'priya', 'Sales', 'Product', 'Ops', 'group:', 'lena']) {
|
|
141
|
+
expect(text).not.toContain(leak);
|
|
142
|
+
}
|
|
143
|
+
const body = JSON.parse(text) as { admins: Record<string, unknown>[] };
|
|
144
|
+
expect(Object.keys(body)).toEqual(['admins']);
|
|
145
|
+
for (const admin of body.admins) expect(Object.keys(admin).sort()).toEqual(['email', 'name']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('answers only the deployment admins when roles.yaml does not parse, as the resolver does', async () => {
|
|
149
|
+
const h = await makeHarness({ roles: 'roles:\n Admin: not-a-list\n' });
|
|
150
|
+
server = h.server;
|
|
151
|
+
const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
|
|
152
|
+
expect(body.admins).toEqual([{ name: 'owner@acme.com', email: 'owner@acme.com' }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('answers only the deployment admins when the resolver rejects roles.yaml the editing model would read', async () => {
|
|
156
|
+
// A role named with the reserved plugin prefix: the resolver refuses the
|
|
157
|
+
// whole file, so nobody in it is an Admin — and nobody is listed as one.
|
|
158
|
+
const h = await makeHarness({
|
|
159
|
+
roles: 'roles:\n Admin:\n - sam.ortiz@acme.com\n - group:Ops\n plugin/x:\n - felix@acme.com\n',
|
|
160
|
+
});
|
|
161
|
+
server = h.server;
|
|
162
|
+
const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
|
|
163
|
+
expect(body.admins).toEqual([{ name: 'owner@acme.com', email: 'owner@acme.com' }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not count the members of a GROUP named Admin, as the resolver does not', async () => {
|
|
167
|
+
const h = await makeHarness({ groups: 'groups:\n Admin:\n - priya@acme.com\n' });
|
|
168
|
+
server = h.server;
|
|
169
|
+
const text = await (await fetch(`${h.baseUrl}/api/access/admins`)).text();
|
|
170
|
+
expect(text).not.toContain('priya');
|
|
171
|
+
expect(text).toContain('sam.ortiz@acme.com');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('leaves out a role member whose account is switched off, but not a switched-off deployment admin', async () => {
|
|
175
|
+
const off = new Date('2026-01-01T00:00:00Z');
|
|
176
|
+
const h = await makeHarness({
|
|
177
|
+
accounts: [
|
|
178
|
+
{ email: 'dana@acme.com', name: 'Dana Admin', deactivatedAt: off },
|
|
179
|
+
{ email: 'sam.ortiz@acme.com', name: 'Sam Ortiz' },
|
|
180
|
+
{ email: 'owner@acme.com', name: 'Olive Owner', deactivatedAt: off },
|
|
181
|
+
MEMBER,
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
server = h.server;
|
|
185
|
+
const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
|
|
186
|
+
expect(body.admins).toEqual([
|
|
187
|
+
{ name: 'Olive Owner', email: 'owner@acme.com' },
|
|
188
|
+
{ name: 'Sam Ortiz', email: 'sam.ortiz@acme.com' },
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('refuses a caller who is not signed in', async () => {
|
|
193
|
+
const h = await makeHarness({ signedIn: false });
|
|
194
|
+
server = h.server;
|
|
195
|
+
expect((await fetch(`${h.baseUrl}/api/access/admins`)).status).toBe(401);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('leaves the roles roster admin-only', async () => {
|
|
199
|
+
const h = await makeHarness();
|
|
200
|
+
server = h.server;
|
|
201
|
+
expect((await fetch(`${h.baseUrl}/api/access/roles`)).status).toBe(403);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -28,11 +28,14 @@ interface Harness {
|
|
|
28
28
|
baseUrl: string;
|
|
29
29
|
canWriteBatch: ReturnType<typeof vi.fn>;
|
|
30
30
|
canOwnerBatch: ReturnType<typeof vi.fn>;
|
|
31
|
+
canReadBatch: ReturnType<typeof vi.fn>;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
// Alice WRITES both skills but is named in the `owner:` grant of one
|
|
34
|
+
// Alice WRITES both skills but is named in the `owner:` grant of one, and
|
|
35
|
+
// READS a folder she may not write.
|
|
34
36
|
const WRITABLE = new Set(['Skills/a/SKILL.md', 'Skills/b/SKILL.md']);
|
|
35
37
|
const OWNED = new Set(['Skills/a/SKILL.md']);
|
|
38
|
+
const READABLE = new Set(['Skills/a/SKILL.md', 'Skills/b/SKILL.md', 'KnowledgeBase/Sales']);
|
|
36
39
|
|
|
37
40
|
const verdicts = (set: Set<string>) =>
|
|
38
41
|
vi.fn(async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, set.has(p)])));
|
|
@@ -40,7 +43,8 @@ const verdicts = (set: Set<string>) =>
|
|
|
40
43
|
async function makeHarness(): Promise<Harness> {
|
|
41
44
|
const canWriteBatch = verdicts(WRITABLE);
|
|
42
45
|
const canOwnerBatch = verdicts(OWNED);
|
|
43
|
-
const
|
|
46
|
+
const canReadBatch = verdicts(READABLE);
|
|
47
|
+
const accessControl = { canWriteBatch, canOwnerBatch, canReadBatch } as unknown as IAccessControl;
|
|
44
48
|
|
|
45
49
|
const workspaceService = {} as unknown as WorkspaceService;
|
|
46
50
|
const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
|
|
@@ -57,6 +61,7 @@ async function makeHarness(): Promise<Harness> {
|
|
|
57
61
|
accessControl,
|
|
58
62
|
workspaceService,
|
|
59
63
|
authService,
|
|
64
|
+
authService,
|
|
60
65
|
{} as unknown as WorkflowService,
|
|
61
66
|
{ emit: vi.fn() } as unknown as WorkflowEventBus,
|
|
62
67
|
usersDbDouble(),
|
|
@@ -68,7 +73,7 @@ async function makeHarness(): Promise<Harness> {
|
|
|
68
73
|
const s = app.listen(0, () => resolve(s));
|
|
69
74
|
});
|
|
70
75
|
const addr = server.address() as AddressInfo;
|
|
71
|
-
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canWriteBatch, canOwnerBatch };
|
|
76
|
+
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canWriteBatch, canOwnerBatch, canReadBatch };
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
function close(s: Server): Promise<void> {
|
|
@@ -119,16 +124,31 @@ describe('POST /access/batch', () => {
|
|
|
119
124
|
expect(h.canWriteBatch).not.toHaveBeenCalled();
|
|
120
125
|
});
|
|
121
126
|
|
|
127
|
+
it('answers READ for `verb: read` — a folder the caller may read but not write gets true', async () => {
|
|
128
|
+
h = await makeHarness();
|
|
129
|
+
|
|
130
|
+
const paths = [...PATHS, 'KnowledgeBase/Sales'];
|
|
131
|
+
const res = await post({ paths, verb: 'read' });
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect(await res.json()).toEqual({
|
|
134
|
+
results: { 'Skills/a/SKILL.md': true, 'Skills/b/SKILL.md': true, 'KnowledgeBase/Sales': true },
|
|
135
|
+
});
|
|
136
|
+
expect(h.canReadBatch).toHaveBeenCalledWith(WS, USER.email, paths);
|
|
137
|
+
expect(h.canWriteBatch).not.toHaveBeenCalled();
|
|
138
|
+
expect(h.canOwnerBatch).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
122
141
|
it('400s a verb it does not dispatch, resolving nothing', async () => {
|
|
123
142
|
h = await makeHarness();
|
|
124
143
|
|
|
125
|
-
for (const verb of ['
|
|
144
|
+
for (const verb of ['download', 'admin', 42, null]) {
|
|
126
145
|
const res = await post({ paths: PATHS, verb });
|
|
127
146
|
expect(res.status).toBe(400);
|
|
128
|
-
expect(await res.json()).toEqual({ error: 'verb must be "write" or "
|
|
147
|
+
expect(await res.json()).toEqual({ error: 'verb must be "write", "owner" or "read"' });
|
|
129
148
|
}
|
|
130
149
|
expect(h.canWriteBatch).not.toHaveBeenCalled();
|
|
131
150
|
expect(h.canOwnerBatch).not.toHaveBeenCalled();
|
|
151
|
+
expect(h.canReadBatch).not.toHaveBeenCalled();
|
|
132
152
|
});
|
|
133
153
|
|
|
134
154
|
it('keeps the paths validation ahead of the verb', async () => {
|
|
@@ -156,6 +156,7 @@ describe('access mutations on a file that cannot carry frontmatter', () => {
|
|
|
156
156
|
accessControl,
|
|
157
157
|
workspaceService,
|
|
158
158
|
{ getUserById: vi.fn(async () => USER) } as unknown as AuthService,
|
|
159
|
+
{ whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
|
|
159
160
|
workflowService,
|
|
160
161
|
{ emit: vi.fn() } as unknown as WorkflowEventBus,
|
|
161
162
|
usersDbDouble(),
|
|
@@ -88,7 +88,7 @@ async function makeHarness(opts: {
|
|
|
88
88
|
(req as unknown as { userId: string }).userId = USER.id;
|
|
89
89
|
next();
|
|
90
90
|
});
|
|
91
|
-
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
91
|
+
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
92
92
|
|
|
93
93
|
const server = await new Promise<Server>((resolve) => {
|
|
94
94
|
const s = app.listen(0, () => resolve(s));
|
|
@@ -98,7 +98,7 @@ async function makeHarness(opts: {
|
|
|
98
98
|
});
|
|
99
99
|
app.use(
|
|
100
100
|
'/api',
|
|
101
|
-
createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })),
|
|
101
|
+
createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })),
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
const server = await new Promise<Server>((resolve) => {
|
|
@@ -64,6 +64,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
|
|
|
64
64
|
accessControl,
|
|
65
65
|
{} as unknown as WorkspaceService,
|
|
66
66
|
{ getUserById: vi.fn(async () => USER) } as unknown as AuthService,
|
|
67
|
+
{ whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
|
|
67
68
|
{} as unknown as WorkflowService,
|
|
68
69
|
{ emit: vi.fn() } as unknown as WorkflowEventBus,
|
|
69
70
|
{} as unknown as Database,
|
|
@@ -134,7 +134,7 @@ async function makeHarness(opts: {
|
|
|
134
134
|
(req as unknown as { userId: string }).userId = USER.id;
|
|
135
135
|
next();
|
|
136
136
|
});
|
|
137
|
-
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
137
|
+
app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
|
|
138
138
|
|
|
139
139
|
const server = await new Promise<Server>((resolve) => {
|
|
140
140
|
const s = app.listen(0, () => resolve(s));
|
|
@@ -146,7 +146,7 @@ describe('SECURITY: Admin-role removal takes effect on the next request', () =>
|
|
|
146
146
|
(req as unknown as { userEmail?: string }).userEmail = req.header('x-test-email') ?? undefined;
|
|
147
147
|
next();
|
|
148
148
|
});
|
|
149
|
-
app.use('/api', createAccountRoutes(authService, adminAccess, erasure));
|
|
149
|
+
app.use('/api', createAccountRoutes(authService, adminAccess, erasure, { runAccountInvited: async () => [] }));
|
|
150
150
|
server = await new Promise<Server>((resolve) => {
|
|
151
151
|
const s = app.listen(0, () => resolve(s));
|
|
152
152
|
});
|
|
@@ -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
|
});
|