@bevel-software/platform-core-backend 0.9.1 → 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/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.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/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- 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 +173 -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/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.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 +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- 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/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- 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 +182 -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
- package/src/version.ts +28 -0
|
@@ -4,6 +4,11 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { AccessConfigError } from './access-errors.js';
|
|
7
|
+
// NOTE: deliberate module cycle — group-files.ts imports this module's parsing
|
|
8
|
+
// primitives. Benign: both sides only dereference the other's exports inside
|
|
9
|
+
// function bodies, never at module-evaluation time.
|
|
10
|
+
import { GROUPS_YAML, SYNCED_GROUPS_YAML, parseGroupsFile, } from './group-files.js';
|
|
11
|
+
import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-sync-bot.js';
|
|
7
12
|
const execFileAsync = promisify(execFile);
|
|
8
13
|
/**
|
|
9
14
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
@@ -119,6 +124,24 @@ export function sourceVerbsFor(verb) {
|
|
|
119
124
|
}
|
|
120
125
|
export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
|
|
121
126
|
export const DENY_PREFIX = 'deny ';
|
|
127
|
+
/**
|
|
128
|
+
* Member-entry prefix in roles.yaml that references a GROUP instead of an
|
|
129
|
+
* email: `- group:Engineering`. Explicit on purpose — membership kind is
|
|
130
|
+
* never guessed from string shape. Valid on EVERY role including Admin, with
|
|
131
|
+
* one kept invariant (see `parseRolesYaml`): Admin must always retain at
|
|
132
|
+
* least one direct email member, so a misconfigured or unreachable directory
|
|
133
|
+
* can never leave the deployment without a rescuable admin.
|
|
134
|
+
*/
|
|
135
|
+
export const GROUP_REF_PREFIX = 'group:';
|
|
136
|
+
/**
|
|
137
|
+
* Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
|
|
138
|
+
* the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
|
|
139
|
+
* and falls back to the role — so `role/` is the escape hatch when a group
|
|
140
|
+
* shares the role's name. The prefix is reserved in the group name-safety
|
|
141
|
+
* rules (a group may never be named `role/...`), and every role is also
|
|
142
|
+
* registered in the principal index under its `role/<canonical>` alias.
|
|
143
|
+
*/
|
|
144
|
+
export const ROLE_TOKEN_PREFIX = 'role/';
|
|
122
145
|
export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
|
|
123
146
|
export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
|
|
124
147
|
function emptyEntries() {
|
|
@@ -130,13 +153,33 @@ function emptyEntries() {
|
|
|
130
153
|
export function isAccessMdPath(p) {
|
|
131
154
|
return p === 'access.md' || p.endsWith('/access.md');
|
|
132
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Extensions of node files whose OWN `---` frontmatter can carry access verbs
|
|
158
|
+
* the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
|
|
159
|
+
* SINGLE source of truth for every surface that enumerates candidate files —
|
|
160
|
+
* the access-declarations scan and the shared `KbReferenceScanner` (which
|
|
161
|
+
* must scan/rewrite the same set, or a rename strands a live `.tool`
|
|
162
|
+
* frontmatter grant). `access.md` is covered by `.md`.
|
|
163
|
+
*/
|
|
164
|
+
export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'];
|
|
165
|
+
/** True when `p` is a file the resolver reads access frontmatter from. */
|
|
166
|
+
export function hasAccessFrontmatterExtension(p) {
|
|
167
|
+
return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
|
|
168
|
+
}
|
|
133
169
|
function isBuiltInRole(canonicalRole) {
|
|
134
170
|
return canonicalRole === EVERYONE_CANONICAL;
|
|
135
171
|
}
|
|
136
172
|
function roleKnown(roles, canonicalRole) {
|
|
137
173
|
return roles.byCanonical.has(canonicalRole) || isBuiltInRole(canonicalRole);
|
|
138
174
|
}
|
|
139
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
|
|
177
|
+
* line: a `#` at the start (after only whitespace) or preceded by whitespace
|
|
178
|
+
* begins a comment. Exported so the reference scanner matches tokens against
|
|
179
|
+
* the SAME comment rule the resolver parses with (a `- GTM Team # sales`
|
|
180
|
+
* entry is the token `GTM Team`, never `GTM Team # sales`).
|
|
181
|
+
*/
|
|
182
|
+
export function stripComment(line) {
|
|
140
183
|
let inWs = true;
|
|
141
184
|
for (let i = 0; i < line.length; i++) {
|
|
142
185
|
const ch = line[i];
|
|
@@ -148,9 +191,14 @@ function stripComment(line) {
|
|
|
148
191
|
}
|
|
149
192
|
return line;
|
|
150
193
|
}
|
|
151
|
-
function tokenise(text) {
|
|
194
|
+
function tokenise(text, opts) {
|
|
152
195
|
const lines = text.split(/\r?\n/);
|
|
153
196
|
const tokens = [];
|
|
197
|
+
// Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
|
|
198
|
+
// key doesn't trip the duplicate-key check (which would retire every valid
|
|
199
|
+
// group after it). All-whitespace keys still canonicalize to '' downstream,
|
|
200
|
+
// so the entry-level "empty group name — skipped" handling sees them all.
|
|
201
|
+
let blankKeySeq = 0;
|
|
154
202
|
for (let i = 0; i < lines.length; i++) {
|
|
155
203
|
const stripped = stripComment(lines[i]).replace(/\s+$/, '');
|
|
156
204
|
if (!stripped.trim())
|
|
@@ -168,16 +216,24 @@ function tokenise(text) {
|
|
|
168
216
|
if (colonIdx < 0) {
|
|
169
217
|
return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
|
|
170
218
|
}
|
|
171
|
-
|
|
219
|
+
let key = content.slice(0, colonIdx).trim();
|
|
172
220
|
const valuePart = content.slice(colonIdx + 1).trim();
|
|
173
|
-
|
|
174
|
-
|
|
221
|
+
// An empty key is normally a hard error (roles.yaml/access.md want loud
|
|
222
|
+
// failures), but a parser with ENTRY-level forgiveness (the group files:
|
|
223
|
+
// one bad entry must not retire every other group) keeps it as a
|
|
224
|
+
// whitespace key for its own skip-with-warning handling.
|
|
225
|
+
if (!key) {
|
|
226
|
+
if (!opts?.tolerateEmptyKeys) {
|
|
227
|
+
return { ok: false, error: `line ${lineNum}: empty mapping key` };
|
|
228
|
+
}
|
|
229
|
+
key = ' '.repeat(++blankKeySeq);
|
|
230
|
+
}
|
|
175
231
|
tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
|
|
176
232
|
}
|
|
177
233
|
return { ok: true, tokens };
|
|
178
234
|
}
|
|
179
|
-
export function parseYamlSubset(text) {
|
|
180
|
-
const tok = tokenise(text);
|
|
235
|
+
export function parseYamlSubset(text, opts) {
|
|
236
|
+
const tok = tokenise(text, opts);
|
|
181
237
|
if (!tok.ok)
|
|
182
238
|
return tok;
|
|
183
239
|
if (tok.tokens.length === 0)
|
|
@@ -309,9 +365,18 @@ export function parseAccessEntry(raw) {
|
|
|
309
365
|
error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
|
|
310
366
|
};
|
|
311
367
|
}
|
|
312
|
-
|
|
368
|
+
let role = canonicalRoleName(body);
|
|
313
369
|
if (!role)
|
|
314
370
|
return { ok: false, error: `empty role name in entry '${raw}'` };
|
|
371
|
+
// Explicit role token: normalize `role/ <Name>` spacing so the canonical
|
|
372
|
+
// form is always `role/<canonicalName>` — the exact alias key the principal
|
|
373
|
+
// index registers for every role.
|
|
374
|
+
if (role.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
375
|
+
const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
|
|
376
|
+
if (!suffix)
|
|
377
|
+
return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
|
|
378
|
+
role = `${ROLE_TOKEN_PREFIX}${suffix}`;
|
|
379
|
+
}
|
|
315
380
|
return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
|
|
316
381
|
}
|
|
317
382
|
// ---------------------------------------------------------------------------
|
|
@@ -344,6 +409,10 @@ export function parseRolesYaml(text) {
|
|
|
344
409
|
errors.push(`roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`);
|
|
345
410
|
continue;
|
|
346
411
|
}
|
|
412
|
+
if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
413
|
+
errors.push(`roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
347
416
|
if (index.byCanonical.has(canonical)) {
|
|
348
417
|
const prev = index.byCanonical.get(canonical).displayName;
|
|
349
418
|
errors.push(`roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`);
|
|
@@ -354,11 +423,26 @@ export function parseRolesYaml(text) {
|
|
|
354
423
|
continue;
|
|
355
424
|
}
|
|
356
425
|
const emails = new Set();
|
|
426
|
+
const groupRefs = new Set();
|
|
357
427
|
for (const rawEmail of value) {
|
|
358
428
|
if (typeof rawEmail !== 'string') {
|
|
359
429
|
errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
|
|
360
430
|
continue;
|
|
361
431
|
}
|
|
432
|
+
// `- group:<Name>` assigns the role to a whole group (expanded against
|
|
433
|
+
// the active group source by `mergeGroupsIntoRoles`). Allowed on every
|
|
434
|
+
// role, Admin included — the Admin invariant below only demands at
|
|
435
|
+
// least one DIRECT email member so a broken directory can never leave
|
|
436
|
+
// the deployment adminless.
|
|
437
|
+
if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
|
|
438
|
+
const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
|
|
439
|
+
if (!refName) {
|
|
440
|
+
errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
groupRefs.add(refName);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
362
446
|
const email = canonicalEmail(rawEmail);
|
|
363
447
|
if (!EMAIL_REGEX.test(email)) {
|
|
364
448
|
errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
|
|
@@ -372,18 +456,158 @@ export function parseRolesYaml(text) {
|
|
|
372
456
|
}
|
|
373
457
|
set.add(canonical);
|
|
374
458
|
}
|
|
375
|
-
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
|
|
459
|
+
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
|
|
376
460
|
}
|
|
377
461
|
if (!index.byCanonical.has(ADMIN_CANONICAL)) {
|
|
378
462
|
errors.push(`roles.yaml: must declare at least one 'Admin' role`);
|
|
379
463
|
}
|
|
380
464
|
else if (index.byCanonical.get(ADMIN_CANONICAL).emails.size === 0) {
|
|
381
|
-
|
|
465
|
+
// The kept invariant: Admin may reference groups, but must ALWAYS retain
|
|
466
|
+
// at least one direct email member — the rescue story requires an admin
|
|
467
|
+
// whose membership does not depend on a reachable, well-configured
|
|
468
|
+
// directory. A group-only Admin is as hard an error as an adminless one.
|
|
469
|
+
errors.push(`roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`);
|
|
382
470
|
}
|
|
383
471
|
if (errors.length)
|
|
384
472
|
return { ok: false, errors };
|
|
385
473
|
return { ok: true, index };
|
|
386
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Merge the active group source into the principal index and expand role →
|
|
477
|
+
* group assignments. Mutates `index` in place; returns human-readable
|
|
478
|
+
* warnings (callers log them — nothing here ever throws, because group
|
|
479
|
+
* problems must degrade, not brick access resolution).
|
|
480
|
+
*
|
|
481
|
+
* Rules (the grant-grammar precedence):
|
|
482
|
+
* - Every role is ALSO registered under its explicit `role/<canonical>`
|
|
483
|
+
* alias — the token that always resolves to the role.
|
|
484
|
+
* - A BARE name resolves GROUP-FIRST: when a group's canonical name
|
|
485
|
+
* collides with a role's, the bare key resolves to the GROUP (warned);
|
|
486
|
+
* the role stays reachable via `role/<canonical>`.
|
|
487
|
+
* - Role `group:<Name>` refs — Admin's included — expand against the
|
|
488
|
+
* merged groups; an unknown ref contributes nothing (warned).
|
|
489
|
+
* - `byEmail` is rebuilt so each member holds exactly the tokens that
|
|
490
|
+
* resolve to a principal they belong to (bare + `role/` alias for roles,
|
|
491
|
+
* bare for groups).
|
|
492
|
+
*/
|
|
493
|
+
export function mergeGroupsIntoRoles(index, groups, sourceFile) {
|
|
494
|
+
const warnings = [];
|
|
495
|
+
// Snapshot before any group lands: at this point the index holds roles only.
|
|
496
|
+
const roleRecords = new Map(index.byCanonical);
|
|
497
|
+
// 1. Expand role → group assignments (Admin included — its safety net is
|
|
498
|
+
// the parse-time "at least one direct email" invariant, not a merge skip).
|
|
499
|
+
for (const [, principal] of roleRecords) {
|
|
500
|
+
if (!principal.groupRefs?.size)
|
|
501
|
+
continue;
|
|
502
|
+
for (const ref of principal.groupRefs) {
|
|
503
|
+
const group = groups.get(ref);
|
|
504
|
+
if (!group) {
|
|
505
|
+
warnings.push(`roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (const email of group.emails)
|
|
509
|
+
principal.emails.add(email);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// 2. Bare-name precedence: groups win the bare token; the role keeps its
|
|
513
|
+
// `role/<canonical>` alias registered below.
|
|
514
|
+
for (const [canonical, def] of groups) {
|
|
515
|
+
if (roleRecords.has(canonical)) {
|
|
516
|
+
warnings.push(`${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`);
|
|
517
|
+
}
|
|
518
|
+
index.byCanonical.set(canonical, {
|
|
519
|
+
displayName: def.displayName,
|
|
520
|
+
emails: new Set(def.emails),
|
|
521
|
+
kind: 'group',
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
// 3. Explicit `role/<canonical>` alias for every role (same record — the
|
|
525
|
+
// alias and the bare key, when the role still owns it, stay in lockstep).
|
|
526
|
+
for (const [canonical, principal] of roleRecords) {
|
|
527
|
+
principal.kind = 'role';
|
|
528
|
+
index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
|
|
529
|
+
}
|
|
530
|
+
// 4. Rebuild the email → tokens map from the final index so membership
|
|
531
|
+
// reflects the post-precedence keys (a collided role's members no longer
|
|
532
|
+
// hold the bare token unless the group also contains them).
|
|
533
|
+
index.byEmail.clear();
|
|
534
|
+
for (const [key, principal] of index.byCanonical) {
|
|
535
|
+
for (const email of principal.emails) {
|
|
536
|
+
let set = index.byEmail.get(email);
|
|
537
|
+
if (!set) {
|
|
538
|
+
set = new Set();
|
|
539
|
+
index.byEmail.set(email, set);
|
|
540
|
+
}
|
|
541
|
+
set.add(key);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return warnings;
|
|
545
|
+
}
|
|
546
|
+
/** True for the errno codes that mean "the file genuinely is not there". */
|
|
547
|
+
function isAbsenceError(err) {
|
|
548
|
+
const code = err?.code;
|
|
549
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Load the ACTIVE group source through `read` (working tree or at-ref — the
|
|
553
|
+
* caller supplies the reader, so both model loaders share one mode rule):
|
|
554
|
+
* `synced-groups.yaml` existing → IdP mode (groups.yaml ignored entirely,
|
|
555
|
+
* even when the synced file is empty or malformed — falling back would
|
|
556
|
+
* resurrect retired manual groups); otherwise `groups.yaml` → manual mode.
|
|
557
|
+
*
|
|
558
|
+
* `read` returns null for a genuinely-absent file and may THROW for any other
|
|
559
|
+
* failure. Only ENOENT/ENOTDIR count as absent (the caller may also whitelist
|
|
560
|
+
* them into null); every other read error — notably a non-absence error on
|
|
561
|
+
* `synced-groups.yaml` — is treated as a BROKEN source: no groups, no
|
|
562
|
+
* fallback to the manual file (falling back would resurrect retired groups),
|
|
563
|
+
* and an `ok: false` health marker. Structural parse failures degrade the
|
|
564
|
+
* same way; this function never throws.
|
|
565
|
+
*/
|
|
566
|
+
export async function loadActiveGroups(read) {
|
|
567
|
+
const broken = (file, reason) => ({
|
|
568
|
+
groups: new Map(),
|
|
569
|
+
sourceFile: file,
|
|
570
|
+
warnings: [],
|
|
571
|
+
health: { ok: false, file, reason },
|
|
572
|
+
});
|
|
573
|
+
let syncedText;
|
|
574
|
+
try {
|
|
575
|
+
syncedText = await read(SYNCED_GROUPS_YAML);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
if (isAbsenceError(err)) {
|
|
579
|
+
syncedText = null;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// A non-absence read error on the SYNCED source must NOT fall back to
|
|
583
|
+
// groups.yaml — the synced file may exist and its manual predecessor is
|
|
584
|
+
// retired. Broken-groups instead.
|
|
585
|
+
return broken(SYNCED_GROUPS_YAML, err instanceof Error ? err.message : String(err));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const sourceFile = syncedText !== null ? SYNCED_GROUPS_YAML : GROUPS_YAML;
|
|
589
|
+
let text;
|
|
590
|
+
if (syncedText !== null) {
|
|
591
|
+
text = syncedText;
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
try {
|
|
595
|
+
text = await read(GROUPS_YAML);
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
if (isAbsenceError(err))
|
|
599
|
+
text = null;
|
|
600
|
+
else
|
|
601
|
+
return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (text === null)
|
|
605
|
+
return { groups: new Map(), sourceFile, warnings: [], health: { ok: true } };
|
|
606
|
+
const parsed = parseGroupsFile(text, sourceFile);
|
|
607
|
+
if (!parsed.ok)
|
|
608
|
+
return broken(sourceFile, parsed.errors.join('; '));
|
|
609
|
+
return { groups: parsed.groups, sourceFile, warnings: parsed.warnings, health: { ok: true } };
|
|
610
|
+
}
|
|
387
611
|
/**
|
|
388
612
|
* Does an `access.md` body declare access rules — i.e. is the file in the NEW
|
|
389
613
|
* (body-governs-the-folder) format?
|
|
@@ -630,7 +854,10 @@ function isAdminEmail(model, email) {
|
|
|
630
854
|
if (model.deploymentOwners.has(email))
|
|
631
855
|
return true;
|
|
632
856
|
const roles = model.roles.byEmail.get(email);
|
|
633
|
-
|
|
857
|
+
// Check the explicit `role/admin` alias, NOT the bare token: bare-name
|
|
858
|
+
// precedence is group-first, so a group that happens to be named "Admin"
|
|
859
|
+
// owns the bare key — and its members must never inherit the capability.
|
|
860
|
+
return !!roles && roles.has(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
|
|
634
861
|
}
|
|
635
862
|
/**
|
|
636
863
|
* Resolve whether `userEmail` has `verb` on `relativePath`.
|
|
@@ -737,7 +964,7 @@ function scopeToGrantSource(scope, kind, relativePath) {
|
|
|
737
964
|
* returning the WHOLE list of removable entries rather than just the winner.
|
|
738
965
|
*
|
|
739
966
|
* Only the principal's OWN named entry yields a source — a `user` by their email,
|
|
740
|
-
* a `role` by its role token. A grant that reaches the user via a
|
|
967
|
+
* a `role` by its role token. A grant that reaches the user via a group/role they
|
|
741
968
|
* belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
|
|
742
969
|
* never adds a source (the group/role shows as its own row instead). The list is:
|
|
743
970
|
* - `[]` (verb omitted by the caller) when the principal effectively holds no
|
|
@@ -753,19 +980,48 @@ function scopeToGrantSource(scope, kind, relativePath) {
|
|
|
753
980
|
* under closest-wins), and the revoke flow needs the inherited remainder that
|
|
754
981
|
* survives removing the direct entry. A group/everyone grant at some scope does
|
|
755
982
|
* not add a source AND does not hide a farther own-entry the principal is named
|
|
756
|
-
* in (removing it is still meaningful if the
|
|
983
|
+
* in (removing it is still meaningful if the group/role grant is later removed).
|
|
757
984
|
*/
|
|
758
|
-
function resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, fileOwn) {
|
|
985
|
+
function resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, fileOwn, tokenMatch) {
|
|
759
986
|
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
760
987
|
const out = [];
|
|
761
988
|
if (principal.kind === 'role') {
|
|
762
|
-
|
|
989
|
+
// A named principal can be spelled two ways in a file: the bare token and
|
|
990
|
+
// the explicit `role/<name>` token. WHICH spellings are THIS principal's
|
|
991
|
+
// entries depends on who owns the bare key in the merged index:
|
|
992
|
+
//
|
|
993
|
+
// - UNSHADOWED (no group named `<bare>`): both spellings resolve to the
|
|
994
|
+
// role, so both count — a grant under either adds a source; a deny
|
|
995
|
+
// under either cuts off farther grants. (The revoke splice strips both
|
|
996
|
+
// spellings in this case too, so classification and removal agree.)
|
|
997
|
+
// - SHADOWED (a group owns the bare key): the spellings are DIFFERENT
|
|
998
|
+
// principals. A bare-token principal is the GROUP — only bare entries
|
|
999
|
+
// are its own; a `role/`-token principal is the ROLE — only `role/`
|
|
1000
|
+
// entries are its own. Counting the other spelling would attribute a
|
|
1001
|
+
// shadowed bare token to the role (hiding a real `role/<name>`
|
|
1002
|
+
// ancestor grant and making its revoke a false no-op), or vice versa.
|
|
1003
|
+
//
|
|
1004
|
+
// - PINNED EXACT (`tokenMatch: 'exact'`): only the literally-spelled
|
|
1005
|
+
// token is this principal's entry, shadowing notwithstanding. The
|
|
1006
|
+
// caller pinned the same identity into the splice it is checking —
|
|
1007
|
+
// a GROUP whose group has VANISHED reads "unshadowed" here, and the
|
|
1008
|
+
// alias-tolerant pair would then attribute a same-named role's
|
|
1009
|
+
// surviving `role/<name>` grant to the group.
|
|
1010
|
+
const canonical = canonicalRoleName(principal.role);
|
|
1011
|
+
const explicit = canonical.startsWith(ROLE_TOKEN_PREFIX);
|
|
1012
|
+
const bare = explicit ? canonical.slice(ROLE_TOKEN_PREFIX.length) : canonical;
|
|
1013
|
+
const shadowed = model.roles.byCanonical.get(bare)?.kind === 'group';
|
|
1014
|
+
const tokens = tokenMatch === 'exact' || shadowed
|
|
1015
|
+
? [explicit ? `${ROLE_TOKEN_PREFIX}${bare}` : bare]
|
|
1016
|
+
: [bare, `${ROLE_TOKEN_PREFIX}${bare}`];
|
|
763
1017
|
for (const scope of scopes) {
|
|
764
|
-
const
|
|
765
|
-
if (
|
|
766
|
-
break; // a closer deny of this role cuts off farther grants
|
|
767
|
-
if (s === 'grant')
|
|
1018
|
+
const states = tokens.map((t) => scope.byRole.get(t));
|
|
1019
|
+
if (states.includes('grant')) {
|
|
768
1020
|
out.push(scopeToGrantSource(scope, kind, relativePath));
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (states.includes('denied'))
|
|
1024
|
+
break; // a closer deny cuts off farther grants
|
|
769
1025
|
}
|
|
770
1026
|
return out;
|
|
771
1027
|
}
|
|
@@ -854,12 +1110,28 @@ function canReadResolved(model, userEmail, relativePath, fileOwn) {
|
|
|
854
1110
|
}
|
|
855
1111
|
function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
856
1112
|
const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
|
|
857
|
-
|
|
1113
|
+
// (kind, display name) → one entry. The `byRole` keys are the merged
|
|
1114
|
+
// index's canonical tokens exactly as granted (bare, or the
|
|
1115
|
+
// `role/<canonical>` alias); the `byCanonical` record a key hits carries
|
|
1116
|
+
// its kind — a `role/` alias always hits the role record, a bare key hits
|
|
1117
|
+
// whichever principal owns it under group-first precedence. A token with no
|
|
1118
|
+
// record (the built-in `everyone`, or a grant naming a since-vanished
|
|
1119
|
+
// principal) degrades to 'role', the pre-groups display. When one display
|
|
1120
|
+
// name is granted as BOTH (a role via its `role/` alias plus a same-named
|
|
1121
|
+
// group via the bare token) BOTH entries survive — they are DIFFERENT
|
|
1122
|
+
// principals, and collapsing them to one would hide the role's live
|
|
1123
|
+
// `role/<name>` grant from every consumer of the eligible list.
|
|
1124
|
+
const byIdentity = new Map();
|
|
1125
|
+
const addPrincipal = (name, kind) => {
|
|
1126
|
+
const key = `${kind}\0${name.toLowerCase()}`;
|
|
1127
|
+
if (!byIdentity.has(key))
|
|
1128
|
+
byIdentity.set(key, { name, kind });
|
|
1129
|
+
};
|
|
858
1130
|
for (const [canonical, state] of byRole) {
|
|
859
1131
|
if (state !== 'grant')
|
|
860
1132
|
continue;
|
|
861
|
-
const
|
|
862
|
-
|
|
1133
|
+
const record = model.roles.byCanonical.get(canonical);
|
|
1134
|
+
addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
|
|
863
1135
|
}
|
|
864
1136
|
// Mirror the admin overrides applied in `hasPermissionResolved`: write on
|
|
865
1137
|
// `roles.yaml` and on any `access.md` is granted to Admin even if the
|
|
@@ -868,10 +1140,17 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
|
868
1140
|
// access.md (it'd say "restricted to no one" when Admin is the answer).
|
|
869
1141
|
if (verb === 'write' &&
|
|
870
1142
|
(relativePath === 'roles.yaml' || isAccessMdPath(relativePath))) {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1143
|
+
// Look the Admin ROLE up via its explicit alias — the bare key may be
|
|
1144
|
+
// owned by a same-named group under group-first precedence. The override
|
|
1145
|
+
// is the ROLE's capability, so the row's kind is 'role' regardless.
|
|
1146
|
+
const adminRole = model.roles.byCanonical.get(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
|
|
1147
|
+
addPrincipal(adminRole ? adminRole.displayName : ADMIN_CANONICAL, 'role');
|
|
1148
|
+
}
|
|
1149
|
+
const principals = [...byIdentity.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
|
|
1150
|
+
// Legacy name-only list, kind erased — kept for the many consumers that
|
|
1151
|
+
// only ever render names (banners, contact lines, PR-routing messages).
|
|
1152
|
+
// De-duplicated: a name granted as both group and role appears once here.
|
|
1153
|
+
const roles = [...new Set(principals.map((p) => p.name))];
|
|
875
1154
|
const users = [];
|
|
876
1155
|
for (const [email, state] of byEmail) {
|
|
877
1156
|
if (state !== 'grant')
|
|
@@ -879,7 +1158,7 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
|
879
1158
|
users.push({ name: '', email });
|
|
880
1159
|
}
|
|
881
1160
|
users.sort((a, b) => a.email.localeCompare(b.email));
|
|
882
|
-
return { roles, users };
|
|
1161
|
+
return { principals, roles, users };
|
|
883
1162
|
}
|
|
884
1163
|
/**
|
|
885
1164
|
* Expand the eligible-writer set to the underlying emails.
|
|
@@ -1017,27 +1296,10 @@ export class AccessControlService {
|
|
|
1017
1296
|
const parsed = parseRolesYaml(text);
|
|
1018
1297
|
return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
|
|
1019
1298
|
}
|
|
1020
|
-
/**
|
|
1021
|
-
* Advisory scan of folder `access.md` files for references to a role (by
|
|
1022
|
-
* canonical name). See `IAccessControl.referencesToRole` — undercounts node
|
|
1023
|
-
* frontmatter by design; powers the delete warning only, never the rename
|
|
1024
|
-
* gate.
|
|
1025
|
-
*/
|
|
1026
|
-
async referencesToRole(workspaceId, canonicalRole) {
|
|
1027
|
-
const model = await this.loadModel(workspaceId);
|
|
1028
|
-
const out = [];
|
|
1029
|
-
for (const file of model.accessFilesByDir.values()) {
|
|
1030
|
-
for (const verb of KNOWN_VERBS) {
|
|
1031
|
-
for (const entry of file.entries[verb]) {
|
|
1032
|
-
if (entry.kind === 'role' && entry.role === canonicalRole) {
|
|
1033
|
-
out.push({ path: file.path, verb });
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
return out;
|
|
1039
|
-
}
|
|
1040
1299
|
async canWrite(workspaceId, userEmail, relativePath) {
|
|
1300
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
|
|
1301
|
+
if (machineOwned !== null)
|
|
1302
|
+
return machineOwned;
|
|
1041
1303
|
const model = await this.loadModel(workspaceId);
|
|
1042
1304
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1043
1305
|
return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
|
|
@@ -1067,7 +1329,8 @@ export class AccessControlService {
|
|
|
1067
1329
|
const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
|
|
1068
1330
|
const result = new Map();
|
|
1069
1331
|
relativePaths.forEach((p, i) => {
|
|
1070
|
-
|
|
1332
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, p);
|
|
1333
|
+
result.set(p, machineOwned ?? hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
|
|
1071
1334
|
});
|
|
1072
1335
|
return result;
|
|
1073
1336
|
}
|
|
@@ -1109,10 +1372,10 @@ export class AccessControlService {
|
|
|
1109
1372
|
// and the role/user lists are meaningless. Otherwise return the explicit
|
|
1110
1373
|
// reader set; it may be empty for a default-denied path with no grants.
|
|
1111
1374
|
if (canEveryoneReadResolved(model, relativePath, own)) {
|
|
1112
|
-
return { restricted: false, roles: [], users: [] };
|
|
1375
|
+
return { restricted: false, principals: [], roles: [], users: [] };
|
|
1113
1376
|
}
|
|
1114
|
-
const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
|
|
1115
|
-
return { restricted: true, roles, users };
|
|
1377
|
+
const { principals, roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
|
|
1378
|
+
return { restricted: true, principals, roles, users };
|
|
1116
1379
|
}
|
|
1117
1380
|
async eligibleDownloaders(workspaceId, relativePath) {
|
|
1118
1381
|
const model = await this.loadModel(workspaceId);
|
|
@@ -1129,7 +1392,7 @@ export class AccessControlService {
|
|
|
1129
1392
|
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1130
1393
|
return eligibleHolderEmailsResolved(model, 'owner', relativePath, own);
|
|
1131
1394
|
}
|
|
1132
|
-
async grantSources(workspaceId, kind, relativePath, principal) {
|
|
1395
|
+
async grantSources(workspaceId, kind, relativePath, principal, opts) {
|
|
1133
1396
|
const model = await this.loadModel(workspaceId);
|
|
1134
1397
|
// A file target consults its own frontmatter as the most-specific scope; a
|
|
1135
1398
|
// folder target's most-specific scope is its own access.md (in the dir
|
|
@@ -1139,7 +1402,7 @@ export class AccessControlService {
|
|
|
1139
1402
|
: null;
|
|
1140
1403
|
const out = {};
|
|
1141
1404
|
for (const verb of KNOWN_VERBS) {
|
|
1142
|
-
const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own);
|
|
1405
|
+
const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own, opts?.tokenMatch);
|
|
1143
1406
|
if (sources.length > 0)
|
|
1144
1407
|
out[verb] = sources;
|
|
1145
1408
|
}
|
|
@@ -1151,16 +1414,24 @@ export class AccessControlService {
|
|
|
1151
1414
|
model = await this.loadModel(workspaceId);
|
|
1152
1415
|
}
|
|
1153
1416
|
catch {
|
|
1154
|
-
return {
|
|
1155
|
-
}
|
|
1156
|
-
//
|
|
1157
|
-
// name
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1417
|
+
return { roles: [], groups: [], people: [] };
|
|
1418
|
+
}
|
|
1419
|
+
// Roles = the built-in `everyone` role plus every declared role's display
|
|
1420
|
+
// name — ROLE principals only, never groups. Each role is enumerated via
|
|
1421
|
+
// its `role/<canonical>` alias key, which exists exactly once per role
|
|
1422
|
+
// (the bare key may be owned by a same-named group under group-first
|
|
1423
|
+
// precedence, and merged group entries must not appear here). `everyone`
|
|
1424
|
+
// is surfaced so the share UI can grant public read; the grant route
|
|
1425
|
+
// gates it to the `read` verb only (write/owner/download everyone stay a
|
|
1426
|
+
// direct-access.md edit).
|
|
1427
|
+
const roles = [EVERYONE_DISPLAY];
|
|
1428
|
+
const groups = [];
|
|
1429
|
+
for (const [key, principal] of model.roles.byCanonical) {
|
|
1430
|
+
if (key.startsWith(ROLE_TOKEN_PREFIX))
|
|
1431
|
+
roles.push(principal.displayName);
|
|
1432
|
+
else if (principal.kind === 'group')
|
|
1433
|
+
groups.push(principal.displayName);
|
|
1434
|
+
}
|
|
1164
1435
|
// People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
|
|
1165
1436
|
// grants (named). The login-only users table is unioned in by the caller.
|
|
1166
1437
|
const byEmail = new Map(); // email -> display name ('' if unknown)
|
|
@@ -1181,7 +1452,7 @@ export class AccessControlService {
|
|
|
1181
1452
|
name: name || email.split('@')[0],
|
|
1182
1453
|
email,
|
|
1183
1454
|
}));
|
|
1184
|
-
return {
|
|
1455
|
+
return { roles, groups, people };
|
|
1185
1456
|
}
|
|
1186
1457
|
async findEmailByHash(workspaceId, hash) {
|
|
1187
1458
|
let model;
|
|
@@ -1218,7 +1489,24 @@ export class AccessControlService {
|
|
|
1218
1489
|
}
|
|
1219
1490
|
return null;
|
|
1220
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* `synced-groups.yaml` is MACHINE-OWNED: regenerated wholesale from the
|
|
1494
|
+
* directory mirror and committed by the directory-sync bot. The bot is its
|
|
1495
|
+
* ONLY writer — role/grant resolution never applies to it. That cuts both
|
|
1496
|
+
* ways: the bot needs no role to write it (it isn't in roles.yaml, and on a
|
|
1497
|
+
* protected branch nothing else would make it eligible), and no HUMAN can
|
|
1498
|
+
* hand-edit it through the app (an edit would be silently overwritten by
|
|
1499
|
+
* the next provisioning push anyway).
|
|
1500
|
+
*/
|
|
1501
|
+
machineOwnedWriteRule(userEmail, relativePath) {
|
|
1502
|
+
if (relativePath !== SYNCED_GROUPS_YAML)
|
|
1503
|
+
return null;
|
|
1504
|
+
return userEmail.trim().toLowerCase() === DIRECTORY_SYNC_BOT_EMAIL;
|
|
1505
|
+
}
|
|
1221
1506
|
async canWriteAtRef(workspaceId, ref, userEmail, relativePath) {
|
|
1507
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
|
|
1508
|
+
if (machineOwned !== null)
|
|
1509
|
+
return machineOwned;
|
|
1222
1510
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1223
1511
|
if (!loaded)
|
|
1224
1512
|
return null;
|
|
@@ -1235,6 +1523,16 @@ export class AccessControlService {
|
|
|
1235
1523
|
return canReadResolved(loaded.model, userEmail, relativePath, own);
|
|
1236
1524
|
}
|
|
1237
1525
|
async canWriteBatchAtRef(workspaceId, ref, userEmail, relativePaths) {
|
|
1526
|
+
// Machine-owned paths resolve without the model (see machineOwnedWriteRule)
|
|
1527
|
+
// — matching canWriteAtRef, including on a repo with no rules at the ref.
|
|
1528
|
+
const machineAnswers = new Map();
|
|
1529
|
+
for (const p of relativePaths) {
|
|
1530
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, p);
|
|
1531
|
+
if (machineOwned !== null)
|
|
1532
|
+
machineAnswers.set(p, machineOwned);
|
|
1533
|
+
}
|
|
1534
|
+
if (machineAnswers.size === relativePaths.length)
|
|
1535
|
+
return machineAnswers;
|
|
1238
1536
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1239
1537
|
if (!loaded)
|
|
1240
1538
|
return null;
|
|
@@ -1244,11 +1542,19 @@ export class AccessControlService {
|
|
|
1244
1542
|
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1245
1543
|
const result = new Map();
|
|
1246
1544
|
for (const p of relativePaths) {
|
|
1247
|
-
|
|
1545
|
+
const machineOwned = machineAnswers.get(p);
|
|
1546
|
+
result.set(p, machineOwned ?? hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
|
|
1248
1547
|
}
|
|
1249
1548
|
return result;
|
|
1250
1549
|
}
|
|
1251
1550
|
async eligibleWritersAtRef(workspaceId, ref, relativePath) {
|
|
1551
|
+
if (relativePath === SYNCED_GROUPS_YAML) {
|
|
1552
|
+
// Machine-owned — see machineOwnedWriteRule.
|
|
1553
|
+
return {
|
|
1554
|
+
roles: [],
|
|
1555
|
+
users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1252
1558
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1253
1559
|
if (!loaded)
|
|
1254
1560
|
return null;
|
|
@@ -1257,14 +1563,34 @@ export class AccessControlService {
|
|
|
1257
1563
|
return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
|
|
1258
1564
|
}
|
|
1259
1565
|
async eligibleWritersForPathsAtRef(workspaceId, ref, relativePaths) {
|
|
1566
|
+
const result = new Map();
|
|
1567
|
+
// Machine-owned paths resolve without the model — same answer
|
|
1568
|
+
// eligibleWritersAtRef gives (including at a ref with no usable
|
|
1569
|
+
// roles.yaml), so batched consumers (CR owner-routing, approval state)
|
|
1570
|
+
// agree with the single-path surface.
|
|
1571
|
+
for (const p of relativePaths) {
|
|
1572
|
+
if (p === SYNCED_GROUPS_YAML) {
|
|
1573
|
+
result.set(p, {
|
|
1574
|
+
roles: [],
|
|
1575
|
+
users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
|
|
1576
|
+
emails: new Set([DIRECTORY_SYNC_BOT_EMAIL]),
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// Only short-circuit when there IS a machine-owned path covering the
|
|
1581
|
+
// whole request: an EMPTY request must still answer null for an
|
|
1582
|
+
// unresolvable ref, as documented.
|
|
1583
|
+
if (relativePaths.length > 0 && result.size === relativePaths.length)
|
|
1584
|
+
return result;
|
|
1260
1585
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1261
1586
|
if (!loaded)
|
|
1262
1587
|
return null;
|
|
1263
1588
|
const repoDir = await this.repoDir(workspaceId);
|
|
1264
|
-
const result = new Map();
|
|
1265
1589
|
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
1266
1590
|
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1267
1591
|
for (const p of relativePaths) {
|
|
1592
|
+
if (result.has(p))
|
|
1593
|
+
continue;
|
|
1268
1594
|
const own = owns.get(p) ?? null;
|
|
1269
1595
|
const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
|
|
1270
1596
|
const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
|
|
@@ -1292,6 +1618,32 @@ export class AccessControlService {
|
|
|
1292
1618
|
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1293
1619
|
if (!rolesParsed.ok)
|
|
1294
1620
|
throw new AccessConfigError(rolesParsed.errors);
|
|
1621
|
+
// Groups — the other named-principal source. Loaded forgivingly (a broken
|
|
1622
|
+
// group file degrades to "contributes nothing"; only roles.yaml problems
|
|
1623
|
+
// may throw) and merged into the principal index, after which the resolver
|
|
1624
|
+
// below needs no group awareness at all. The reader whitelists ONLY
|
|
1625
|
+
// genuine absence (ENOENT/ENOTDIR → null, like synced-groups-committer);
|
|
1626
|
+
// any other read error propagates so loadActiveGroups records a
|
|
1627
|
+
// broken-groups marker instead of silently treating the file as missing.
|
|
1628
|
+
const activeGroups = await loadActiveGroups(async (filename) => {
|
|
1629
|
+
try {
|
|
1630
|
+
return await fs.readFile(path.join(repoDir, filename), 'utf-8');
|
|
1631
|
+
}
|
|
1632
|
+
catch (err) {
|
|
1633
|
+
const code = err?.code;
|
|
1634
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
1635
|
+
return null;
|
|
1636
|
+
throw err;
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
if (!activeGroups.health.ok) {
|
|
1640
|
+
console.error(`[access] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`);
|
|
1641
|
+
}
|
|
1642
|
+
for (const w of activeGroups.warnings)
|
|
1643
|
+
console.warn(`[access] ${w}`);
|
|
1644
|
+
const mergeWarnings = mergeGroupsIntoRoles(rolesParsed.index, activeGroups.groups, activeGroups.sourceFile);
|
|
1645
|
+
for (const w of mergeWarnings)
|
|
1646
|
+
console.warn(`[access] ${w}`);
|
|
1295
1647
|
const accessFiles = new Map();
|
|
1296
1648
|
// Walk the entire repo for `access.md` files. The access tree is
|
|
1297
1649
|
// structure-agnostic — any `access.md` at any depth (including repo
|
|
@@ -1325,6 +1677,7 @@ export class AccessControlService {
|
|
|
1325
1677
|
const model = {
|
|
1326
1678
|
roles: rolesParsed.index,
|
|
1327
1679
|
accessFilesByDir: accessFiles,
|
|
1680
|
+
groupsHealth: activeGroups.health,
|
|
1328
1681
|
deploymentOwners: this.deploymentOwners,
|
|
1329
1682
|
};
|
|
1330
1683
|
this.cache.set(workspaceId, { model, loadedAt: Date.now() });
|
|
@@ -1525,6 +1878,21 @@ export class AccessControlService {
|
|
|
1525
1878
|
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1526
1879
|
if (!rolesParsed.ok)
|
|
1527
1880
|
return null;
|
|
1881
|
+
// Same group loading as the working-tree model, read AT THE REF — the
|
|
1882
|
+
// whole point of file-materialized groups is that the merge/push gates
|
|
1883
|
+
// can evaluate them at the commit they gate. (`showAtRef` folds every git
|
|
1884
|
+
// failure into null/absent — at-ref reads cannot distinguish a missing
|
|
1885
|
+
// path from a repo error, so the broken-groups marker here fires only on
|
|
1886
|
+
// parse failures.)
|
|
1887
|
+
const activeGroups = await loadActiveGroups((filename) => this.showAtRef(repoDir, resolvedRef, filename));
|
|
1888
|
+
if (!activeGroups.health.ok) {
|
|
1889
|
+
console.error(`[access@${resolvedRef}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`);
|
|
1890
|
+
}
|
|
1891
|
+
for (const w of activeGroups.warnings)
|
|
1892
|
+
console.warn(`[access@${resolvedRef}] ${w}`);
|
|
1893
|
+
const mergeWarnings = mergeGroupsIntoRoles(rolesParsed.index, activeGroups.groups, activeGroups.sourceFile);
|
|
1894
|
+
for (const w of mergeWarnings)
|
|
1895
|
+
console.warn(`[access@${resolvedRef}] ${w}`);
|
|
1528
1896
|
const accessFiles = new Map();
|
|
1529
1897
|
const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
|
|
1530
1898
|
for (const p of accessPaths) {
|
|
@@ -1550,6 +1918,7 @@ export class AccessControlService {
|
|
|
1550
1918
|
model: {
|
|
1551
1919
|
roles: rolesParsed.index,
|
|
1552
1920
|
accessFilesByDir: accessFiles,
|
|
1921
|
+
groupsHealth: activeGroups.health,
|
|
1553
1922
|
// Same owners as the working-tree model. The at-ref model backs the
|
|
1554
1923
|
// PUSH gate, so omitting them here would let the deployment owner save
|
|
1555
1924
|
// a roles.yaml repair locally and then be refused when it tries to
|