@bevel-software/platform-core-backend 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +165 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +174 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
|
@@ -11,8 +11,22 @@ import type {
|
|
|
11
11
|
GrantPrincipal,
|
|
12
12
|
GrantSource,
|
|
13
13
|
GrantSources,
|
|
14
|
+
ResolvedPrincipal,
|
|
14
15
|
} from './access-control.interface.js';
|
|
15
16
|
import { AccessConfigError } from './access-errors.js';
|
|
17
|
+
// NOTE: deliberate module cycle — group-files.ts imports this module's parsing
|
|
18
|
+
// primitives. Benign: both sides only dereference the other's exports inside
|
|
19
|
+
// function bodies, never at module-evaluation time.
|
|
20
|
+
import {
|
|
21
|
+
GROUPS_YAML,
|
|
22
|
+
SYNCED_GROUPS_YAML,
|
|
23
|
+
parseGroupsFile,
|
|
24
|
+
type GroupsIndex,
|
|
25
|
+
} from './group-files.js';
|
|
26
|
+
import {
|
|
27
|
+
DIRECTORY_SYNC_BOT_EMAIL,
|
|
28
|
+
DIRECTORY_SYNC_BOT_NAME,
|
|
29
|
+
} from './directory-sync-bot.js';
|
|
16
30
|
|
|
17
31
|
const execFileAsync = promisify(execFile);
|
|
18
32
|
|
|
@@ -133,6 +147,24 @@ export function sourceVerbsFor(verb: Verb): Verb[] {
|
|
|
133
147
|
}
|
|
134
148
|
export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
|
|
135
149
|
export const DENY_PREFIX = 'deny ';
|
|
150
|
+
/**
|
|
151
|
+
* Member-entry prefix in roles.yaml that references a GROUP instead of an
|
|
152
|
+
* email: `- group:Engineering`. Explicit on purpose — membership kind is
|
|
153
|
+
* never guessed from string shape. Valid on EVERY role including Admin, with
|
|
154
|
+
* one kept invariant (see `parseRolesYaml`): Admin must always retain at
|
|
155
|
+
* least one direct email member, so a misconfigured or unreachable directory
|
|
156
|
+
* can never leave the deployment without a rescuable admin.
|
|
157
|
+
*/
|
|
158
|
+
export const GROUP_REF_PREFIX = 'group:';
|
|
159
|
+
/**
|
|
160
|
+
* Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
|
|
161
|
+
* the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
|
|
162
|
+
* and falls back to the role — so `role/` is the escape hatch when a group
|
|
163
|
+
* shares the role's name. The prefix is reserved in the group name-safety
|
|
164
|
+
* rules (a group may never be named `role/...`), and every role is also
|
|
165
|
+
* registered in the principal index under its `role/<canonical>` alias.
|
|
166
|
+
*/
|
|
167
|
+
export const ROLE_TOKEN_PREFIX = 'role/';
|
|
136
168
|
|
|
137
169
|
export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
|
|
138
170
|
export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
|
|
@@ -180,14 +212,50 @@ export function isAccessMdPath(p: string): boolean {
|
|
|
180
212
|
return p === 'access.md' || p.endsWith('/access.md');
|
|
181
213
|
}
|
|
182
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Extensions of node files whose OWN `---` frontmatter can carry access verbs
|
|
217
|
+
* the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
|
|
218
|
+
* SINGLE source of truth for every surface that enumerates candidate files —
|
|
219
|
+
* the access-declarations scan and the shared `KbReferenceScanner` (which
|
|
220
|
+
* must scan/rewrite the same set, or a rename strands a live `.tool`
|
|
221
|
+
* frontmatter grant). `access.md` is covered by `.md`.
|
|
222
|
+
*/
|
|
223
|
+
export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
|
|
224
|
+
|
|
225
|
+
/** True when `p` is a file the resolver reads access frontmatter from. */
|
|
226
|
+
export function hasAccessFrontmatterExtension(p: string): boolean {
|
|
227
|
+
return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The PRINCIPAL index — canonical name → member emails. Despite the name it
|
|
232
|
+
* holds both kinds of named principal after `mergeGroupsIntoRoles` runs:
|
|
233
|
+
* roles.yaml roles (`kind: 'role'`, the default) and the active group file's
|
|
234
|
+
* groups (`kind: 'group'`). Grant resolution treats them identically — a
|
|
235
|
+
* grant names a principal, the principal has member emails — which is what
|
|
236
|
+
* lets the whole closeness-first resolver work on groups without changes.
|
|
237
|
+
*
|
|
238
|
+
* `groupRefs` carries a role's `group:<Name>` member entries between parse
|
|
239
|
+
* and merge; the merge expands them into `emails`/`byEmail`.
|
|
240
|
+
*/
|
|
183
241
|
interface RolesIndex {
|
|
184
|
-
byCanonical: Map<
|
|
242
|
+
byCanonical: Map<
|
|
243
|
+
string,
|
|
244
|
+
{ displayName: string; emails: Set<string>; groupRefs?: Set<string>; kind?: 'role' | 'group' }
|
|
245
|
+
>;
|
|
185
246
|
byEmail: Map<string, Set<string>>;
|
|
186
247
|
}
|
|
187
248
|
|
|
188
249
|
interface AccessModel {
|
|
189
250
|
roles: RolesIndex;
|
|
190
251
|
accessFilesByDir: Map<string, AccessFile>;
|
|
252
|
+
/**
|
|
253
|
+
* Whether the active groups source loaded cleanly — see {@link GroupsHealth}.
|
|
254
|
+
* A broken source degrades (groups contribute nothing) but is recorded here
|
|
255
|
+
* so admin surfaces can banner it instead of silently resolving without
|
|
256
|
+
* groups.
|
|
257
|
+
*/
|
|
258
|
+
groupsHealth: GroupsHealth;
|
|
191
259
|
/**
|
|
192
260
|
* Canonical emails that count as Admin whatever `roles.yaml` says — the
|
|
193
261
|
* deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
|
|
@@ -230,7 +298,14 @@ interface YamlErr {
|
|
|
230
298
|
error: string;
|
|
231
299
|
}
|
|
232
300
|
|
|
233
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
|
|
303
|
+
* line: a `#` at the start (after only whitespace) or preceded by whitespace
|
|
304
|
+
* begins a comment. Exported so the reference scanner matches tokens against
|
|
305
|
+
* the SAME comment rule the resolver parses with (a `- GTM Team # sales`
|
|
306
|
+
* entry is the token `GTM Team`, never `GTM Team # sales`).
|
|
307
|
+
*/
|
|
308
|
+
export function stripComment(line: string): string {
|
|
234
309
|
let inWs = true;
|
|
235
310
|
for (let i = 0; i < line.length; i++) {
|
|
236
311
|
const ch = line[i];
|
|
@@ -250,9 +325,17 @@ interface Token {
|
|
|
250
325
|
value: string;
|
|
251
326
|
}
|
|
252
327
|
|
|
253
|
-
function tokenise(
|
|
328
|
+
function tokenise(
|
|
329
|
+
text: string,
|
|
330
|
+
opts?: { tolerateEmptyKeys?: boolean },
|
|
331
|
+
): YamlErr | { ok: true; tokens: Token[] } {
|
|
254
332
|
const lines = text.split(/\r?\n/);
|
|
255
333
|
const tokens: Token[] = [];
|
|
334
|
+
// Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
|
|
335
|
+
// key doesn't trip the duplicate-key check (which would retire every valid
|
|
336
|
+
// group after it). All-whitespace keys still canonicalize to '' downstream,
|
|
337
|
+
// so the entry-level "empty group name — skipped" handling sees them all.
|
|
338
|
+
let blankKeySeq = 0;
|
|
256
339
|
for (let i = 0; i < lines.length; i++) {
|
|
257
340
|
const stripped = stripComment(lines[i]).replace(/\s+$/, '');
|
|
258
341
|
if (!stripped.trim()) continue;
|
|
@@ -270,16 +353,28 @@ function tokenise(text: string): YamlErr | { ok: true; tokens: Token[] } {
|
|
|
270
353
|
if (colonIdx < 0) {
|
|
271
354
|
return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
|
|
272
355
|
}
|
|
273
|
-
|
|
356
|
+
let key = content.slice(0, colonIdx).trim();
|
|
274
357
|
const valuePart = content.slice(colonIdx + 1).trim();
|
|
275
|
-
|
|
358
|
+
// An empty key is normally a hard error (roles.yaml/access.md want loud
|
|
359
|
+
// failures), but a parser with ENTRY-level forgiveness (the group files:
|
|
360
|
+
// one bad entry must not retire every other group) keeps it as a
|
|
361
|
+
// whitespace key for its own skip-with-warning handling.
|
|
362
|
+
if (!key) {
|
|
363
|
+
if (!opts?.tolerateEmptyKeys) {
|
|
364
|
+
return { ok: false, error: `line ${lineNum}: empty mapping key` };
|
|
365
|
+
}
|
|
366
|
+
key = ' '.repeat(++blankKeySeq);
|
|
367
|
+
}
|
|
276
368
|
tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
|
|
277
369
|
}
|
|
278
370
|
return { ok: true, tokens };
|
|
279
371
|
}
|
|
280
372
|
|
|
281
|
-
export function parseYamlSubset(
|
|
282
|
-
|
|
373
|
+
export function parseYamlSubset(
|
|
374
|
+
text: string,
|
|
375
|
+
opts?: { tolerateEmptyKeys?: boolean },
|
|
376
|
+
): YamlOk | YamlErr {
|
|
377
|
+
const tok = tokenise(text, opts);
|
|
283
378
|
if (!tok.ok) return tok;
|
|
284
379
|
if (tok.tokens.length === 0) return { ok: true, value: {} };
|
|
285
380
|
|
|
@@ -426,8 +521,16 @@ export function parseAccessEntry(
|
|
|
426
521
|
};
|
|
427
522
|
}
|
|
428
523
|
|
|
429
|
-
|
|
524
|
+
let role = canonicalRoleName(body);
|
|
430
525
|
if (!role) return { ok: false, error: `empty role name in entry '${raw}'` };
|
|
526
|
+
// Explicit role token: normalize `role/ <Name>` spacing so the canonical
|
|
527
|
+
// form is always `role/<canonicalName>` — the exact alias key the principal
|
|
528
|
+
// index registers for every role.
|
|
529
|
+
if (role.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
530
|
+
const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
|
|
531
|
+
if (!suffix) return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
|
|
532
|
+
role = `${ROLE_TOKEN_PREFIX}${suffix}`;
|
|
533
|
+
}
|
|
431
534
|
return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
|
|
432
535
|
}
|
|
433
536
|
|
|
@@ -469,6 +572,12 @@ export function parseRolesYaml(
|
|
|
469
572
|
);
|
|
470
573
|
continue;
|
|
471
574
|
}
|
|
575
|
+
if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
576
|
+
errors.push(
|
|
577
|
+
`roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`,
|
|
578
|
+
);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
472
581
|
if (index.byCanonical.has(canonical)) {
|
|
473
582
|
const prev = index.byCanonical.get(canonical)!.displayName;
|
|
474
583
|
errors.push(
|
|
@@ -481,11 +590,26 @@ export function parseRolesYaml(
|
|
|
481
590
|
continue;
|
|
482
591
|
}
|
|
483
592
|
const emails = new Set<string>();
|
|
593
|
+
const groupRefs = new Set<string>();
|
|
484
594
|
for (const rawEmail of value) {
|
|
485
595
|
if (typeof rawEmail !== 'string') {
|
|
486
596
|
errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
|
|
487
597
|
continue;
|
|
488
598
|
}
|
|
599
|
+
// `- group:<Name>` assigns the role to a whole group (expanded against
|
|
600
|
+
// the active group source by `mergeGroupsIntoRoles`). Allowed on every
|
|
601
|
+
// role, Admin included — the Admin invariant below only demands at
|
|
602
|
+
// least one DIRECT email member so a broken directory can never leave
|
|
603
|
+
// the deployment adminless.
|
|
604
|
+
if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
|
|
605
|
+
const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
|
|
606
|
+
if (!refName) {
|
|
607
|
+
errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
groupRefs.add(refName);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
489
613
|
const email = canonicalEmail(rawEmail);
|
|
490
614
|
if (!EMAIL_REGEX.test(email)) {
|
|
491
615
|
errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
|
|
@@ -499,19 +623,182 @@ export function parseRolesYaml(
|
|
|
499
623
|
}
|
|
500
624
|
set.add(canonical);
|
|
501
625
|
}
|
|
502
|
-
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
|
|
626
|
+
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
|
|
503
627
|
}
|
|
504
628
|
|
|
505
629
|
if (!index.byCanonical.has(ADMIN_CANONICAL)) {
|
|
506
630
|
errors.push(`roles.yaml: must declare at least one 'Admin' role`);
|
|
507
631
|
} else if (index.byCanonical.get(ADMIN_CANONICAL)!.emails.size === 0) {
|
|
508
|
-
|
|
632
|
+
// The kept invariant: Admin may reference groups, but must ALWAYS retain
|
|
633
|
+
// at least one direct email member — the rescue story requires an admin
|
|
634
|
+
// whose membership does not depend on a reachable, well-configured
|
|
635
|
+
// directory. A group-only Admin is as hard an error as an adminless one.
|
|
636
|
+
errors.push(
|
|
637
|
+
`roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`,
|
|
638
|
+
);
|
|
509
639
|
}
|
|
510
640
|
|
|
511
641
|
if (errors.length) return { ok: false, errors };
|
|
512
642
|
return { ok: true, index };
|
|
513
643
|
}
|
|
514
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Merge the active group source into the principal index and expand role →
|
|
647
|
+
* group assignments. Mutates `index` in place; returns human-readable
|
|
648
|
+
* warnings (callers log them — nothing here ever throws, because group
|
|
649
|
+
* problems must degrade, not brick access resolution).
|
|
650
|
+
*
|
|
651
|
+
* Rules (the grant-grammar precedence):
|
|
652
|
+
* - Every role is ALSO registered under its explicit `role/<canonical>`
|
|
653
|
+
* alias — the token that always resolves to the role.
|
|
654
|
+
* - A BARE name resolves GROUP-FIRST: when a group's canonical name
|
|
655
|
+
* collides with a role's, the bare key resolves to the GROUP (warned);
|
|
656
|
+
* the role stays reachable via `role/<canonical>`.
|
|
657
|
+
* - Role `group:<Name>` refs — Admin's included — expand against the
|
|
658
|
+
* merged groups; an unknown ref contributes nothing (warned).
|
|
659
|
+
* - `byEmail` is rebuilt so each member holds exactly the tokens that
|
|
660
|
+
* resolve to a principal they belong to (bare + `role/` alias for roles,
|
|
661
|
+
* bare for groups).
|
|
662
|
+
*/
|
|
663
|
+
export function mergeGroupsIntoRoles(
|
|
664
|
+
index: RolesIndex,
|
|
665
|
+
groups: GroupsIndex,
|
|
666
|
+
sourceFile: string,
|
|
667
|
+
): string[] {
|
|
668
|
+
const warnings: string[] = [];
|
|
669
|
+
// Snapshot before any group lands: at this point the index holds roles only.
|
|
670
|
+
const roleRecords = new Map(index.byCanonical);
|
|
671
|
+
|
|
672
|
+
// 1. Expand role → group assignments (Admin included — its safety net is
|
|
673
|
+
// the parse-time "at least one direct email" invariant, not a merge skip).
|
|
674
|
+
for (const [, principal] of roleRecords) {
|
|
675
|
+
if (!principal.groupRefs?.size) continue;
|
|
676
|
+
for (const ref of principal.groupRefs) {
|
|
677
|
+
const group = groups.get(ref);
|
|
678
|
+
if (!group) {
|
|
679
|
+
warnings.push(
|
|
680
|
+
`roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`,
|
|
681
|
+
);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
for (const email of group.emails) principal.emails.add(email);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 2. Bare-name precedence: groups win the bare token; the role keeps its
|
|
689
|
+
// `role/<canonical>` alias registered below.
|
|
690
|
+
for (const [canonical, def] of groups) {
|
|
691
|
+
if (roleRecords.has(canonical)) {
|
|
692
|
+
warnings.push(
|
|
693
|
+
`${sourceFile}: group '${def.displayName}' shares its name with a role — the bare name now resolves to the GROUP; use '${ROLE_TOKEN_PREFIX}${canonical}' to reference the role`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
index.byCanonical.set(canonical, {
|
|
697
|
+
displayName: def.displayName,
|
|
698
|
+
emails: new Set(def.emails),
|
|
699
|
+
kind: 'group',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 3. Explicit `role/<canonical>` alias for every role (same record — the
|
|
704
|
+
// alias and the bare key, when the role still owns it, stay in lockstep).
|
|
705
|
+
for (const [canonical, principal] of roleRecords) {
|
|
706
|
+
principal.kind = 'role';
|
|
707
|
+
index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// 4. Rebuild the email → tokens map from the final index so membership
|
|
711
|
+
// reflects the post-precedence keys (a collided role's members no longer
|
|
712
|
+
// hold the bare token unless the group also contains them).
|
|
713
|
+
index.byEmail.clear();
|
|
714
|
+
for (const [key, principal] of index.byCanonical) {
|
|
715
|
+
for (const email of principal.emails) {
|
|
716
|
+
let set = index.byEmail.get(email);
|
|
717
|
+
if (!set) {
|
|
718
|
+
set = new Set();
|
|
719
|
+
index.byEmail.set(email, set);
|
|
720
|
+
}
|
|
721
|
+
set.add(key);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return warnings;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Health of the ACTIVE groups source as of the last load. `ok: false` means
|
|
730
|
+
* the source EXISTS but could not be read or parsed — groups contribute
|
|
731
|
+
* nothing, bare grant tokens fall through to roles, and group-backed denies
|
|
732
|
+
* drop (the owner's explicit degrade-loudly decision, NOT fail-closed). The
|
|
733
|
+
* marker is carried on the loaded model and surfaced by the groups admin
|
|
734
|
+
* endpoints so the Groups page can banner it.
|
|
735
|
+
*/
|
|
736
|
+
export type GroupsHealth = { ok: true } | { ok: false; file: string; reason: string };
|
|
737
|
+
|
|
738
|
+
/** True for the errno codes that mean "the file genuinely is not there". */
|
|
739
|
+
function isAbsenceError(err: unknown): boolean {
|
|
740
|
+
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
741
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Load the ACTIVE group source through `read` (working tree or at-ref — the
|
|
746
|
+
* caller supplies the reader, so both model loaders share one mode rule):
|
|
747
|
+
* `synced-groups.yaml` existing → IdP mode (groups.yaml ignored entirely,
|
|
748
|
+
* even when the synced file is empty or malformed — falling back would
|
|
749
|
+
* resurrect retired manual groups); otherwise `groups.yaml` → manual mode.
|
|
750
|
+
*
|
|
751
|
+
* `read` returns null for a genuinely-absent file and may THROW for any other
|
|
752
|
+
* failure. Only ENOENT/ENOTDIR count as absent (the caller may also whitelist
|
|
753
|
+
* them into null); every other read error — notably a non-absence error on
|
|
754
|
+
* `synced-groups.yaml` — is treated as a BROKEN source: no groups, no
|
|
755
|
+
* fallback to the manual file (falling back would resurrect retired groups),
|
|
756
|
+
* and an `ok: false` health marker. Structural parse failures degrade the
|
|
757
|
+
* same way; this function never throws.
|
|
758
|
+
*/
|
|
759
|
+
export async function loadActiveGroups(
|
|
760
|
+
read: (filename: string) => Promise<string | null>,
|
|
761
|
+
): Promise<{ groups: GroupsIndex; sourceFile: string; warnings: string[]; health: GroupsHealth }> {
|
|
762
|
+
const broken = (file: string, reason: string) => ({
|
|
763
|
+
groups: new Map() as GroupsIndex,
|
|
764
|
+
sourceFile: file,
|
|
765
|
+
warnings: [],
|
|
766
|
+
health: { ok: false as const, file, reason },
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
let syncedText: string | null;
|
|
770
|
+
try {
|
|
771
|
+
syncedText = await read(SYNCED_GROUPS_YAML);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
if (isAbsenceError(err)) {
|
|
774
|
+
syncedText = null;
|
|
775
|
+
} else {
|
|
776
|
+
// A non-absence read error on the SYNCED source must NOT fall back to
|
|
777
|
+
// groups.yaml — the synced file may exist and its manual predecessor is
|
|
778
|
+
// retired. Broken-groups instead.
|
|
779
|
+
return broken(SYNCED_GROUPS_YAML, err instanceof Error ? err.message : String(err));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const sourceFile = syncedText !== null ? SYNCED_GROUPS_YAML : GROUPS_YAML;
|
|
784
|
+
let text: string | null;
|
|
785
|
+
if (syncedText !== null) {
|
|
786
|
+
text = syncedText;
|
|
787
|
+
} else {
|
|
788
|
+
try {
|
|
789
|
+
text = await read(GROUPS_YAML);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
if (isAbsenceError(err)) text = null;
|
|
792
|
+
else return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (text === null) return { groups: new Map(), sourceFile, warnings: [], health: { ok: true } };
|
|
796
|
+
|
|
797
|
+
const parsed = parseGroupsFile(text, sourceFile);
|
|
798
|
+
if (!parsed.ok) return broken(sourceFile, parsed.errors.join('; '));
|
|
799
|
+
return { groups: parsed.groups, sourceFile, warnings: parsed.warnings, health: { ok: true } };
|
|
800
|
+
}
|
|
801
|
+
|
|
515
802
|
/**
|
|
516
803
|
* Does an `access.md` body declare access rules — i.e. is the file in the NEW
|
|
517
804
|
* (body-governs-the-folder) format?
|
|
@@ -807,7 +1094,10 @@ function resolveAtPath(
|
|
|
807
1094
|
function isAdminEmail(model: AccessModel, email: string): boolean {
|
|
808
1095
|
if (model.deploymentOwners.has(email)) return true;
|
|
809
1096
|
const roles = model.roles.byEmail.get(email);
|
|
810
|
-
|
|
1097
|
+
// Check the explicit `role/admin` alias, NOT the bare token: bare-name
|
|
1098
|
+
// precedence is group-first, so a group that happens to be named "Admin"
|
|
1099
|
+
// owns the bare key — and its members must never inherit the capability.
|
|
1100
|
+
return !!roles && roles.has(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
|
|
811
1101
|
}
|
|
812
1102
|
|
|
813
1103
|
/**
|
|
@@ -925,7 +1215,7 @@ function scopeToGrantSource(
|
|
|
925
1215
|
* returning the WHOLE list of removable entries rather than just the winner.
|
|
926
1216
|
*
|
|
927
1217
|
* Only the principal's OWN named entry yields a source — a `user` by their email,
|
|
928
|
-
* a `role` by its role token. A grant that reaches the user via a
|
|
1218
|
+
* a `role` by its role token. A grant that reaches the user via a group/role they
|
|
929
1219
|
* belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
|
|
930
1220
|
* never adds a source (the group/role shows as its own row instead). The list is:
|
|
931
1221
|
* - `[]` (verb omitted by the caller) when the principal effectively holds no
|
|
@@ -941,7 +1231,7 @@ function scopeToGrantSource(
|
|
|
941
1231
|
* under closest-wins), and the revoke flow needs the inherited remainder that
|
|
942
1232
|
* survives removing the direct entry. A group/everyone grant at some scope does
|
|
943
1233
|
* not add a source AND does not hide a farther own-entry the principal is named
|
|
944
|
-
* in (removing it is still meaningful if the
|
|
1234
|
+
* in (removing it is still meaningful if the group/role grant is later removed).
|
|
945
1235
|
*/
|
|
946
1236
|
function resolveGrantSourcesForVerb(
|
|
947
1237
|
model: AccessModel,
|
|
@@ -950,16 +1240,48 @@ function resolveGrantSourcesForVerb(
|
|
|
950
1240
|
relativePath: string,
|
|
951
1241
|
principal: GrantPrincipal,
|
|
952
1242
|
fileOwn?: OwnEntries | null,
|
|
1243
|
+
tokenMatch?: 'exact' | 'name',
|
|
953
1244
|
): GrantSource[] {
|
|
954
1245
|
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
955
1246
|
const out: GrantSource[] = [];
|
|
956
1247
|
|
|
957
1248
|
if (principal.kind === 'role') {
|
|
958
|
-
|
|
1249
|
+
// A named principal can be spelled two ways in a file: the bare token and
|
|
1250
|
+
// the explicit `role/<name>` token. WHICH spellings are THIS principal's
|
|
1251
|
+
// entries depends on who owns the bare key in the merged index:
|
|
1252
|
+
//
|
|
1253
|
+
// - UNSHADOWED (no group named `<bare>`): both spellings resolve to the
|
|
1254
|
+
// role, so both count — a grant under either adds a source; a deny
|
|
1255
|
+
// under either cuts off farther grants. (The revoke splice strips both
|
|
1256
|
+
// spellings in this case too, so classification and removal agree.)
|
|
1257
|
+
// - SHADOWED (a group owns the bare key): the spellings are DIFFERENT
|
|
1258
|
+
// principals. A bare-token principal is the GROUP — only bare entries
|
|
1259
|
+
// are its own; a `role/`-token principal is the ROLE — only `role/`
|
|
1260
|
+
// entries are its own. Counting the other spelling would attribute a
|
|
1261
|
+
// shadowed bare token to the role (hiding a real `role/<name>`
|
|
1262
|
+
// ancestor grant and making its revoke a false no-op), or vice versa.
|
|
1263
|
+
//
|
|
1264
|
+
// - PINNED EXACT (`tokenMatch: 'exact'`): only the literally-spelled
|
|
1265
|
+
// token is this principal's entry, shadowing notwithstanding. The
|
|
1266
|
+
// caller pinned the same identity into the splice it is checking —
|
|
1267
|
+
// a GROUP whose group has VANISHED reads "unshadowed" here, and the
|
|
1268
|
+
// alias-tolerant pair would then attribute a same-named role's
|
|
1269
|
+
// surviving `role/<name>` grant to the group.
|
|
1270
|
+
const canonical = canonicalRoleName(principal.role);
|
|
1271
|
+
const explicit = canonical.startsWith(ROLE_TOKEN_PREFIX);
|
|
1272
|
+
const bare = explicit ? canonical.slice(ROLE_TOKEN_PREFIX.length) : canonical;
|
|
1273
|
+
const shadowed = model.roles.byCanonical.get(bare)?.kind === 'group';
|
|
1274
|
+
const tokens =
|
|
1275
|
+
tokenMatch === 'exact' || shadowed
|
|
1276
|
+
? [explicit ? `${ROLE_TOKEN_PREFIX}${bare}` : bare]
|
|
1277
|
+
: [bare, `${ROLE_TOKEN_PREFIX}${bare}`];
|
|
959
1278
|
for (const scope of scopes) {
|
|
960
|
-
const
|
|
961
|
-
if (
|
|
962
|
-
|
|
1279
|
+
const states = tokens.map((t) => scope.byRole.get(t));
|
|
1280
|
+
if (states.includes('grant')) {
|
|
1281
|
+
out.push(scopeToGrantSource(scope, kind, relativePath));
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (states.includes('denied')) break; // a closer deny cuts off farther grants
|
|
963
1285
|
}
|
|
964
1286
|
return out;
|
|
965
1287
|
}
|
|
@@ -1058,14 +1380,29 @@ function eligibleHoldersResolved(
|
|
|
1058
1380
|
verb: Verb,
|
|
1059
1381
|
relativePath: string,
|
|
1060
1382
|
fileOwn?: OwnEntries | null,
|
|
1061
|
-
): { roles: string[]; users: { name: string; email: string }[] } {
|
|
1383
|
+
): { principals: ResolvedPrincipal[]; roles: string[]; users: { name: string; email: string }[] } {
|
|
1062
1384
|
const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
|
|
1063
1385
|
|
|
1064
|
-
|
|
1386
|
+
// (kind, display name) → one entry. The `byRole` keys are the merged
|
|
1387
|
+
// index's canonical tokens exactly as granted (bare, or the
|
|
1388
|
+
// `role/<canonical>` alias); the `byCanonical` record a key hits carries
|
|
1389
|
+
// its kind — a `role/` alias always hits the role record, a bare key hits
|
|
1390
|
+
// whichever principal owns it under group-first precedence. A token with no
|
|
1391
|
+
// record (the built-in `everyone`, or a grant naming a since-vanished
|
|
1392
|
+
// principal) degrades to 'role', the pre-groups display. When one display
|
|
1393
|
+
// name is granted as BOTH (a role via its `role/` alias plus a same-named
|
|
1394
|
+
// group via the bare token) BOTH entries survive — they are DIFFERENT
|
|
1395
|
+
// principals, and collapsing them to one would hide the role's live
|
|
1396
|
+
// `role/<name>` grant from every consumer of the eligible list.
|
|
1397
|
+
const byIdentity = new Map<string, ResolvedPrincipal>();
|
|
1398
|
+
const addPrincipal = (name: string, kind: 'role' | 'group') => {
|
|
1399
|
+
const key = `${kind}\0${name.toLowerCase()}`;
|
|
1400
|
+
if (!byIdentity.has(key)) byIdentity.set(key, { name, kind });
|
|
1401
|
+
};
|
|
1065
1402
|
for (const [canonical, state] of byRole) {
|
|
1066
1403
|
if (state !== 'grant') continue;
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1404
|
+
const record = model.roles.byCanonical.get(canonical);
|
|
1405
|
+
addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
|
|
1069
1406
|
}
|
|
1070
1407
|
|
|
1071
1408
|
// Mirror the admin overrides applied in `hasPermissionResolved`: write on
|
|
@@ -1077,11 +1414,20 @@ function eligibleHoldersResolved(
|
|
|
1077
1414
|
verb === 'write' &&
|
|
1078
1415
|
(relativePath === 'roles.yaml' || isAccessMdPath(relativePath))
|
|
1079
1416
|
) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1417
|
+
// Look the Admin ROLE up via its explicit alias — the bare key may be
|
|
1418
|
+
// owned by a same-named group under group-first precedence. The override
|
|
1419
|
+
// is the ROLE's capability, so the row's kind is 'role' regardless.
|
|
1420
|
+
const adminRole = model.roles.byCanonical.get(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
|
|
1421
|
+
addPrincipal(adminRole ? adminRole.displayName : ADMIN_CANONICAL, 'role');
|
|
1082
1422
|
}
|
|
1083
1423
|
|
|
1084
|
-
const
|
|
1424
|
+
const principals: ResolvedPrincipal[] = [...byIdentity.values()].sort((a, b) =>
|
|
1425
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0,
|
|
1426
|
+
);
|
|
1427
|
+
// Legacy name-only list, kind erased — kept for the many consumers that
|
|
1428
|
+
// only ever render names (banners, contact lines, PR-routing messages).
|
|
1429
|
+
// De-duplicated: a name granted as both group and role appears once here.
|
|
1430
|
+
const roles = [...new Set(principals.map((p) => p.name))];
|
|
1085
1431
|
|
|
1086
1432
|
const users: { name: string; email: string }[] = [];
|
|
1087
1433
|
for (const [email, state] of byEmail) {
|
|
@@ -1090,7 +1436,7 @@ function eligibleHoldersResolved(
|
|
|
1090
1436
|
}
|
|
1091
1437
|
users.sort((a, b) => a.email.localeCompare(b.email));
|
|
1092
1438
|
|
|
1093
|
-
return { roles, users };
|
|
1439
|
+
return { principals, roles, users };
|
|
1094
1440
|
}
|
|
1095
1441
|
|
|
1096
1442
|
/**
|
|
@@ -1254,35 +1600,13 @@ export class AccessControlService implements IAccessControl {
|
|
|
1254
1600
|
return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
|
|
1255
1601
|
}
|
|
1256
1602
|
|
|
1257
|
-
/**
|
|
1258
|
-
* Advisory scan of folder `access.md` files for references to a role (by
|
|
1259
|
-
* canonical name). See `IAccessControl.referencesToRole` — undercounts node
|
|
1260
|
-
* frontmatter by design; powers the delete warning only, never the rename
|
|
1261
|
-
* gate.
|
|
1262
|
-
*/
|
|
1263
|
-
async referencesToRole(
|
|
1264
|
-
workspaceId: string,
|
|
1265
|
-
canonicalRole: string,
|
|
1266
|
-
): Promise<{ path: string; verb: string }[]> {
|
|
1267
|
-
const model = await this.loadModel(workspaceId);
|
|
1268
|
-
const out: { path: string; verb: string }[] = [];
|
|
1269
|
-
for (const file of model.accessFilesByDir.values()) {
|
|
1270
|
-
for (const verb of KNOWN_VERBS) {
|
|
1271
|
-
for (const entry of file.entries[verb]) {
|
|
1272
|
-
if (entry.kind === 'role' && entry.role === canonicalRole) {
|
|
1273
|
-
out.push({ path: file.path, verb });
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
return out;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
1603
|
async canWrite(
|
|
1282
1604
|
workspaceId: string,
|
|
1283
1605
|
userEmail: string,
|
|
1284
1606
|
relativePath: string,
|
|
1285
1607
|
): Promise<boolean> {
|
|
1608
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
|
|
1609
|
+
if (machineOwned !== null) return machineOwned;
|
|
1286
1610
|
const model = await this.loadModel(workspaceId);
|
|
1287
1611
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1288
1612
|
return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
|
|
@@ -1327,7 +1651,8 @@ export class AccessControlService implements IAccessControl {
|
|
|
1327
1651
|
const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
|
|
1328
1652
|
const result = new Map<string, boolean>();
|
|
1329
1653
|
relativePaths.forEach((p, i) => {
|
|
1330
|
-
|
|
1654
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, p);
|
|
1655
|
+
result.set(p, machineOwned ?? hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
|
|
1331
1656
|
});
|
|
1332
1657
|
return result;
|
|
1333
1658
|
}
|
|
@@ -1371,7 +1696,11 @@ export class AccessControlService implements IAccessControl {
|
|
|
1371
1696
|
async eligibleOwners(
|
|
1372
1697
|
workspaceId: string,
|
|
1373
1698
|
relativePath: string,
|
|
1374
|
-
): Promise<{
|
|
1699
|
+
): Promise<{
|
|
1700
|
+
principals: ResolvedPrincipal[];
|
|
1701
|
+
roles: string[];
|
|
1702
|
+
users: { name: string; email: string }[];
|
|
1703
|
+
}> {
|
|
1375
1704
|
const model = await this.loadModel(workspaceId);
|
|
1376
1705
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1377
1706
|
return eligibleHoldersResolved(model, 'owner', relativePath, own);
|
|
@@ -1380,7 +1709,11 @@ export class AccessControlService implements IAccessControl {
|
|
|
1380
1709
|
async eligibleWriters(
|
|
1381
1710
|
workspaceId: string,
|
|
1382
1711
|
relativePath: string,
|
|
1383
|
-
): Promise<{
|
|
1712
|
+
): Promise<{
|
|
1713
|
+
principals: ResolvedPrincipal[];
|
|
1714
|
+
roles: string[];
|
|
1715
|
+
users: { name: string; email: string }[];
|
|
1716
|
+
}> {
|
|
1384
1717
|
const model = await this.loadModel(workspaceId);
|
|
1385
1718
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1386
1719
|
return eligibleHoldersResolved(model, 'write', relativePath, own);
|
|
@@ -1389,23 +1722,32 @@ export class AccessControlService implements IAccessControl {
|
|
|
1389
1722
|
async eligibleReaders(
|
|
1390
1723
|
workspaceId: string,
|
|
1391
1724
|
relativePath: string,
|
|
1392
|
-
): Promise<{
|
|
1725
|
+
): Promise<{
|
|
1726
|
+
restricted: boolean;
|
|
1727
|
+
principals: ResolvedPrincipal[];
|
|
1728
|
+
roles: string[];
|
|
1729
|
+
users: { name: string; email: string }[];
|
|
1730
|
+
}> {
|
|
1393
1731
|
const model = await this.loadModel(workspaceId);
|
|
1394
1732
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1395
1733
|
// When `read: everyone` applies cleanly, the node is readable by all users
|
|
1396
1734
|
// and the role/user lists are meaningless. Otherwise return the explicit
|
|
1397
1735
|
// reader set; it may be empty for a default-denied path with no grants.
|
|
1398
1736
|
if (canEveryoneReadResolved(model, relativePath, own)) {
|
|
1399
|
-
return { restricted: false, roles: [], users: [] };
|
|
1737
|
+
return { restricted: false, principals: [], roles: [], users: [] };
|
|
1400
1738
|
}
|
|
1401
|
-
const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
|
|
1402
|
-
return { restricted: true, roles, users };
|
|
1739
|
+
const { principals, roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
|
|
1740
|
+
return { restricted: true, principals, roles, users };
|
|
1403
1741
|
}
|
|
1404
1742
|
|
|
1405
1743
|
async eligibleDownloaders(
|
|
1406
1744
|
workspaceId: string,
|
|
1407
1745
|
relativePath: string,
|
|
1408
|
-
): Promise<{
|
|
1746
|
+
): Promise<{
|
|
1747
|
+
principals: ResolvedPrincipal[];
|
|
1748
|
+
roles: string[];
|
|
1749
|
+
users: { name: string; email: string }[];
|
|
1750
|
+
}> {
|
|
1409
1751
|
const model = await this.loadModel(workspaceId);
|
|
1410
1752
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1411
1753
|
return eligibleHoldersResolved(model, 'download', relativePath, own);
|
|
@@ -1434,6 +1776,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1434
1776
|
kind: AccessTargetKind,
|
|
1435
1777
|
relativePath: string,
|
|
1436
1778
|
principal: GrantPrincipal,
|
|
1779
|
+
opts?: { tokenMatch?: 'exact' | 'name' },
|
|
1437
1780
|
): Promise<GrantSources> {
|
|
1438
1781
|
const model = await this.loadModel(workspaceId);
|
|
1439
1782
|
// A file target consults its own frontmatter as the most-specific scope; a
|
|
@@ -1445,7 +1788,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
1445
1788
|
: null;
|
|
1446
1789
|
const out: GrantSources = {};
|
|
1447
1790
|
for (const verb of KNOWN_VERBS) {
|
|
1448
|
-
const sources = resolveGrantSourcesForVerb(
|
|
1791
|
+
const sources = resolveGrantSourcesForVerb(
|
|
1792
|
+
model,
|
|
1793
|
+
verb,
|
|
1794
|
+
kind,
|
|
1795
|
+
relativePath,
|
|
1796
|
+
principal,
|
|
1797
|
+
own,
|
|
1798
|
+
opts?.tokenMatch,
|
|
1799
|
+
);
|
|
1449
1800
|
if (sources.length > 0) out[verb] = sources;
|
|
1450
1801
|
}
|
|
1451
1802
|
return out;
|
|
@@ -1453,21 +1804,27 @@ export class AccessControlService implements IAccessControl {
|
|
|
1453
1804
|
|
|
1454
1805
|
async kbPrincipals(
|
|
1455
1806
|
workspaceId: string,
|
|
1456
|
-
): Promise<{
|
|
1807
|
+
): Promise<{ roles: string[]; groups: string[]; people: { name: string; email: string }[] }> {
|
|
1457
1808
|
let model: AccessModel;
|
|
1458
1809
|
try {
|
|
1459
1810
|
model = await this.loadModel(workspaceId);
|
|
1460
1811
|
} catch {
|
|
1461
|
-
return {
|
|
1812
|
+
return { roles: [], groups: [], people: [] };
|
|
1813
|
+
}
|
|
1814
|
+
// Roles = the built-in `everyone` role plus every declared role's display
|
|
1815
|
+
// name — ROLE principals only, never groups. Each role is enumerated via
|
|
1816
|
+
// its `role/<canonical>` alias key, which exists exactly once per role
|
|
1817
|
+
// (the bare key may be owned by a same-named group under group-first
|
|
1818
|
+
// precedence, and merged group entries must not appear here). `everyone`
|
|
1819
|
+
// is surfaced so the share UI can grant public read; the grant route
|
|
1820
|
+
// gates it to the `read` verb only (write/owner/download everyone stay a
|
|
1821
|
+
// direct-access.md edit).
|
|
1822
|
+
const roles = [EVERYONE_DISPLAY];
|
|
1823
|
+
const groups: string[] = [];
|
|
1824
|
+
for (const [key, principal] of model.roles.byCanonical) {
|
|
1825
|
+
if (key.startsWith(ROLE_TOKEN_PREFIX)) roles.push(principal.displayName);
|
|
1826
|
+
else if (principal.kind === 'group') groups.push(principal.displayName);
|
|
1462
1827
|
}
|
|
1463
|
-
// Plugins = the built-in `everyone` role plus every declared role's display
|
|
1464
|
-
// name. `everyone` is surfaced so the share UI can grant public read; the
|
|
1465
|
-
// grant route gates it to the `read` verb only (write/owner/download
|
|
1466
|
-
// everyone stay a direct-access.md edit).
|
|
1467
|
-
const plugins = [
|
|
1468
|
-
EVERYONE_DISPLAY,
|
|
1469
|
-
...[...model.roles.byCanonical.values()].map((r) => r.displayName),
|
|
1470
|
-
];
|
|
1471
1828
|
// People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
|
|
1472
1829
|
// grants (named). The login-only users table is unioned in by the caller.
|
|
1473
1830
|
const byEmail = new Map<string, string>(); // email -> display name ('' if unknown)
|
|
@@ -1487,7 +1844,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1487
1844
|
name: name || email.split('@')[0],
|
|
1488
1845
|
email,
|
|
1489
1846
|
}));
|
|
1490
|
-
return {
|
|
1847
|
+
return { roles, groups, people };
|
|
1491
1848
|
}
|
|
1492
1849
|
|
|
1493
1850
|
async findEmailByHash(
|
|
@@ -1530,12 +1887,28 @@ export class AccessControlService implements IAccessControl {
|
|
|
1530
1887
|
return null;
|
|
1531
1888
|
}
|
|
1532
1889
|
|
|
1890
|
+
/**
|
|
1891
|
+
* `synced-groups.yaml` is MACHINE-OWNED: regenerated wholesale from the
|
|
1892
|
+
* directory mirror and committed by the directory-sync bot. The bot is its
|
|
1893
|
+
* ONLY writer — role/grant resolution never applies to it. That cuts both
|
|
1894
|
+
* ways: the bot needs no role to write it (it isn't in roles.yaml, and on a
|
|
1895
|
+
* protected branch nothing else would make it eligible), and no HUMAN can
|
|
1896
|
+
* hand-edit it through the app (an edit would be silently overwritten by
|
|
1897
|
+
* the next provisioning push anyway).
|
|
1898
|
+
*/
|
|
1899
|
+
private machineOwnedWriteRule(userEmail: string, relativePath: string): boolean | null {
|
|
1900
|
+
if (relativePath !== SYNCED_GROUPS_YAML) return null;
|
|
1901
|
+
return userEmail.trim().toLowerCase() === DIRECTORY_SYNC_BOT_EMAIL;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1533
1904
|
async canWriteAtRef(
|
|
1534
1905
|
workspaceId: string,
|
|
1535
1906
|
ref: string,
|
|
1536
1907
|
userEmail: string,
|
|
1537
1908
|
relativePath: string,
|
|
1538
1909
|
): Promise<boolean | null> {
|
|
1910
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
|
|
1911
|
+
if (machineOwned !== null) return machineOwned;
|
|
1539
1912
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1540
1913
|
if (!loaded) return null;
|
|
1541
1914
|
const repoDir = await this.repoDir(workspaceId);
|
|
@@ -1562,6 +1935,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
1562
1935
|
userEmail: string,
|
|
1563
1936
|
relativePaths: string[],
|
|
1564
1937
|
): Promise<Map<string, boolean> | null> {
|
|
1938
|
+
// Machine-owned paths resolve without the model (see machineOwnedWriteRule)
|
|
1939
|
+
// — matching canWriteAtRef, including on a repo with no rules at the ref.
|
|
1940
|
+
const machineAnswers = new Map<string, boolean>();
|
|
1941
|
+
for (const p of relativePaths) {
|
|
1942
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, p);
|
|
1943
|
+
if (machineOwned !== null) machineAnswers.set(p, machineOwned);
|
|
1944
|
+
}
|
|
1945
|
+
if (machineAnswers.size === relativePaths.length) return machineAnswers;
|
|
1565
1946
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1566
1947
|
if (!loaded) return null;
|
|
1567
1948
|
const repoDir = await this.repoDir(workspaceId);
|
|
@@ -1570,7 +1951,11 @@ export class AccessControlService implements IAccessControl {
|
|
|
1570
1951
|
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1571
1952
|
const result = new Map<string, boolean>();
|
|
1572
1953
|
for (const p of relativePaths) {
|
|
1573
|
-
|
|
1954
|
+
const machineOwned = machineAnswers.get(p);
|
|
1955
|
+
result.set(
|
|
1956
|
+
p,
|
|
1957
|
+
machineOwned ?? hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null),
|
|
1958
|
+
);
|
|
1574
1959
|
}
|
|
1575
1960
|
return result;
|
|
1576
1961
|
}
|
|
@@ -1580,6 +1965,13 @@ export class AccessControlService implements IAccessControl {
|
|
|
1580
1965
|
ref: string,
|
|
1581
1966
|
relativePath: string,
|
|
1582
1967
|
): Promise<{ roles: string[]; users: { name: string; email: string }[] } | null> {
|
|
1968
|
+
if (relativePath === SYNCED_GROUPS_YAML) {
|
|
1969
|
+
// Machine-owned — see machineOwnedWriteRule.
|
|
1970
|
+
return {
|
|
1971
|
+
roles: [],
|
|
1972
|
+
users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1583
1975
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1584
1976
|
if (!loaded) return null;
|
|
1585
1977
|
const repoDir = await this.repoDir(workspaceId);
|
|
@@ -1600,9 +1992,6 @@ export class AccessControlService implements IAccessControl {
|
|
|
1600
1992
|
excludedEmails?: Set<string>;
|
|
1601
1993
|
}
|
|
1602
1994
|
> | null> {
|
|
1603
|
-
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1604
|
-
if (!loaded) return null;
|
|
1605
|
-
const repoDir = await this.repoDir(workspaceId);
|
|
1606
1995
|
const result = new Map<
|
|
1607
1996
|
string,
|
|
1608
1997
|
{
|
|
@@ -1612,9 +2001,30 @@ export class AccessControlService implements IAccessControl {
|
|
|
1612
2001
|
excludedEmails?: Set<string>;
|
|
1613
2002
|
}
|
|
1614
2003
|
>();
|
|
2004
|
+
// Machine-owned paths resolve without the model — same answer
|
|
2005
|
+
// eligibleWritersAtRef gives (including at a ref with no usable
|
|
2006
|
+
// roles.yaml), so batched consumers (CR owner-routing, approval state)
|
|
2007
|
+
// agree with the single-path surface.
|
|
2008
|
+
for (const p of relativePaths) {
|
|
2009
|
+
if (p === SYNCED_GROUPS_YAML) {
|
|
2010
|
+
result.set(p, {
|
|
2011
|
+
roles: [],
|
|
2012
|
+
users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
|
|
2013
|
+
emails: new Set([DIRECTORY_SYNC_BOT_EMAIL]),
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// Only short-circuit when there IS a machine-owned path covering the
|
|
2018
|
+
// whole request: an EMPTY request must still answer null for an
|
|
2019
|
+
// unresolvable ref, as documented.
|
|
2020
|
+
if (relativePaths.length > 0 && result.size === relativePaths.length) return result;
|
|
2021
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2022
|
+
if (!loaded) return null;
|
|
2023
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1615
2024
|
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
1616
2025
|
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1617
2026
|
for (const p of relativePaths) {
|
|
2027
|
+
if (result.has(p)) continue;
|
|
1618
2028
|
const own = owns.get(p) ?? null;
|
|
1619
2029
|
const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
|
|
1620
2030
|
const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
|
|
@@ -1646,6 +2056,35 @@ export class AccessControlService implements IAccessControl {
|
|
|
1646
2056
|
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1647
2057
|
if (!rolesParsed.ok) throw new AccessConfigError(rolesParsed.errors);
|
|
1648
2058
|
|
|
2059
|
+
// Groups — the other named-principal source. Loaded forgivingly (a broken
|
|
2060
|
+
// group file degrades to "contributes nothing"; only roles.yaml problems
|
|
2061
|
+
// may throw) and merged into the principal index, after which the resolver
|
|
2062
|
+
// below needs no group awareness at all. The reader whitelists ONLY
|
|
2063
|
+
// genuine absence (ENOENT/ENOTDIR → null, like synced-groups-committer);
|
|
2064
|
+
// any other read error propagates so loadActiveGroups records a
|
|
2065
|
+
// broken-groups marker instead of silently treating the file as missing.
|
|
2066
|
+
const activeGroups = await loadActiveGroups(async (filename) => {
|
|
2067
|
+
try {
|
|
2068
|
+
return await fs.readFile(path.join(repoDir, filename), 'utf-8');
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
2071
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
|
|
2072
|
+
throw err;
|
|
2073
|
+
}
|
|
2074
|
+
});
|
|
2075
|
+
if (!activeGroups.health.ok) {
|
|
2076
|
+
console.error(
|
|
2077
|
+
`[access] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
for (const w of activeGroups.warnings) console.warn(`[access] ${w}`);
|
|
2081
|
+
const mergeWarnings = mergeGroupsIntoRoles(
|
|
2082
|
+
rolesParsed.index,
|
|
2083
|
+
activeGroups.groups,
|
|
2084
|
+
activeGroups.sourceFile,
|
|
2085
|
+
);
|
|
2086
|
+
for (const w of mergeWarnings) console.warn(`[access] ${w}`);
|
|
2087
|
+
|
|
1649
2088
|
const accessFiles = new Map<string, AccessFile>();
|
|
1650
2089
|
|
|
1651
2090
|
// Walk the entire repo for `access.md` files. The access tree is
|
|
@@ -1684,6 +2123,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1684
2123
|
const model: AccessModel = {
|
|
1685
2124
|
roles: rolesParsed.index,
|
|
1686
2125
|
accessFilesByDir: accessFiles,
|
|
2126
|
+
groupsHealth: activeGroups.health,
|
|
1687
2127
|
deploymentOwners: this.deploymentOwners,
|
|
1688
2128
|
};
|
|
1689
2129
|
this.cache.set(workspaceId, { model, loadedAt: Date.now() });
|
|
@@ -1901,6 +2341,28 @@ export class AccessControlService implements IAccessControl {
|
|
|
1901
2341
|
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1902
2342
|
if (!rolesParsed.ok) return null;
|
|
1903
2343
|
|
|
2344
|
+
// Same group loading as the working-tree model, read AT THE REF — the
|
|
2345
|
+
// whole point of file-materialized groups is that the merge/push gates
|
|
2346
|
+
// can evaluate them at the commit they gate. (`showAtRef` folds every git
|
|
2347
|
+
// failure into null/absent — at-ref reads cannot distinguish a missing
|
|
2348
|
+
// path from a repo error, so the broken-groups marker here fires only on
|
|
2349
|
+
// parse failures.)
|
|
2350
|
+
const activeGroups = await loadActiveGroups((filename) =>
|
|
2351
|
+
this.showAtRef(repoDir, resolvedRef, filename),
|
|
2352
|
+
);
|
|
2353
|
+
if (!activeGroups.health.ok) {
|
|
2354
|
+
console.error(
|
|
2355
|
+
`[access@${resolvedRef}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
for (const w of activeGroups.warnings) console.warn(`[access@${resolvedRef}] ${w}`);
|
|
2359
|
+
const mergeWarnings = mergeGroupsIntoRoles(
|
|
2360
|
+
rolesParsed.index,
|
|
2361
|
+
activeGroups.groups,
|
|
2362
|
+
activeGroups.sourceFile,
|
|
2363
|
+
);
|
|
2364
|
+
for (const w of mergeWarnings) console.warn(`[access@${resolvedRef}] ${w}`);
|
|
2365
|
+
|
|
1904
2366
|
const accessFiles = new Map<string, AccessFile>();
|
|
1905
2367
|
const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
|
|
1906
2368
|
for (const p of accessPaths) {
|
|
@@ -1928,6 +2390,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1928
2390
|
model: {
|
|
1929
2391
|
roles: rolesParsed.index,
|
|
1930
2392
|
accessFilesByDir: accessFiles,
|
|
2393
|
+
groupsHealth: activeGroups.health,
|
|
1931
2394
|
// Same owners as the working-tree model. The at-ref model backs the
|
|
1932
2395
|
// PUSH gate, so omitting them here would let the deployment owner save
|
|
1933
2396
|
// a roles.yaml repair locally and then be refused when it tries to
|