@bevel-software/platform-core-backend 0.28.0 → 0.29.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/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +7 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -14
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +11 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +13 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +5 -2
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +36 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +208 -85
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +7 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +2 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +13 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +24 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +10 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +106 -20
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/kb-template/access.md +2 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +29 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +7 -1
- package/src/core/create-core-services.ts +27 -21
- package/src/core/lifecycle.ts +24 -12
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- 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__/change-read-gate.test.ts +36 -5
- package/src/modules/access/access-control.interface.ts +5 -2
- package/src/modules/access/access-control.service.ts +236 -95
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/index.ts +6 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/git.service.ts +23 -0
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
- package/src/modules/workspace/index.ts +14 -0
- package/src/modules/workspace/workspace.tools.ts +120 -20
- package/src/shared/workspace-id.ts +24 -0
|
@@ -15,12 +15,11 @@ import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-
|
|
|
15
15
|
/**
|
|
16
16
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
17
17
|
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
18
|
-
* them
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
18
|
+
* them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
|
|
19
|
+
* or `<spec> missing\n` (the spec may itself contain spaces, hence the
|
|
20
|
+
* endsWith checks).
|
|
22
21
|
*/
|
|
23
|
-
async function
|
|
22
|
+
async function catFileBatchOutcomes(runner, repoDir, specs) {
|
|
24
23
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
25
24
|
// below has to step by that count through content that is not all ASCII.
|
|
26
25
|
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
@@ -42,7 +41,7 @@ async function catFileBatch(runner, repoDir, specs) {
|
|
|
42
41
|
const header = out.subarray(off, nl).toString('utf-8');
|
|
43
42
|
off = nl + 1;
|
|
44
43
|
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
45
|
-
results.push(
|
|
44
|
+
results.push({ kind: 'missing' });
|
|
46
45
|
continue;
|
|
47
46
|
}
|
|
48
47
|
const parts = header.split(' ');
|
|
@@ -50,13 +49,25 @@ async function catFileBatch(runner, repoDir, specs) {
|
|
|
50
49
|
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
51
50
|
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
52
51
|
}
|
|
53
|
-
//
|
|
54
|
-
// payload still has to be skipped to stay aligned.
|
|
55
|
-
results.push(parts[1] === 'blob'
|
|
52
|
+
// A non-blob (a directory path resolves to a tree) is reported as such,
|
|
53
|
+
// and its payload still has to be skipped to stay aligned.
|
|
54
|
+
results.push(parts[1] === 'blob'
|
|
55
|
+
? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
|
|
56
|
+
: { kind: 'non-blob', type: parts[1] });
|
|
56
57
|
off += size + 1; // payload + trailing LF
|
|
57
58
|
}
|
|
58
59
|
return results;
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* {@link catFileBatchOutcomes} for callers that want text or nothing: the
|
|
63
|
+
* blob's text, or null when the spec is missing at the ref or resolves to a
|
|
64
|
+
* non-blob — the own-entries reads, where a directory where a file was
|
|
65
|
+
* named simply carries no entries.
|
|
66
|
+
*/
|
|
67
|
+
async function catFileBatch(runner, repoDir, specs) {
|
|
68
|
+
const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
|
|
69
|
+
return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
|
|
70
|
+
}
|
|
60
71
|
function isBuiltInRole(canonicalRole) {
|
|
61
72
|
return canonicalRole === EVERYONE_CANONICAL;
|
|
62
73
|
}
|
|
@@ -319,18 +330,36 @@ function resolveAtPath(model, verb, relativePath, fileOwn) {
|
|
|
319
330
|
/**
|
|
320
331
|
* Every principal key a caller holds: the tokens their email is a member of,
|
|
321
332
|
* plus the PUBLIC keys every caller holds regardless (a plugin principal
|
|
322
|
-
* whose plugin grants `everyone`)
|
|
333
|
+
* whose plugin grants `everyone`), plus — for a deployment owner — the Admin
|
|
334
|
+
* role's own keys, whether or not `roles.yaml` names them: the owner IS an
|
|
335
|
+
* Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
|
|
336
|
+
* role binds them, for every verb. Undefined when there is nothing at all,
|
|
323
337
|
* matching the plain `byEmail.get` the tier-2 check used to read.
|
|
324
338
|
*/
|
|
325
339
|
function principalKeysOf(model, email) {
|
|
326
340
|
const own = model.roles.byEmail.get(email);
|
|
327
341
|
const pub = model.roles.publicKeys;
|
|
328
|
-
|
|
342
|
+
const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
|
|
343
|
+
if (!pub?.size && !asOwner)
|
|
329
344
|
return own;
|
|
330
|
-
return new Set([
|
|
345
|
+
return new Set([
|
|
346
|
+
...(own ?? []),
|
|
347
|
+
...(pub ?? []),
|
|
348
|
+
...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
|
|
349
|
+
]);
|
|
331
350
|
}
|
|
332
351
|
/** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
|
|
333
352
|
const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
|
|
353
|
+
/**
|
|
354
|
+
* The verbs the Admin floor at the repository root confers: write, and the
|
|
355
|
+
* read that write implies (`VERB_REQUIRES`). An admin who may change
|
|
356
|
+
* everything under the root may see it; otherwise the read-before-write gate
|
|
357
|
+
* refuses them the very change the floor grants. Download is not implied by
|
|
358
|
+
* write and stays with the access tree.
|
|
359
|
+
*/
|
|
360
|
+
function holdsAdminFloor(verb) {
|
|
361
|
+
return verb === 'write' || verb === 'read';
|
|
362
|
+
}
|
|
334
363
|
/**
|
|
335
364
|
* Add to `keys` what holding them confers on top: every plugin principal
|
|
336
365
|
* expanded from one of the keys, and the public keys every caller holds.
|
|
@@ -381,12 +410,13 @@ const NO_KEYS = new Set();
|
|
|
381
410
|
/**
|
|
382
411
|
* The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
|
|
383
412
|
* of principal KEYS with no person behind them — no direct-email tier, no
|
|
384
|
-
* admin rescue, no machine-owned rule — only the Admin write
|
|
385
|
-
* keys include the Admin role. Closest scope
|
|
386
|
-
* grant to any key wins over a denial to another,
|
|
413
|
+
* admin rescue, no machine-owned rule — only the Admin floor (write, and the
|
|
414
|
+
* read it implies), when the keys include the Admin role. Closest scope
|
|
415
|
+
* first; within a scope a grant to any key wins over a denial to another,
|
|
416
|
+
* exactly as for a person.
|
|
387
417
|
*/
|
|
388
418
|
function hasPermissionForKeys(model, verb, keys, relativePath, fileOwn) {
|
|
389
|
-
const adminFloor = verb
|
|
419
|
+
const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
|
|
390
420
|
for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
|
|
391
421
|
if (adminFloor && isAdminFloorScope(scope, relativePath))
|
|
392
422
|
return true;
|
|
@@ -460,17 +490,20 @@ function isAdminEmail(model, email) {
|
|
|
460
490
|
* owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
|
|
461
491
|
* authority they did not have; it only gives it a door.
|
|
462
492
|
*
|
|
463
|
-
* One floor for `write
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* whatever the root file says, so no root
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
493
|
+
* One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
|
|
494
|
+
* during the walk: an admin (same definition) always holds both at the
|
|
495
|
+
* repository root. When the walk reaches the root `access.md` (or the own
|
|
496
|
+
* frontmatter of a file directly in the root) — or runs out of scopes without
|
|
497
|
+
* a verdict — an admin is granted, whatever the root file says, so no root
|
|
498
|
+
* rule can lock the deployment out of its own tree, and nobody who may change
|
|
499
|
+
* everything under the root is refused the sight of it. A NEARER scope still
|
|
500
|
+
* decides first: a subfolder that denies Admin (or denies `everyone` without
|
|
501
|
+
* naming Admin) excludes admins there like anyone else. The root file's own
|
|
502
|
+
* entries are not rewritten — the floor is a verdict, not a grant line — so
|
|
503
|
+
* `grantSources` stays file-backed.
|
|
472
504
|
*
|
|
473
|
-
* No special-
|
|
505
|
+
* No special-case for `download` (or `owner`) — they fall through to scope
|
|
506
|
+
* resolution; write implies read, not download.
|
|
474
507
|
*/
|
|
475
508
|
function hasPermissionResolved(model, verb, userEmail, relativePath, fileOwn) {
|
|
476
509
|
return decidePermissionResolved(model, verb, userEmail, relativePath, fileOwn).allowed;
|
|
@@ -485,14 +518,12 @@ function decidePermissionResolved(model, verb, userEmail, relativePath, fileOwn)
|
|
|
485
518
|
return { allowed: true, scope: null, via: 'admin-rescue' };
|
|
486
519
|
}
|
|
487
520
|
}
|
|
488
|
-
const adminFloor = verb
|
|
489
|
-
//
|
|
490
|
-
// Admin rules along the way bind them too
|
|
491
|
-
// the owner exactly as it excludes the role's
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
|
|
495
|
-
}
|
|
521
|
+
const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
|
|
522
|
+
// The deployment owner IS Admin: `principalKeysOf` gives them the role's
|
|
523
|
+
// keys, so the Admin rules along the way bind them too — a subfolder
|
|
524
|
+
// denying Admin excludes the owner exactly as it excludes the role's
|
|
525
|
+
// members, and one granting Admin admits them.
|
|
526
|
+
const userRoles = principalKeysOf(model, email);
|
|
496
527
|
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
497
528
|
for (const scope of scopes) {
|
|
498
529
|
if (adminFloor && isAdminFloorScope(scope, relativePath))
|
|
@@ -858,14 +889,11 @@ function canEveryoneReadResolved(model, relativePath, fileOwn) {
|
|
|
858
889
|
// Every *named* principal must also still read. A collapsed deny may have
|
|
859
890
|
// been shadowed by a closer-scope `everyone` grant, so re-resolve each
|
|
860
891
|
// candidate through the closeness-first gate rather than trusting the
|
|
861
|
-
// collapsed deny state.
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
for (const email of byEmail.keys())
|
|
867
|
-
candidates.add(email);
|
|
868
|
-
for (const email of candidates) {
|
|
892
|
+
// collapsed deny state. The candidates are the same set the eligible-email
|
|
893
|
+
// lists enumerate — roles.yaml members, inline-named users, and the
|
|
894
|
+
// deployment owner, whom a `deny Admin` carves out like any member of the
|
|
895
|
+
// role whether or not roles.yaml names them.
|
|
896
|
+
for (const email of namedCandidateEmails(model, byEmail)) {
|
|
869
897
|
if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn))
|
|
870
898
|
return false;
|
|
871
899
|
}
|
|
@@ -876,11 +904,14 @@ function canEveryoneReadResolved(model, relativePath, fileOwn) {
|
|
|
876
904
|
*
|
|
877
905
|
* Read is default-deny: a path is readable only when resolution grants the
|
|
878
906
|
* caller `read` directly, via one of their roles, via the built-in `everyone`
|
|
879
|
-
* role, or via `owner:` (
|
|
907
|
+
* role, or via `write:`/`owner:` (both imply read).
|
|
880
908
|
*
|
|
881
|
-
* No admin rescue
|
|
882
|
-
*
|
|
883
|
-
*
|
|
909
|
+
* No admin rescue on root files (that is write's), but the Admin floor
|
|
910
|
+
* holds: an admin — a member of the role or the deployment owner — reads
|
|
911
|
+
* wherever no nearer scope excludes Admin, whether or not the root names it,
|
|
912
|
+
* because the floor gives them write there and write implies read. A
|
|
913
|
+
* subfolder that denies Admin, or everyone, takes the read away as it does
|
|
914
|
+
* for any principal.
|
|
884
915
|
*/
|
|
885
916
|
function canReadResolved(model, userEmail, relativePath, fileOwn) {
|
|
886
917
|
return hasPermissionResolved(model, 'read', userEmail, relativePath, fileOwn);
|
|
@@ -945,9 +976,10 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
|
945
976
|
const record = model.roles.byCanonical.get(canonical);
|
|
946
977
|
addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
|
|
947
978
|
}
|
|
948
|
-
// The Admin
|
|
949
|
-
// whenever no nearer scope excludes it, whether or not
|
|
950
|
-
|
|
979
|
+
// The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
|
|
980
|
+
// read it implies, here whenever no nearer scope excludes it, whether or not
|
|
981
|
+
// any file names it.
|
|
982
|
+
if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
|
|
951
983
|
const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY);
|
|
952
984
|
if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY)))
|
|
953
985
|
addPrincipal(adminRole.displayName, 'role');
|
|
@@ -989,8 +1021,8 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
|
989
1021
|
}
|
|
990
1022
|
/**
|
|
991
1023
|
* Every individual the model names: `roles.yaml` members, emails named inline
|
|
992
|
-
* at this path's scope, and the deployment owner (
|
|
993
|
-
* even when `roles.yaml` does not list them).
|
|
1024
|
+
* at this path's scope, and the deployment owner (bound by every rule that
|
|
1025
|
+
* names Admin even when `roles.yaml` does not list them).
|
|
994
1026
|
*/
|
|
995
1027
|
function namedCandidateEmails(model, byEmail) {
|
|
996
1028
|
const candidates = new Set(model.deploymentOwners);
|
|
@@ -1086,22 +1118,31 @@ export class AccessControlService {
|
|
|
1086
1118
|
*
|
|
1087
1119
|
* This is what makes the change-request gates affordable. One detail read
|
|
1088
1120
|
* plus one approval click runs six at-ref gates, and before this cache
|
|
1089
|
-
* every one of them rebuilt the model from git
|
|
1090
|
-
* `
|
|
1091
|
-
*
|
|
1121
|
+
* every one of them rebuilt the model from git. A build is two git calls
|
|
1122
|
+
* (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
|
|
1123
|
+
* file the model reads), and each call forks the server process, which
|
|
1124
|
+
* costs tens of milliseconds on a server holding a large heap.
|
|
1125
|
+
*
|
|
1126
|
+
* Bounded FIFO. The bound is sized for a listing that resolves access at
|
|
1127
|
+
* every open change request's two refs: one commit per open request plus
|
|
1128
|
+
* the targets, and a knowledge base with a hundred open requests was
|
|
1129
|
+
* evicting in a cycle under the old bound of 64, rebuilding every model on
|
|
1130
|
+
* every listing — thousands of forks, minutes of the main thread, and
|
|
1131
|
+
* every other request waiting behind them. A commit's tree never changes,
|
|
1132
|
+
* so a model is good for as long as it is held; the bound only caps memory.
|
|
1092
1133
|
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
* misses on the same commit share one build via `atRefInFlight`.
|
|
1134
|
+
* A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
|
|
1135
|
+
* read answers "missing" for it from git itself, which is as final as the
|
|
1136
|
+
* commit. What is never cached is a build that saw a git read FAIL: it
|
|
1137
|
+
* throws AccessUnreadableError and the gate fails closed for that call, and
|
|
1138
|
+
* pinning that to a commit would turn one hiccup into a wrong verdict until
|
|
1139
|
+
* the branch moved. Concurrent misses on the same commit share one build
|
|
1140
|
+
* via `atRefInFlight`.
|
|
1101
1141
|
*/
|
|
1102
1142
|
atRefCache = new Map();
|
|
1143
|
+
atRefNoRoles = new Set();
|
|
1103
1144
|
atRefInFlight = new Map();
|
|
1104
|
-
static AT_REF_CACHE_MAX =
|
|
1145
|
+
static AT_REF_CACHE_MAX = 512;
|
|
1105
1146
|
/**
|
|
1106
1147
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
1107
1148
|
* Held on the service rather than read per-model because it comes from the
|
|
@@ -2062,27 +2103,35 @@ export class AccessControlService {
|
|
|
2062
2103
|
/**
|
|
2063
2104
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
2064
2105
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
2065
|
-
* regardless of where it sits. Uses `git ls-tree -r --name-only
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2106
|
+
* regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
|
|
2107
|
+
* puts a NUL between paths and prints them as they are, where the default
|
|
2108
|
+
* output C-quotes a path with a non-ASCII or special character, which would
|
|
2109
|
+
* then name nothing when read back. `'error'` when git failed: a model
|
|
2110
|
+
* built without its access files is not the model, and the caller must not
|
|
2111
|
+
* cache it as one.
|
|
2068
2112
|
*/
|
|
2069
2113
|
async listAccessFilesAtRef(repoDir, ref) {
|
|
2070
2114
|
try {
|
|
2071
|
-
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
|
|
2115
|
+
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2072
2116
|
const out = [];
|
|
2073
2117
|
const pluginDirs = [];
|
|
2118
|
+
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2119
|
+
// files): a read of one of these that comes back empty is "absent"
|
|
2120
|
+
// only when the tree does not name it.
|
|
2121
|
+
const named = new Set();
|
|
2074
2122
|
const { pluginsDir } = this.kb.layout;
|
|
2075
|
-
for (const
|
|
2076
|
-
const p = line.trim();
|
|
2123
|
+
for (const p of stdout.split('\0')) {
|
|
2077
2124
|
if (!p)
|
|
2078
2125
|
continue;
|
|
2079
2126
|
if (p === 'access.md' || p.endsWith('/access.md'))
|
|
2080
2127
|
out.push(p);
|
|
2128
|
+
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML)
|
|
2129
|
+
named.add(p);
|
|
2081
2130
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2082
2131
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2083
2132
|
}
|
|
2084
2133
|
}
|
|
2085
|
-
return { accessFiles: out, pluginDirs };
|
|
2134
|
+
return { accessFiles: out, pluginDirs, named };
|
|
2086
2135
|
}
|
|
2087
2136
|
catch {
|
|
2088
2137
|
return 'error';
|
|
@@ -2127,6 +2176,10 @@ export class AccessControlService {
|
|
|
2127
2176
|
const hit = this.atRefCache.get(key);
|
|
2128
2177
|
if (hit)
|
|
2129
2178
|
return hit;
|
|
2179
|
+
// A commit known to carry no roles.yaml: the next candidate, without a
|
|
2180
|
+
// build (see `atRefNoRoles`).
|
|
2181
|
+
if (this.atRefNoRoles.has(key))
|
|
2182
|
+
continue;
|
|
2130
2183
|
// Registered BEFORE the first await on the build path, so concurrent
|
|
2131
2184
|
// misses on one commit share a single build instead of all racing past
|
|
2132
2185
|
// an empty in-flight check together.
|
|
@@ -2136,6 +2189,8 @@ export class AccessControlService {
|
|
|
2136
2189
|
.then((built) => {
|
|
2137
2190
|
if (typeof built !== 'string')
|
|
2138
2191
|
this.rememberAtRef(key, built);
|
|
2192
|
+
else if (built === 'no-roles')
|
|
2193
|
+
this.rememberNoRolesAtRef(key);
|
|
2139
2194
|
return built;
|
|
2140
2195
|
})
|
|
2141
2196
|
.finally(() => {
|
|
@@ -2184,6 +2239,14 @@ export class AccessControlService {
|
|
|
2184
2239
|
this.atRefCache.delete(oldest);
|
|
2185
2240
|
}
|
|
2186
2241
|
}
|
|
2242
|
+
rememberNoRolesAtRef(key) {
|
|
2243
|
+
this.atRefNoRoles.add(key);
|
|
2244
|
+
if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2245
|
+
const oldest = this.atRefNoRoles.values().next().value;
|
|
2246
|
+
if (oldest !== undefined)
|
|
2247
|
+
this.atRefNoRoles.delete(oldest);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2187
2250
|
/**
|
|
2188
2251
|
* The uncached build behind `loadModelAtRef`: the whole access tree read
|
|
2189
2252
|
* at `commit`. `label` is the candidate ref the commit came from, kept for
|
|
@@ -2191,6 +2254,15 @@ export class AccessControlService {
|
|
|
2191
2254
|
* means the commit carries no roles.yaml (the caller tries its next
|
|
2192
2255
|
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
2193
2256
|
* answers null, as it always has).
|
|
2257
|
+
*
|
|
2258
|
+
* TWO git calls, whatever the tree holds: one `ls-tree` to learn which
|
|
2259
|
+
* files the model is made of, then one `cat-file --batch` that reads all of
|
|
2260
|
+
* them — roles.yaml, the groups sources, every access.md and every plugin
|
|
2261
|
+
* manifest. It used to be one `git show` per file, and each git call forks
|
|
2262
|
+
* the server process, which on a server holding a large heap costs tens of
|
|
2263
|
+
* milliseconds of the main thread per call: a knowledge base with a few
|
|
2264
|
+
* dozen access files paid a second or more per commit, times every ref a
|
|
2265
|
+
* listing resolves.
|
|
2194
2266
|
*/
|
|
2195
2267
|
async buildModelAtCommit(repoDir, commit, label) {
|
|
2196
2268
|
// Every read below tells "absent" apart from "git failed". A failure
|
|
@@ -2199,20 +2271,76 @@ export class AccessControlService {
|
|
|
2199
2271
|
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
2200
2272
|
// retry reads the tree in full.
|
|
2201
2273
|
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
2274
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2275
|
+
if (listed === 'error') {
|
|
2276
|
+
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2277
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2278
|
+
}
|
|
2279
|
+
const wanted = [
|
|
2280
|
+
'roles.yaml',
|
|
2281
|
+
SYNCED_GROUPS_YAML,
|
|
2282
|
+
GROUPS_YAML,
|
|
2283
|
+
...listed.accessFiles,
|
|
2284
|
+
...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
|
|
2285
|
+
];
|
|
2286
|
+
let outcomes;
|
|
2287
|
+
try {
|
|
2288
|
+
outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
|
|
2289
|
+
}
|
|
2290
|
+
catch (err) {
|
|
2291
|
+
// The operational signal: one line per failed build, naming the commit,
|
|
2292
|
+
// so a run of these is visible in the logs.
|
|
2293
|
+
logger(`access@${tag}`).warn(`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`);
|
|
2294
|
+
throw new AccessUnreadableError(label, 'access tree (cat-file)');
|
|
2295
|
+
}
|
|
2296
|
+
const fetched = new Map();
|
|
2297
|
+
wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
|
|
2298
|
+
const refuse = (relativePath, why) => {
|
|
2299
|
+
logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
|
|
2300
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
2301
|
+
};
|
|
2302
|
+
// What the build reads. "Missing" from the batch is "absent" only for a
|
|
2303
|
+
// groups source the tree does not name. A path the tree names — an
|
|
2304
|
+
// access.md, a plugin manifest, or a groups file the listing carried —
|
|
2305
|
+
// that comes back missing has a blob git could not read (a lost object),
|
|
2306
|
+
// and one that is not a file is a tree the model cannot be read from:
|
|
2307
|
+
// either way the build fails closed rather than drop what that file
|
|
2308
|
+
// would deny. A path the build did not prefetch (none today) is read on
|
|
2309
|
+
// its own, told apart from a git failure as before.
|
|
2310
|
+
const inTree = (relativePath) => listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
|
|
2202
2311
|
const read = async (relativePath) => {
|
|
2203
|
-
const
|
|
2204
|
-
if (
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2312
|
+
const got = fetched.get(relativePath);
|
|
2313
|
+
if (got === undefined) {
|
|
2314
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
2315
|
+
if (outcome.kind === 'error')
|
|
2316
|
+
refuse(relativePath, 'could not be read');
|
|
2317
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
2209
2318
|
}
|
|
2210
|
-
|
|
2319
|
+
if (got.kind === 'blob')
|
|
2320
|
+
return got.text;
|
|
2321
|
+
if (got.kind === 'non-blob')
|
|
2322
|
+
return refuse(relativePath, `is a ${got.type}, not a file`);
|
|
2323
|
+
if (!inTree(relativePath))
|
|
2324
|
+
return null;
|
|
2325
|
+
return refuse(relativePath, 'is in the tree but its blob could not be read');
|
|
2211
2326
|
};
|
|
2212
|
-
|
|
2213
|
-
|
|
2327
|
+
// roles.yaml decides the build's outcome, so its states are read
|
|
2328
|
+
// directly. Absent from the tree is "no roles here" (the caller tries
|
|
2329
|
+
// its next candidate ref). In the tree but unreadable refuses the build:
|
|
2330
|
+
// answering "no roles" for it would be remembered for the commit and
|
|
2331
|
+
// would send a short ref through to origin, which may grant. A directory
|
|
2332
|
+
// of that name is as malformed as a file that does not parse (the caller
|
|
2333
|
+
// answers null, and never falls through to a ref that might grant what
|
|
2334
|
+
// this one does not).
|
|
2335
|
+
const roles = fetched.get('roles.yaml') ?? { kind: 'missing' };
|
|
2336
|
+
if (roles.kind === 'missing') {
|
|
2337
|
+
if (listed.named.has('roles.yaml'))
|
|
2338
|
+
refuse('roles.yaml', 'is in the tree but its blob could not be read');
|
|
2214
2339
|
return 'no-roles';
|
|
2215
|
-
|
|
2340
|
+
}
|
|
2341
|
+
if (roles.kind === 'non-blob')
|
|
2342
|
+
return 'malformed';
|
|
2343
|
+
const rolesParsed = parseRolesYaml(roles.text);
|
|
2216
2344
|
if (!rolesParsed.ok)
|
|
2217
2345
|
return 'malformed';
|
|
2218
2346
|
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
@@ -2232,11 +2360,6 @@ export class AccessControlService {
|
|
|
2232
2360
|
for (const w of mergeWarnings)
|
|
2233
2361
|
logger(`access@${tag}`).warn(w);
|
|
2234
2362
|
const accessFiles = new Map();
|
|
2235
|
-
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2236
|
-
if (listed === 'error') {
|
|
2237
|
-
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2238
|
-
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2239
|
-
}
|
|
2240
2363
|
for (const p of listed.accessFiles) {
|
|
2241
2364
|
const text = await read(p);
|
|
2242
2365
|
if (text === null)
|