@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
|
@@ -66,19 +66,30 @@ import {
|
|
|
66
66
|
DIRECTORY_SYNC_BOT_NAME,
|
|
67
67
|
} from './directory-sync-bot.js';
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What one spec of a batched read came back as: a blob's text, nothing at
|
|
71
|
+
* that path, or an object that is not a file (a directory, say). The last
|
|
72
|
+
* two are told apart on purpose: for the model at a commit, a path that is
|
|
73
|
+
* there but is not a file is not "absent", it is config the model cannot be
|
|
74
|
+
* read from.
|
|
75
|
+
*/
|
|
76
|
+
type CatFileOutcome =
|
|
77
|
+
| { kind: 'blob'; text: string }
|
|
78
|
+
| { kind: 'missing' }
|
|
79
|
+
| { kind: 'non-blob'; type: string };
|
|
80
|
+
|
|
69
81
|
/**
|
|
70
82
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
71
83
|
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
72
|
-
* them
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
84
|
+
* them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
|
|
85
|
+
* or `<spec> missing\n` (the spec may itself contain spaces, hence the
|
|
86
|
+
* endsWith checks).
|
|
76
87
|
*/
|
|
77
|
-
async function
|
|
88
|
+
async function catFileBatchOutcomes(
|
|
78
89
|
runner: IGitRunner,
|
|
79
90
|
repoDir: string,
|
|
80
91
|
specs: string[],
|
|
81
|
-
): Promise<
|
|
92
|
+
): Promise<CatFileOutcome[]> {
|
|
82
93
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
83
94
|
// below has to step by that count through content that is not all ASCII.
|
|
84
95
|
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
@@ -91,7 +102,7 @@ async function catFileBatch(
|
|
|
91
102
|
input: `${specs.join('\n')}\n`,
|
|
92
103
|
encoding: 'buffer',
|
|
93
104
|
});
|
|
94
|
-
const results:
|
|
105
|
+
const results: CatFileOutcome[] = [];
|
|
95
106
|
let off = 0;
|
|
96
107
|
for (let i = 0; i < specs.length; i++) {
|
|
97
108
|
const nl = out.indexOf(0x0a, off);
|
|
@@ -99,7 +110,7 @@ async function catFileBatch(
|
|
|
99
110
|
const header = out.subarray(off, nl).toString('utf-8');
|
|
100
111
|
off = nl + 1;
|
|
101
112
|
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
102
|
-
results.push(
|
|
113
|
+
results.push({ kind: 'missing' });
|
|
103
114
|
continue;
|
|
104
115
|
}
|
|
105
116
|
const parts = header.split(' ');
|
|
@@ -107,14 +118,33 @@ async function catFileBatch(
|
|
|
107
118
|
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
108
119
|
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
109
120
|
}
|
|
110
|
-
//
|
|
111
|
-
// payload still has to be skipped to stay aligned.
|
|
112
|
-
results.push(
|
|
121
|
+
// A non-blob (a directory path resolves to a tree) is reported as such,
|
|
122
|
+
// and its payload still has to be skipped to stay aligned.
|
|
123
|
+
results.push(
|
|
124
|
+
parts[1] === 'blob'
|
|
125
|
+
? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
|
|
126
|
+
: { kind: 'non-blob', type: parts[1]! },
|
|
127
|
+
);
|
|
113
128
|
off += size + 1; // payload + trailing LF
|
|
114
129
|
}
|
|
115
130
|
return results;
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* {@link catFileBatchOutcomes} for callers that want text or nothing: the
|
|
135
|
+
* blob's text, or null when the spec is missing at the ref or resolves to a
|
|
136
|
+
* non-blob — the own-entries reads, where a directory where a file was
|
|
137
|
+
* named simply carries no entries.
|
|
138
|
+
*/
|
|
139
|
+
async function catFileBatch(
|
|
140
|
+
runner: IGitRunner,
|
|
141
|
+
repoDir: string,
|
|
142
|
+
specs: string[],
|
|
143
|
+
): Promise<(string | null)[]> {
|
|
144
|
+
const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
|
|
145
|
+
return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
|
|
119
149
|
interface AccessModel {
|
|
120
150
|
roles: RolesIndex;
|
|
@@ -130,15 +160,16 @@ interface AccessModel {
|
|
|
130
160
|
* Canonical emails that count as Admin whatever `roles.yaml` says — the
|
|
131
161
|
* deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
|
|
132
162
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* `ADMIN_EMAIL` itself, so
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
163
|
+
* An owner IS an Admin, for every verb: resolution gives them the Admin
|
|
164
|
+
* role's own keys (`principalKeysOf`), so they read, download, write and
|
|
165
|
+
* own exactly where a member of the role does and are excluded exactly
|
|
166
|
+
* where the role is, and they hold the two hardcoded `write` rescues and
|
|
167
|
+
* the root floor as the role does. The owner is the person who can already
|
|
168
|
+
* change `ADMIN_EMAIL` itself, so none of this concedes anything they could
|
|
169
|
+
* not already take — while withholding it turns a `roles.yaml` that has
|
|
170
|
+
* lost its last Admin into a knowledge base nobody can repair through the
|
|
171
|
+
* app, and an owner the file does not name into someone the write floor
|
|
172
|
+
* admits everywhere and the read gate admits nowhere.
|
|
142
173
|
*/
|
|
143
174
|
deploymentOwners: ReadonlySet<string>;
|
|
144
175
|
}
|
|
@@ -478,19 +509,38 @@ function resolveAtPath(
|
|
|
478
509
|
/**
|
|
479
510
|
* Every principal key a caller holds: the tokens their email is a member of,
|
|
480
511
|
* plus the PUBLIC keys every caller holds regardless (a plugin principal
|
|
481
|
-
* whose plugin grants `everyone`)
|
|
512
|
+
* whose plugin grants `everyone`), plus — for a deployment owner — the Admin
|
|
513
|
+
* role's own keys, whether or not `roles.yaml` names them: the owner IS an
|
|
514
|
+
* Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
|
|
515
|
+
* role binds them, for every verb. Undefined when there is nothing at all,
|
|
482
516
|
* matching the plain `byEmail.get` the tier-2 check used to read.
|
|
483
517
|
*/
|
|
484
518
|
function principalKeysOf(model: AccessModel, email: string): Set<string> | undefined {
|
|
485
519
|
const own = model.roles.byEmail.get(email);
|
|
486
520
|
const pub = model.roles.publicKeys;
|
|
487
|
-
|
|
488
|
-
|
|
521
|
+
const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
|
|
522
|
+
if (!pub?.size && !asOwner) return own;
|
|
523
|
+
return new Set([
|
|
524
|
+
...(own ?? []),
|
|
525
|
+
...(pub ?? []),
|
|
526
|
+
...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
|
|
527
|
+
]);
|
|
489
528
|
}
|
|
490
529
|
|
|
491
530
|
/** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
|
|
492
531
|
const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
|
|
493
532
|
|
|
533
|
+
/**
|
|
534
|
+
* The verbs the Admin floor at the repository root confers: write, and the
|
|
535
|
+
* read that write implies (`VERB_REQUIRES`). An admin who may change
|
|
536
|
+
* everything under the root may see it; otherwise the read-before-write gate
|
|
537
|
+
* refuses them the very change the floor grants. Download is not implied by
|
|
538
|
+
* write and stays with the access tree.
|
|
539
|
+
*/
|
|
540
|
+
function holdsAdminFloor(verb: Verb): boolean {
|
|
541
|
+
return verb === 'write' || verb === 'read';
|
|
542
|
+
}
|
|
543
|
+
|
|
494
544
|
/**
|
|
495
545
|
* Add to `keys` what holding them confers on top: every plugin principal
|
|
496
546
|
* expanded from one of the keys, and the public keys every caller holds.
|
|
@@ -540,9 +590,10 @@ const NO_KEYS: ReadonlySet<string> = new Set();
|
|
|
540
590
|
/**
|
|
541
591
|
* The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
|
|
542
592
|
* of principal KEYS with no person behind them — no direct-email tier, no
|
|
543
|
-
* admin rescue, no machine-owned rule — only the Admin write
|
|
544
|
-
* keys include the Admin role. Closest scope
|
|
545
|
-
* grant to any key wins over a denial to another,
|
|
593
|
+
* admin rescue, no machine-owned rule — only the Admin floor (write, and the
|
|
594
|
+
* read it implies), when the keys include the Admin role. Closest scope
|
|
595
|
+
* first; within a scope a grant to any key wins over a denial to another,
|
|
596
|
+
* exactly as for a person.
|
|
546
597
|
*/
|
|
547
598
|
function hasPermissionForKeys(
|
|
548
599
|
model: AccessModel,
|
|
@@ -551,7 +602,7 @@ function hasPermissionForKeys(
|
|
|
551
602
|
relativePath: string,
|
|
552
603
|
fileOwn?: OwnEntries | null,
|
|
553
604
|
): boolean {
|
|
554
|
-
const adminFloor = verb
|
|
605
|
+
const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
|
|
555
606
|
for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
|
|
556
607
|
if (adminFloor && isAdminFloorScope(scope, relativePath)) return true;
|
|
557
608
|
let grant = false;
|
|
@@ -620,17 +671,20 @@ function isAdminEmail(model: AccessModel, email: string): boolean {
|
|
|
620
671
|
* owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
|
|
621
672
|
* authority they did not have; it only gives it a door.
|
|
622
673
|
*
|
|
623
|
-
* One floor for `write
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* whatever the root file says, so no root
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
674
|
+
* One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
|
|
675
|
+
* during the walk: an admin (same definition) always holds both at the
|
|
676
|
+
* repository root. When the walk reaches the root `access.md` (or the own
|
|
677
|
+
* frontmatter of a file directly in the root) — or runs out of scopes without
|
|
678
|
+
* a verdict — an admin is granted, whatever the root file says, so no root
|
|
679
|
+
* rule can lock the deployment out of its own tree, and nobody who may change
|
|
680
|
+
* everything under the root is refused the sight of it. A NEARER scope still
|
|
681
|
+
* decides first: a subfolder that denies Admin (or denies `everyone` without
|
|
682
|
+
* naming Admin) excludes admins there like anyone else. The root file's own
|
|
683
|
+
* entries are not rewritten — the floor is a verdict, not a grant line — so
|
|
684
|
+
* `grantSources` stays file-backed.
|
|
632
685
|
*
|
|
633
|
-
* No special-
|
|
686
|
+
* No special-case for `download` (or `owner`) — they fall through to scope
|
|
687
|
+
* resolution; write implies read, not download.
|
|
634
688
|
*/
|
|
635
689
|
function hasPermissionResolved(
|
|
636
690
|
model: AccessModel,
|
|
@@ -676,14 +730,12 @@ function decidePermissionResolved(
|
|
|
676
730
|
}
|
|
677
731
|
}
|
|
678
732
|
|
|
679
|
-
const adminFloor = verb
|
|
680
|
-
//
|
|
681
|
-
// Admin rules along the way bind them too
|
|
682
|
-
// the owner exactly as it excludes the role's
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
|
|
686
|
-
}
|
|
733
|
+
const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
|
|
734
|
+
// The deployment owner IS Admin: `principalKeysOf` gives them the role's
|
|
735
|
+
// keys, so the Admin rules along the way bind them too — a subfolder
|
|
736
|
+
// denying Admin excludes the owner exactly as it excludes the role's
|
|
737
|
+
// members, and one granting Admin admits them.
|
|
738
|
+
const userRoles = principalKeysOf(model, email);
|
|
687
739
|
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
688
740
|
|
|
689
741
|
for (const scope of scopes) {
|
|
@@ -1090,12 +1142,11 @@ function canEveryoneReadResolved(
|
|
|
1090
1142
|
// Every *named* principal must also still read. A collapsed deny may have
|
|
1091
1143
|
// been shadowed by a closer-scope `everyone` grant, so re-resolve each
|
|
1092
1144
|
// candidate through the closeness-first gate rather than trusting the
|
|
1093
|
-
// collapsed deny state.
|
|
1094
|
-
//
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
for (const email of byEmail
|
|
1098
|
-
for (const email of candidates) {
|
|
1145
|
+
// collapsed deny state. The candidates are the same set the eligible-email
|
|
1146
|
+
// lists enumerate — roles.yaml members, inline-named users, and the
|
|
1147
|
+
// deployment owner, whom a `deny Admin` carves out like any member of the
|
|
1148
|
+
// role whether or not roles.yaml names them.
|
|
1149
|
+
for (const email of namedCandidateEmails(model, byEmail)) {
|
|
1099
1150
|
if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn)) return false;
|
|
1100
1151
|
}
|
|
1101
1152
|
return true;
|
|
@@ -1106,11 +1157,14 @@ function canEveryoneReadResolved(
|
|
|
1106
1157
|
*
|
|
1107
1158
|
* Read is default-deny: a path is readable only when resolution grants the
|
|
1108
1159
|
* caller `read` directly, via one of their roles, via the built-in `everyone`
|
|
1109
|
-
* role, or via `owner:` (
|
|
1160
|
+
* role, or via `write:`/`owner:` (both imply read).
|
|
1110
1161
|
*
|
|
1111
|
-
* No admin rescue
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1162
|
+
* No admin rescue on root files (that is write's), but the Admin floor
|
|
1163
|
+
* holds: an admin — a member of the role or the deployment owner — reads
|
|
1164
|
+
* wherever no nearer scope excludes Admin, whether or not the root names it,
|
|
1165
|
+
* because the floor gives them write there and write implies read. A
|
|
1166
|
+
* subfolder that denies Admin, or everyone, takes the read away as it does
|
|
1167
|
+
* for any principal.
|
|
1114
1168
|
*/
|
|
1115
1169
|
function canReadResolved(
|
|
1116
1170
|
model: AccessModel,
|
|
@@ -1183,9 +1237,10 @@ function eligibleHoldersResolved(
|
|
|
1183
1237
|
const record = model.roles.byCanonical.get(canonical);
|
|
1184
1238
|
addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
|
|
1185
1239
|
}
|
|
1186
|
-
// The Admin
|
|
1187
|
-
// whenever no nearer scope excludes it, whether or not
|
|
1188
|
-
|
|
1240
|
+
// The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
|
|
1241
|
+
// read it implies, here whenever no nearer scope excludes it, whether or not
|
|
1242
|
+
// any file names it.
|
|
1243
|
+
if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
|
|
1189
1244
|
const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY)!;
|
|
1190
1245
|
if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY))) addPrincipal(adminRole.displayName, 'role');
|
|
1191
1246
|
}
|
|
@@ -1232,8 +1287,8 @@ function eligibleHoldersResolved(
|
|
|
1232
1287
|
|
|
1233
1288
|
/**
|
|
1234
1289
|
* Every individual the model names: `roles.yaml` members, emails named inline
|
|
1235
|
-
* at this path's scope, and the deployment owner (
|
|
1236
|
-
* even when `roles.yaml` does not list them).
|
|
1290
|
+
* at this path's scope, and the deployment owner (bound by every rule that
|
|
1291
|
+
* names Admin even when `roles.yaml` does not list them).
|
|
1237
1292
|
*/
|
|
1238
1293
|
function namedCandidateEmails(model: AccessModel, byEmail: CollapsedScope['byEmail']): Set<string> {
|
|
1239
1294
|
const candidates = new Set<string>(model.deploymentOwners);
|
|
@@ -1359,22 +1414,31 @@ export class AccessControlService implements IAccessControl {
|
|
|
1359
1414
|
*
|
|
1360
1415
|
* This is what makes the change-request gates affordable. One detail read
|
|
1361
1416
|
* plus one approval click runs six at-ref gates, and before this cache
|
|
1362
|
-
* every one of them rebuilt the model from git
|
|
1363
|
-
* `
|
|
1364
|
-
*
|
|
1417
|
+
* every one of them rebuilt the model from git. A build is two git calls
|
|
1418
|
+
* (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
|
|
1419
|
+
* file the model reads), and each call forks the server process, which
|
|
1420
|
+
* costs tens of milliseconds on a server holding a large heap.
|
|
1365
1421
|
*
|
|
1366
|
-
* Bounded FIFO
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1422
|
+
* Bounded FIFO. The bound is sized for a listing that resolves access at
|
|
1423
|
+
* every open change request's two refs: one commit per open request plus
|
|
1424
|
+
* the targets, and a knowledge base with a hundred open requests was
|
|
1425
|
+
* evicting in a cycle under the old bound of 64, rebuilding every model on
|
|
1426
|
+
* every listing — thousands of forks, minutes of the main thread, and
|
|
1427
|
+
* every other request waiting behind them. A commit's tree never changes,
|
|
1428
|
+
* so a model is good for as long as it is held; the bound only caps memory.
|
|
1429
|
+
*
|
|
1430
|
+
* A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
|
|
1431
|
+
* read answers "missing" for it from git itself, which is as final as the
|
|
1432
|
+
* commit. What is never cached is a build that saw a git read FAIL: it
|
|
1433
|
+
* throws AccessUnreadableError and the gate fails closed for that call, and
|
|
1434
|
+
* pinning that to a commit would turn one hiccup into a wrong verdict until
|
|
1435
|
+
* the branch moved. Concurrent misses on the same commit share one build
|
|
1436
|
+
* via `atRefInFlight`.
|
|
1374
1437
|
*/
|
|
1375
1438
|
private readonly atRefCache = new Map<string, AtRefModel>();
|
|
1439
|
+
private readonly atRefNoRoles = new Set<string>();
|
|
1376
1440
|
private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
|
|
1377
|
-
private static readonly AT_REF_CACHE_MAX =
|
|
1441
|
+
private static readonly AT_REF_CACHE_MAX = 512;
|
|
1378
1442
|
|
|
1379
1443
|
/**
|
|
1380
1444
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
@@ -2575,28 +2639,35 @@ export class AccessControlService implements IAccessControl {
|
|
|
2575
2639
|
/**
|
|
2576
2640
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
2577
2641
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
2578
|
-
* regardless of where it sits. Uses `git ls-tree -r --name-only
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2642
|
+
* regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
|
|
2643
|
+
* puts a NUL between paths and prints them as they are, where the default
|
|
2644
|
+
* output C-quotes a path with a non-ASCII or special character, which would
|
|
2645
|
+
* then name nothing when read back. `'error'` when git failed: a model
|
|
2646
|
+
* built without its access files is not the model, and the caller must not
|
|
2647
|
+
* cache it as one.
|
|
2581
2648
|
*/
|
|
2582
2649
|
private async listAccessFilesAtRef(
|
|
2583
2650
|
repoDir: string,
|
|
2584
2651
|
ref: string,
|
|
2585
|
-
): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
|
|
2652
|
+
): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string> } | 'error'> {
|
|
2586
2653
|
try {
|
|
2587
|
-
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
|
|
2654
|
+
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2588
2655
|
const out: string[] = [];
|
|
2589
2656
|
const pluginDirs: string[] = [];
|
|
2657
|
+
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2658
|
+
// files): a read of one of these that comes back empty is "absent"
|
|
2659
|
+
// only when the tree does not name it.
|
|
2660
|
+
const named = new Set<string>();
|
|
2590
2661
|
const { pluginsDir } = this.kb.layout;
|
|
2591
|
-
for (const
|
|
2592
|
-
const p = line.trim();
|
|
2662
|
+
for (const p of stdout.split('\0')) {
|
|
2593
2663
|
if (!p) continue;
|
|
2594
2664
|
if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
|
|
2665
|
+
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
|
|
2595
2666
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2596
2667
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2597
2668
|
}
|
|
2598
2669
|
}
|
|
2599
|
-
return { accessFiles: out, pluginDirs };
|
|
2670
|
+
return { accessFiles: out, pluginDirs, named };
|
|
2600
2671
|
} catch {
|
|
2601
2672
|
return 'error';
|
|
2602
2673
|
}
|
|
@@ -2641,6 +2712,9 @@ export class AccessControlService implements IAccessControl {
|
|
|
2641
2712
|
const key = `${workspaceId}\0${commit}`;
|
|
2642
2713
|
const hit = this.atRefCache.get(key);
|
|
2643
2714
|
if (hit) return hit;
|
|
2715
|
+
// A commit known to carry no roles.yaml: the next candidate, without a
|
|
2716
|
+
// build (see `atRefNoRoles`).
|
|
2717
|
+
if (this.atRefNoRoles.has(key)) continue;
|
|
2644
2718
|
// Registered BEFORE the first await on the build path, so concurrent
|
|
2645
2719
|
// misses on one commit share a single build instead of all racing past
|
|
2646
2720
|
// an empty in-flight check together.
|
|
@@ -2649,6 +2723,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2649
2723
|
pending = this.buildModelAtCommit(repoDir, commit, candidate)
|
|
2650
2724
|
.then((built) => {
|
|
2651
2725
|
if (typeof built !== 'string') this.rememberAtRef(key, built);
|
|
2726
|
+
else if (built === 'no-roles') this.rememberNoRolesAtRef(key);
|
|
2652
2727
|
return built;
|
|
2653
2728
|
})
|
|
2654
2729
|
.finally(() => {
|
|
@@ -2693,6 +2768,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2693
2768
|
}
|
|
2694
2769
|
}
|
|
2695
2770
|
|
|
2771
|
+
private rememberNoRolesAtRef(key: string): void {
|
|
2772
|
+
this.atRefNoRoles.add(key);
|
|
2773
|
+
if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2774
|
+
const oldest = this.atRefNoRoles.values().next().value;
|
|
2775
|
+
if (oldest !== undefined) this.atRefNoRoles.delete(oldest);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2696
2779
|
/**
|
|
2697
2780
|
* The uncached build behind `loadModelAtRef`: the whole access tree read
|
|
2698
2781
|
* at `commit`. `label` is the candidate ref the commit came from, kept for
|
|
@@ -2700,6 +2783,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
2700
2783
|
* means the commit carries no roles.yaml (the caller tries its next
|
|
2701
2784
|
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
2702
2785
|
* answers null, as it always has).
|
|
2786
|
+
*
|
|
2787
|
+
* TWO git calls, whatever the tree holds: one `ls-tree` to learn which
|
|
2788
|
+
* files the model is made of, then one `cat-file --batch` that reads all of
|
|
2789
|
+
* them — roles.yaml, the groups sources, every access.md and every plugin
|
|
2790
|
+
* manifest. It used to be one `git show` per file, and each git call forks
|
|
2791
|
+
* the server process, which on a server holding a large heap costs tens of
|
|
2792
|
+
* milliseconds of the main thread per call: a knowledge base with a few
|
|
2793
|
+
* dozen access files paid a second or more per commit, times every ref a
|
|
2794
|
+
* listing resolves.
|
|
2703
2795
|
*/
|
|
2704
2796
|
private async buildModelAtCommit(
|
|
2705
2797
|
repoDir: string,
|
|
@@ -2712,20 +2804,74 @@ export class AccessControlService implements IAccessControl {
|
|
|
2712
2804
|
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
2713
2805
|
// retry reads the tree in full.
|
|
2714
2806
|
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
2807
|
+
|
|
2808
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2809
|
+
if (listed === 'error') {
|
|
2810
|
+
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2811
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2812
|
+
}
|
|
2813
|
+
const wanted = [
|
|
2814
|
+
'roles.yaml',
|
|
2815
|
+
SYNCED_GROUPS_YAML,
|
|
2816
|
+
GROUPS_YAML,
|
|
2817
|
+
...listed.accessFiles,
|
|
2818
|
+
...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
|
|
2819
|
+
];
|
|
2820
|
+
let outcomes: CatFileOutcome[];
|
|
2821
|
+
try {
|
|
2822
|
+
outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
|
|
2823
|
+
} catch (err) {
|
|
2824
|
+
// The operational signal: one line per failed build, naming the commit,
|
|
2825
|
+
// so a run of these is visible in the logs.
|
|
2826
|
+
logger(`access@${tag}`).warn(
|
|
2827
|
+
`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`,
|
|
2828
|
+
);
|
|
2829
|
+
throw new AccessUnreadableError(label, 'access tree (cat-file)');
|
|
2830
|
+
}
|
|
2831
|
+
const fetched = new Map<string, CatFileOutcome>();
|
|
2832
|
+
wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
|
|
2833
|
+
const refuse = (relativePath: string, why: string): never => {
|
|
2834
|
+
logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
|
|
2835
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
2836
|
+
};
|
|
2837
|
+
// What the build reads. "Missing" from the batch is "absent" only for a
|
|
2838
|
+
// groups source the tree does not name. A path the tree names — an
|
|
2839
|
+
// access.md, a plugin manifest, or a groups file the listing carried —
|
|
2840
|
+
// that comes back missing has a blob git could not read (a lost object),
|
|
2841
|
+
// and one that is not a file is a tree the model cannot be read from:
|
|
2842
|
+
// either way the build fails closed rather than drop what that file
|
|
2843
|
+
// would deny. A path the build did not prefetch (none today) is read on
|
|
2844
|
+
// its own, told apart from a git failure as before.
|
|
2845
|
+
const inTree = (relativePath: string) =>
|
|
2846
|
+
listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
|
|
2715
2847
|
const read = async (relativePath: string): Promise<string | null> => {
|
|
2716
|
-
const
|
|
2717
|
-
if (
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
throw new AccessUnreadableError(label, relativePath);
|
|
2848
|
+
const got = fetched.get(relativePath);
|
|
2849
|
+
if (got === undefined) {
|
|
2850
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
2851
|
+
if (outcome.kind === 'error') refuse(relativePath, 'could not be read');
|
|
2852
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
2722
2853
|
}
|
|
2723
|
-
|
|
2854
|
+
if (got.kind === 'blob') return got.text;
|
|
2855
|
+
if (got.kind === 'non-blob') return refuse(relativePath, `is a ${got.type}, not a file`);
|
|
2856
|
+
if (!inTree(relativePath)) return null;
|
|
2857
|
+
return refuse(relativePath, 'is in the tree but its blob could not be read');
|
|
2724
2858
|
};
|
|
2725
2859
|
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2860
|
+
// roles.yaml decides the build's outcome, so its states are read
|
|
2861
|
+
// directly. Absent from the tree is "no roles here" (the caller tries
|
|
2862
|
+
// its next candidate ref). In the tree but unreadable refuses the build:
|
|
2863
|
+
// answering "no roles" for it would be remembered for the commit and
|
|
2864
|
+
// would send a short ref through to origin, which may grant. A directory
|
|
2865
|
+
// of that name is as malformed as a file that does not parse (the caller
|
|
2866
|
+
// answers null, and never falls through to a ref that might grant what
|
|
2867
|
+
// this one does not).
|
|
2868
|
+
const roles = fetched.get('roles.yaml') ?? { kind: 'missing' as const };
|
|
2869
|
+
if (roles.kind === 'missing') {
|
|
2870
|
+
if (listed.named.has('roles.yaml')) refuse('roles.yaml', 'is in the tree but its blob could not be read');
|
|
2871
|
+
return 'no-roles';
|
|
2872
|
+
}
|
|
2873
|
+
if (roles.kind === 'non-blob') return 'malformed';
|
|
2874
|
+
const rolesParsed = parseRolesYaml(roles.text);
|
|
2729
2875
|
if (!rolesParsed.ok) return 'malformed';
|
|
2730
2876
|
|
|
2731
2877
|
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
@@ -2750,11 +2896,6 @@ export class AccessControlService implements IAccessControl {
|
|
|
2750
2896
|
for (const w of mergeWarnings) logger(`access@${tag}`).warn(w);
|
|
2751
2897
|
|
|
2752
2898
|
const accessFiles = new Map<string, AccessFile>();
|
|
2753
|
-
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2754
|
-
if (listed === 'error') {
|
|
2755
|
-
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2756
|
-
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2757
|
-
}
|
|
2758
2899
|
for (const p of listed.accessFiles) {
|
|
2759
2900
|
const text = await read(p);
|
|
2760
2901
|
if (text === null) continue;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry other modules reach auth through: its admission contract, and
|
|
3
|
+
* the two things a module checking a credential or refusing a switched-off
|
|
4
|
+
* account needs to agree with auth on. The composition root wires the rest.
|
|
5
|
+
*
|
|
6
|
+
* The embed reaches auth here. Modules written before this entry existed
|
|
7
|
+
* still import auth's files directly (`auth.service.ts`, the middleware's
|
|
8
|
+
* request augmentation, `account-admission.ts`); each moves here as it is
|
|
9
|
+
* touched, and what it needs is added here then — this file holds what is
|
|
10
|
+
* offered, not everything auth has.
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
ACCOUNT_DEACTIVATED_MESSAGE,
|
|
14
|
+
AccountAdmissionRefusedError,
|
|
15
|
+
AccountChangeRefusedError,
|
|
16
|
+
AccountDeactivatedError,
|
|
17
|
+
AuthBackendError,
|
|
18
|
+
admitEveryone,
|
|
19
|
+
type AccountAdmissionVerdict,
|
|
20
|
+
type AccountProvisionReason,
|
|
21
|
+
type IAccountAdmission,
|
|
22
|
+
} from './account-admission.js';
|
|
23
|
+
// A constant-time comparison for a secret arriving on a request, so a
|
|
24
|
+
// shared-secret check elsewhere cannot leak the secret's length or prefix by
|
|
25
|
+
// timing — one implementation, auth's.
|
|
26
|
+
export { timingSafeStringEqual } from './password-hash.js';
|
|
27
|
+
// The Express `Request` augmentation (`req.userId`, …): a module whose routes
|
|
28
|
+
// read it imports this entry, never the middleware file.
|
|
29
|
+
import './auth.middleware.js';
|