@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
|
@@ -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,
|
|
@@ -133,11 +133,31 @@ describe('Admin write floor at the repository root', () => {
|
|
|
133
133
|
expect(await service().canWrite(WS, ADMIN, 'README.md')).toBe(true);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it('is write
|
|
137
|
-
await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\n');
|
|
136
|
+
it('is write and the read it implies: download still answers to the access tree', async () => {
|
|
137
|
+
await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\nread:\n - deny Admin\n');
|
|
138
138
|
const svc = service();
|
|
139
|
-
|
|
139
|
+
for (const who of [ADMIN, OWNER]) {
|
|
140
|
+
expect(await svc.canRead(WS, who, 'README.md')).toBe(true);
|
|
141
|
+
expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
|
|
142
|
+
expect(await svc.canDownload(WS, who, 'README.md')).toBe(false);
|
|
143
|
+
}
|
|
140
144
|
expect(await svc.canWrite(WS, ENGINEER, 'README.md')).toBe(false);
|
|
145
|
+
expect(await svc.canRead(WS, ENGINEER, 'README.md')).toBe(false);
|
|
146
|
+
// Who may read, for the banner: the floor's holders, and nobody else.
|
|
147
|
+
expect((await svc.eligibleReaders(WS, 'Knowledge/Foo.md')).roles).toEqual(['Admin']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('the deployment owner, absent from roles.yaml, reads where the Admin role reads and not where it is excluded', async () => {
|
|
151
|
+
await write(repo, 'access.md', '---\n---\nread:\n - Engineer\n');
|
|
152
|
+
await write(repo, 'HR/access.md', '---\n---\nread:\n - Engineer\n - deny Admin\n');
|
|
153
|
+
await write(repo, 'Ops/access.md', '---\n---\nread:\n - deny everyone\n - Admin\n');
|
|
154
|
+
const svc = service();
|
|
155
|
+
for (const who of [ADMIN, OWNER]) {
|
|
156
|
+
expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
|
|
157
|
+
expect(await svc.canRead(WS, who, 'HR/Salaries.md')).toBe(false);
|
|
158
|
+
expect(await svc.canRead(WS, who, 'Ops/Runbook.md')).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
expect(await svc.canRead(WS, ENGINEER, 'Ops/Runbook.md')).toBe(false);
|
|
141
161
|
});
|
|
142
162
|
|
|
143
163
|
describe('a subfolder that excludes Admin', () => {
|
|
@@ -280,6 +300,7 @@ describe('Admin write floor at the repository root', () => {
|
|
|
280
300
|
service(),
|
|
281
301
|
{ ...ws, getOrCreateForBranch: async () => ({ id: WS, kbDirName: KB }) } as unknown as WorkspaceService,
|
|
282
302
|
{ getUserById: async (id: string) => users[id as 'u-admin'] ?? null } as unknown as AuthService,
|
|
303
|
+
{ whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
|
|
283
304
|
{
|
|
284
305
|
getLock: async () => null,
|
|
285
306
|
acquireLock: async () => ({ acquired: true, lock: {} }),
|
|
@@ -102,7 +102,9 @@ describe('download implies read', () => {
|
|
|
102
102
|
'access.md': '---\n---\ndownload:\n - Product Manager\n',
|
|
103
103
|
});
|
|
104
104
|
expect(await svc.canRead(workspaceId, 'pm@x.io', 'Knowledge/Deal.md')).toBe(true);
|
|
105
|
-
|
|
105
|
+
// Nobody else: the fold adds no grant that was not written. (An admin
|
|
106
|
+
// reads here too, through the root floor, not through this fold.)
|
|
107
|
+
expect(await svc.canRead(workspaceId, 'mallory@x.io', 'Knowledge/Deal.md')).toBe(false);
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it('for a GROUP grant', async () => {
|
|
@@ -943,16 +943,24 @@ describe('AccessControlService', () => {
|
|
|
943
943
|
expect(await svc.canRead(workspaceId, 'sara@example.com', 'Knowledge/Foo.md')).toBe(true);
|
|
944
944
|
});
|
|
945
945
|
|
|
946
|
-
it('
|
|
946
|
+
it('the root floor gives admins read (write implies it) whatever the root says; a nearer scope can still exclude them', async () => {
|
|
947
947
|
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
948
948
|
await writeFile(repo, 'roles.yaml', ROLES_YAML);
|
|
949
|
-
// Write/read restricted to Product Manager — Admin is
|
|
950
|
-
//
|
|
949
|
+
// Write/read restricted to Product Manager — Admin is not named at the
|
|
950
|
+
// root, but the floor gives Admin write there and write implies read, so
|
|
951
|
+
// an admin who could create anything under the root can also see it.
|
|
951
952
|
await writeFile(repo, 'access.md', '---\nwrite:\n - Product Manager\nread:\n - Product Manager\n---\n');
|
|
953
|
+
// A subfolder is an ordinary scope: it takes Admin's read away as it would anyone's.
|
|
954
|
+
await writeFile(repo, 'Knowledge/Board/access.md', '---\nread:\n - deny Admin\n---\n');
|
|
952
955
|
|
|
953
956
|
const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR, new NodeFs());
|
|
954
|
-
// razvan is Admin but not a Product Manager
|
|
955
|
-
expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(
|
|
957
|
+
// razvan is Admin but not a Product Manager.
|
|
958
|
+
expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(true);
|
|
959
|
+
expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Board/Minutes.md')).toBe(false);
|
|
960
|
+
// The floor is write and read, not download.
|
|
961
|
+
expect(await svc.canDownload(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(false);
|
|
962
|
+
// Nobody else gains anything from it.
|
|
963
|
+
expect(await svc.canRead(workspaceId, 'ali@bevel.software', 'Knowledge/Foo.md')).toBe(false);
|
|
956
964
|
});
|
|
957
965
|
|
|
958
966
|
it("a node's own read: frontmatter grants just that file", async () => {
|
|
@@ -1002,17 +1010,24 @@ describe('AccessControlService', () => {
|
|
|
1002
1010
|
});
|
|
1003
1011
|
|
|
1004
1012
|
describe('eligibleReaders', () => {
|
|
1005
|
-
it('reports restricted=true with
|
|
1013
|
+
it('reports restricted=true, with Admin as the one reader, for a default-denied node', async () => {
|
|
1006
1014
|
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
1007
1015
|
await writeFile(repo, 'roles.yaml', ROLES_YAML);
|
|
1008
1016
|
// The only line in the tree is a DENIAL — a denial grants nobody, and
|
|
1009
1017
|
// no verb that folds into read grants anyone either, so the node is
|
|
1010
|
-
// default-denied
|
|
1018
|
+
// default-denied. Admin still reads it through the root floor, and the
|
|
1019
|
+
// list says so: the banner must name who can actually open it.
|
|
1011
1020
|
await writeFile(repo, 'access.md', '---\nread:\n - deny everyone\n---\n');
|
|
1012
1021
|
|
|
1013
1022
|
const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR, new NodeFs());
|
|
1014
1023
|
const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Foo.md');
|
|
1015
|
-
expect(e).toEqual({
|
|
1024
|
+
expect(e).toEqual({
|
|
1025
|
+
restricted: true,
|
|
1026
|
+
principals: [{ name: 'Admin', kind: 'role' }],
|
|
1027
|
+
roles: ['Admin'],
|
|
1028
|
+
users: [],
|
|
1029
|
+
publicVia: [],
|
|
1030
|
+
});
|
|
1016
1031
|
});
|
|
1017
1032
|
|
|
1018
1033
|
it('reports restricted=false when read: everyone applies', async () => {
|
|
@@ -1047,10 +1062,15 @@ describe('AccessControlService', () => {
|
|
|
1047
1062
|
// node really is readable by everyone — not restricted.
|
|
1048
1063
|
expect(await svc.canRead(workspaceId, 'felix@example.com', 'Knowledge/Open/Foo.md')).toBe(true);
|
|
1049
1064
|
const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Open/Foo.md');
|
|
1065
|
+
// Admin is listed beside everyone, as it is for write: the floor holds
|
|
1066
|
+
// wherever no nearer scope excludes it.
|
|
1050
1067
|
expect(e).toEqual({
|
|
1051
1068
|
restricted: false,
|
|
1052
|
-
principals: [
|
|
1053
|
-
|
|
1069
|
+
principals: [
|
|
1070
|
+
{ name: 'Admin', kind: 'role' },
|
|
1071
|
+
{ name: 'everyone', kind: 'role' },
|
|
1072
|
+
],
|
|
1073
|
+
roles: ['Admin', 'everyone'],
|
|
1054
1074
|
users: [],
|
|
1055
1075
|
publicVia: [],
|
|
1056
1076
|
});
|
|
@@ -1133,19 +1153,60 @@ describe('AccessControlService', () => {
|
|
|
1133
1153
|
});
|
|
1134
1154
|
|
|
1135
1155
|
/**
|
|
1136
|
-
* Beyond the two rescue files, the owner
|
|
1137
|
-
*
|
|
1138
|
-
* and
|
|
1156
|
+
* Beyond the two rescue files, the owner IS the Admin role: the root floor
|
|
1157
|
+
* (write, and the read it implies), every grant and denial that names
|
|
1158
|
+
* Admin along the way, and nothing more. An owner the file does not name
|
|
1159
|
+
* used to hold write everywhere and read nowhere, so the read-before-write
|
|
1160
|
+
* gate refused them the very file the floor let them create.
|
|
1139
1161
|
*/
|
|
1140
|
-
it('
|
|
1162
|
+
it('is bound by the Admin rules for every verb, as a member of the role would be', async () => {
|
|
1141
1163
|
const ws = await seeded();
|
|
1142
1164
|
const { repo } = await seedWorkspace(root, workspaceId);
|
|
1143
1165
|
await writeFile(repo, 'HR/access.md', '---\nwrite:\n - deny Admin\n---\n');
|
|
1166
|
+
await writeFile(repo, 'Board/access.md', '---\nread:\n - deny Admin\n---\n');
|
|
1167
|
+
await writeFile(repo, 'Vault/access.md', '---\ndownload:\n - Admin\n---\n');
|
|
1144
1168
|
const svc = new AccessControlService(ws, PROCESS_MAP_DIR, new NodeFs(), [OWNER]);
|
|
1169
|
+
// The floor: write and read under the root, not download.
|
|
1145
1170
|
expect(await svc.canWrite(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(true);
|
|
1146
|
-
expect(await svc.
|
|
1147
|
-
expect(await svc.canRead(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(false);
|
|
1171
|
+
expect(await svc.canRead(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(true);
|
|
1148
1172
|
expect(await svc.canDownload(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(false);
|
|
1173
|
+
// A nearer rule naming Admin binds the owner too, each verb on its own.
|
|
1174
|
+
expect(await svc.canWrite(workspaceId, OWNER, 'HR/Salaries.md')).toBe(false);
|
|
1175
|
+
expect(await svc.canRead(workspaceId, OWNER, 'HR/Salaries.md')).toBe(true);
|
|
1176
|
+
expect(await svc.canRead(workspaceId, OWNER, 'Board/Minutes.md')).toBe(false);
|
|
1177
|
+
expect(await svc.canWrite(workspaceId, OWNER, 'Board/Minutes.md')).toBe(false);
|
|
1178
|
+
// A grant to Admin is a grant to the owner — the role's keys are theirs.
|
|
1179
|
+
expect(await svc.canDownload(workspaceId, OWNER, 'Vault/Keys.md')).toBe(true);
|
|
1180
|
+
// The explorer's batch asks the same question and gets the same answer.
|
|
1181
|
+
const batch = await svc.canReadBatch(workspaceId, OWNER, ['Knowledge/Foo.md', 'Board/Minutes.md']);
|
|
1182
|
+
expect([...batch.entries()]).toEqual([
|
|
1183
|
+
['Knowledge/Foo.md', true],
|
|
1184
|
+
['Board/Minutes.md', false],
|
|
1185
|
+
]);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('is counted when the share dialog asks whether everyone can read: a public folder that denies Admin is restricted for them', async () => {
|
|
1189
|
+
const ws = await seeded();
|
|
1190
|
+
const { repo } = await seedWorkspace(root, workspaceId);
|
|
1191
|
+
// Everyone reads, Admin is carved out, and the role's one member is let
|
|
1192
|
+
// back in by name (a direct entry outranks the role's denial). The only
|
|
1193
|
+
// person the denial still reaches is the owner, whom roles.yaml does not
|
|
1194
|
+
// name — so a check that enumerates roles.yaml alone sees nobody denied.
|
|
1195
|
+
await writeFile(
|
|
1196
|
+
repo,
|
|
1197
|
+
'Open/access.md',
|
|
1198
|
+
'---\nread:\n - everyone\n - deny Admin\n - Someone Else <someone-else@example.com>\n---\n',
|
|
1199
|
+
);
|
|
1200
|
+
await writeFile(repo, 'Public/access.md', '---\nread:\n - everyone\n---\n');
|
|
1201
|
+
const svc = new AccessControlService(ws, PROCESS_MAP_DIR, new NodeFs(), [OWNER]);
|
|
1202
|
+
expect(await svc.canRead(workspaceId, 'someone-else@example.com', 'Open/Notes.md')).toBe(true);
|
|
1203
|
+
// The denial reaches the owner, so the folder is not readable by
|
|
1204
|
+
// everyone and the dialog must not say it is.
|
|
1205
|
+
expect(await svc.canRead(workspaceId, OWNER, 'Open/Notes.md')).toBe(false);
|
|
1206
|
+
expect((await svc.eligibleReaders(workspaceId, 'Open/Notes.md')).restricted).toBe(true);
|
|
1207
|
+
// Without the carve-out the same owner reads, and the folder is public.
|
|
1208
|
+
expect(await svc.canRead(workspaceId, OWNER, 'Public/Notes.md')).toBe(true);
|
|
1209
|
+
expect((await svc.eligibleReaders(workspaceId, 'Public/Notes.md')).restricted).toBe(false);
|
|
1149
1210
|
});
|
|
1150
1211
|
|
|
1151
1212
|
it('is matched case-insensitively, like every other email here', async () => {
|
|
@@ -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));
|
|
@@ -185,13 +185,44 @@ describe('ChangeReadGate', () => {
|
|
|
185
185
|
|
|
186
186
|
it('the rescues name FILES: a folder target at the root is judged as a folder, even for an admin', async () => {
|
|
187
187
|
// The KB clone's own folder, asked about as an extraction destination:
|
|
188
|
-
// the root scope
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
// the root scope. It grants nobody here, so a non-admin is refused; an
|
|
189
|
+
// admin reads it through the root floor (write implies read), which is
|
|
190
|
+
// the access tree's own answer, not a rescue.
|
|
191
|
+
expect(await judge(ALICE, KB, 'dir')).toEqual({ allowed: false, unreadable: '' });
|
|
192
|
+
expect(await judge(ALICE, `${KB}/KnowledgeBase`, 'dir')).toEqual({ allowed: false, unreadable: 'KnowledgeBase' });
|
|
193
|
+
expect(await judge(ADMIN, KB, 'dir')).toEqual({ allowed: true, via: 'readable' });
|
|
194
|
+
expect(await judge(ADMIN, `${KB}/KnowledgeBase`, 'dir')).toEqual({ allowed: true, via: 'readable' });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('an admin may add a loose file directly in the knowledge folder, with or without a root access.md', async () => {
|
|
198
|
+
// The reported case: an admin could make a folder under the knowledge
|
|
199
|
+
// root (the new-folder exception) and write inside it, but not a page
|
|
200
|
+
// directly in the root — the floor gave them write there and nothing
|
|
201
|
+
// gave them read. Write implies read, so the floor gives both.
|
|
202
|
+
expect(await judge(ADMIN, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: true, via: 'readable' });
|
|
192
203
|
await write(repo, 'access.md', `---\nowner:\n - Admin\n---\nwrite:\n - Admin\n`);
|
|
193
204
|
access.invalidate(WS);
|
|
194
|
-
expect(await judge(ADMIN, KB
|
|
205
|
+
expect(await judge(ADMIN, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: true, via: 'readable' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('the deployment owner is an admin for this gate too, whether or not roles.yaml names them', async () => {
|
|
209
|
+
const OWNER = 'owner@bevel.software';
|
|
210
|
+
const ws = stubWorkspaceService(path.join(root, WS));
|
|
211
|
+
const owned = new ChangeReadGate(
|
|
212
|
+
ws,
|
|
213
|
+
new AccessControlService(ws, KB, new NodeFs(), [OWNER]),
|
|
214
|
+
testKbContext({ kbDirName: KB }),
|
|
215
|
+
new NodeFs(),
|
|
216
|
+
);
|
|
217
|
+
await write(repo, 'KnowledgeBase/Board/access.md', '---\nread:\n - deny Admin\n---\n');
|
|
218
|
+
expect(await owned.judge(WS, OWNER, `${KB}/KnowledgeBase/notes.md`, 'file')).toEqual({ allowed: true, via: 'readable' });
|
|
219
|
+
// Excluded exactly where the Admin role is.
|
|
220
|
+
expect(await owned.judge(WS, OWNER, `${KB}/KnowledgeBase/Board/memo.md`, 'file')).toEqual({
|
|
221
|
+
allowed: false,
|
|
222
|
+
unreadable: 'KnowledgeBase/Board',
|
|
223
|
+
});
|
|
224
|
+
// Not an owner on the gate's own service: no floor, no read.
|
|
225
|
+
expect(await judge(OWNER, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: false, unreadable: 'KnowledgeBase' });
|
|
195
226
|
});
|
|
196
227
|
|
|
197
228
|
it('a roles.yaml that is there but unusable is not an open door', async () => {
|
|
@@ -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
|
});
|