@bevel-software/platform-core-backend 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +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
|
@@ -1,51 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Admin Roles & Members service —
|
|
2
|
+
* Admin Roles & Members service — MEMBERSHIP editing on the default-branch
|
|
3
|
+
* `roles.yaml`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Roles are APP-DEFINED capabilities (see `capability-registry.ts`): the
|
|
6
|
+
* product decides which roles exist; users can never create, rename, or
|
|
7
|
+
* delete one — those editors are gone. What remains admin-editable is
|
|
8
|
+
* MEMBERSHIP: adding/removing individual emails and assigning/unassigning
|
|
9
|
+
* groups (`group:<canonical>` members), on capability roles and on LEGACY
|
|
10
|
+
* people-set roles alike (pre-split roles keep resolving and stay editable;
|
|
11
|
+
* `convertRoleToGroup` is their migration path).
|
|
12
|
+
*
|
|
13
|
+
* It is the primary in-app writer of `roles.yaml` (the groups admin also
|
|
14
|
+
* rewrites its `group:` refs on group rename/delete, through the same shared
|
|
15
|
+
* validated pipeline), and it is built around one non-negotiable safety
|
|
16
|
+
* property:
|
|
8
17
|
*
|
|
9
18
|
* roles.yaml has NO admin-rescue (canWrite('roles.yaml') is a pure isAdmin
|
|
10
19
|
* check) and loadModel HARD-THROWS on a parse failure (which isAdmin swallows
|
|
11
20
|
* into false for EVERYONE). So a single malformed write = a permanent,
|
|
12
21
|
* app-wide, in-app-unrecoverable admin lockout. Every mutation therefore runs
|
|
13
22
|
* the candidate text through the resolver's OWN parser (validateRolesYaml)
|
|
14
|
-
* before a byte hits disk; on any error it writes nothing.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Single-file ops (create/delete role, add/remove member) — `runEdit`:
|
|
19
|
-
* pre-check roles.yaml isn't locked → RolesAdminError 409
|
|
20
|
-
* read CURRENT roles.yaml → parse → edit-in-memory → re-emit candidate
|
|
21
|
-
* validateRolesYaml(candidate) → RolesAdminError 422, write nothing
|
|
22
|
-
* LockingFilesystem.writeFile(roles.yaml) ← acquire → write → release;
|
|
23
|
-
* release IS the commit + push (default per-file summary), so the commit
|
|
24
|
-
* lands a beat after the write — same pipeline the human editor + agent
|
|
25
|
-
* use. The HTTP roster reads the (synchronously written) working tree.
|
|
26
|
-
*
|
|
27
|
-
* Rename (`renameRole`) — keeps an ATOMIC multi-file special case because it
|
|
28
|
-
* rewrites roles.yaml + every reference in ONE commit (a partial rewrite =
|
|
29
|
-
* silent access drop):
|
|
30
|
-
* pre-check roles.yaml isn't locked → RolesAdminError 409
|
|
31
|
-
* parse + rewrite refs (fail-closed) → validate gate
|
|
32
|
-
* LockingFilesystem.writeFiles(roles.yaml + refs) ← acquires every path's
|
|
33
|
-
* lock, commits the curated set as ONE change (via the workflow service's
|
|
34
|
-
* commitChanges), then releases without a per-file commit.
|
|
23
|
+
* before a byte hits disk; on any error it writes nothing. That parser also
|
|
24
|
+
* carries the Admin invariant — at least one DIRECT email member, group
|
|
25
|
+
* references alone are never enough — so no write path can land a
|
|
26
|
+
* directory-dependent Admin.
|
|
35
27
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
28
|
+
* Lock/commit plumbing is the SHARED `AdminLockedCommits` helper (one
|
|
29
|
+
* implementation for this service and the groups admin — the twins diverged
|
|
30
|
+
* once into a real bug). Reference scanning is the SHARED
|
|
31
|
+
* `KbReferenceScanner` (cached, so roster mutations don't rerun the full-KB
|
|
32
|
+
* sweep the GET pays for).
|
|
39
33
|
*/
|
|
40
|
-
import path from 'node:path';
|
|
41
|
-
import { promises as fs } from 'node:fs';
|
|
42
34
|
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
43
|
-
import { LockingFilesystem } from '../workflow/locking-filesystem.js';
|
|
44
35
|
import { WorkflowDomainError } from '../workflow/workflow.errors.js';
|
|
45
|
-
import { ADMIN_CANONICAL, canonicalRoleName, canonicalEmail,
|
|
36
|
+
import { ADMIN_CANONICAL, ROLE_TOKEN_PREFIX, canonicalRoleName, canonicalEmail, loadActiveGroups, } from './access-control.service.js';
|
|
46
37
|
import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
|
|
47
38
|
import { renderRolesYaml } from './render-roles-yaml.js';
|
|
48
|
-
import { parseRolesModel,
|
|
39
|
+
import { parseRolesModel, deleteRole as editDeleteRole, addMember as editAddMember, removeMember as editRemoveMember, addRoleGroupRef as editAddGroupRef, removeRoleGroupRef as editRemoveGroupRef, isGroupRefMember, RolesEditError, } from './roles-edit.js';
|
|
40
|
+
import { GROUPS_YAML, SYNCED_GROUPS_YAML, validateGroupsFile } from './group-files.js';
|
|
41
|
+
import { GroupsEditError, assertSafeGroupDisplayName, emitGroupsModel, parseGroupsModel, } from './groups-edit.js';
|
|
42
|
+
import { capabilityRoleFor, isLegacyPeopleSetRole } from './capability-registry.js';
|
|
43
|
+
import { GROUP_REF_PREFIX, RESERVED_ROLE_NAMES } from './access-control.service.js';
|
|
44
|
+
import { AdminLockedCommits } from './admin-locked-commit.js';
|
|
45
|
+
import { KbReferenceScanner } from './reference-scan.js';
|
|
49
46
|
/** A mutation that cannot proceed (invariant violation, contention, not found). */
|
|
50
47
|
export class RolesAdminError extends WorkflowDomainError {
|
|
51
48
|
constructor(message, status = 422, payload) {
|
|
@@ -80,6 +77,10 @@ export class RolesAdminService {
|
|
|
80
77
|
defaultBranchOf;
|
|
81
78
|
eventBus;
|
|
82
79
|
recoveryAdmins;
|
|
80
|
+
/** Shared lock/commit plumbing — see module doc. */
|
|
81
|
+
locked;
|
|
82
|
+
/** Shared cached reference scanner — see module doc. */
|
|
83
|
+
references;
|
|
83
84
|
constructor(workspaceService, workflowService, accessControl, kbDirName,
|
|
84
85
|
/**
|
|
85
86
|
* The branch whose roles.yaml is authoritative for admin status, read
|
|
@@ -111,6 +112,23 @@ export class RolesAdminService {
|
|
|
111
112
|
this.defaultBranchOf = defaultBranchOf;
|
|
112
113
|
this.eventBus = eventBus;
|
|
113
114
|
this.recoveryAdmins = recoveryAdmins;
|
|
115
|
+
this.locked = new AdminLockedCommits({
|
|
116
|
+
workspaceService,
|
|
117
|
+
workflowService,
|
|
118
|
+
kbDirName,
|
|
119
|
+
defaultBranchOf,
|
|
120
|
+
makeError: (message, status, payload) => new RolesAdminError(message, status, payload),
|
|
121
|
+
logTag: 'roles-admin',
|
|
122
|
+
contendedSubject: 'Roles',
|
|
123
|
+
// Defence in depth: this surface already validates candidates before
|
|
124
|
+
// writing, but the same pre-disk gate the editor/agent use guarantees a
|
|
125
|
+
// malformed roles.yaml can never land through here either.
|
|
126
|
+
validateWrite: makeRolesYamlWriteValidator(kbDirName),
|
|
127
|
+
});
|
|
128
|
+
// The event bus keeps the roster's `referencedBy` fresh: a share-dialog
|
|
129
|
+
// grant/revoke on any access.md happens outside this service, and the
|
|
130
|
+
// scanner invalidates its cache on those writes' events (TTL as backstop).
|
|
131
|
+
this.references = new KbReferenceScanner(workspaceService, kbDirName, eventBus);
|
|
114
132
|
}
|
|
115
133
|
/** Resolved per call — see the constructor note on `defaultBranchOf`. */
|
|
116
134
|
get defaultBranch() {
|
|
@@ -147,33 +165,11 @@ export class RolesAdminService {
|
|
|
147
165
|
await this.workspaceService.getOrCreateForBranch(this.defaultBranch);
|
|
148
166
|
return this.workspaceId;
|
|
149
167
|
}
|
|
150
|
-
async repoDir(workspaceId) {
|
|
151
|
-
const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
152
|
-
return path.join(wsDir, this.kbDirName);
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* A lock-aware filesystem scoped to `actor` — writes auto-commit + push as the
|
|
156
|
-
* actor on release, through the same pipeline as the human editor and agent.
|
|
157
|
-
* Built per-op because the lock context captures the acting user.
|
|
158
|
-
*/
|
|
159
|
-
async lockingFsForActor(workspaceId, actor) {
|
|
160
|
-
const basePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
161
|
-
return new LockingFilesystem({ basePath, contained: true }, {
|
|
162
|
-
workflow: this.workflowService,
|
|
163
|
-
workspaceId,
|
|
164
|
-
branch: this.defaultBranch,
|
|
165
|
-
user: actor,
|
|
166
|
-
// Defence in depth: this surface already validates candidates before
|
|
167
|
-
// writing, but the same pre-disk gate the editor/agent use guarantees a
|
|
168
|
-
// malformed roles.yaml can never land through here either.
|
|
169
|
-
validateWrite: makeRolesYamlWriteValidator(this.kbDirName),
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
168
|
/**
|
|
173
169
|
* Fail fast with a friendly 409 if roles.yaml is already locked by someone
|
|
174
|
-
* else. The actual lock is taken by the
|
|
175
|
-
*
|
|
176
|
-
*
|
|
170
|
+
* else. The actual lock is taken by the write that follows; this only
|
|
171
|
+
* preserves the explicit "being edited by X" contract (the raw contention
|
|
172
|
+
* error is status-less and would surface as 500).
|
|
177
173
|
*/
|
|
178
174
|
async assertRolesUnlocked(workspaceId, actor) {
|
|
179
175
|
const held = await this.workflowService.getLock(workspaceId, this.defaultBranch, `${this.kbDirName}/${ROLES_YAML}`);
|
|
@@ -181,35 +177,8 @@ export class RolesAdminService {
|
|
|
181
177
|
throw new RolesAdminError(`Roles are being edited by ${held.holderName || 'another admin'}. Try again in a moment.`, 409);
|
|
182
178
|
}
|
|
183
179
|
}
|
|
184
|
-
/**
|
|
185
|
-
* Run a lock-aware filesystem write, mapping its status-less contention error
|
|
186
|
-
* ("Skipped editing … — locked by …") to the friendly 409. {@link
|
|
187
|
-
* assertRolesUnlocked} pre-checks the common case (and names the holder), but
|
|
188
|
-
* it's a TOCTOU check — another admin can grab the lock between it and the
|
|
189
|
-
* actual acquire inside the write. This catches that race so contention always
|
|
190
|
-
* surfaces as a 409, never an unhandled 500.
|
|
191
|
-
*/
|
|
192
|
-
async mapLockContention(write) {
|
|
193
|
-
try {
|
|
194
|
-
return await write();
|
|
195
|
-
}
|
|
196
|
-
catch (err) {
|
|
197
|
-
if (err instanceof Error && /locked by /.test(err.message)) {
|
|
198
|
-
throw new RolesAdminError('Roles are being edited by another admin. Try again in a moment.', 409);
|
|
199
|
-
}
|
|
200
|
-
throw err;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
180
|
async readRolesYaml(workspaceId) {
|
|
204
|
-
|
|
205
|
-
return await this.workspaceService.readFile(workspaceId, path.posix.join(this.kbDirName, ROLES_YAML));
|
|
206
|
-
}
|
|
207
|
-
catch (err) {
|
|
208
|
-
const code = err?.code;
|
|
209
|
-
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
210
|
-
return '';
|
|
211
|
-
throw err;
|
|
212
|
-
}
|
|
181
|
+
return (await this.locked.readKbFile(workspaceId, ROLES_YAML)) ?? '';
|
|
213
182
|
}
|
|
214
183
|
// ---- Read -------------------------------------------------------------
|
|
215
184
|
/** The roster: every role on the default branch with members + references. */
|
|
@@ -217,20 +186,37 @@ export class RolesAdminService {
|
|
|
217
186
|
const workspaceId = await this.ensureWorkspace();
|
|
218
187
|
const text = await this.readRolesYaml(workspaceId);
|
|
219
188
|
const model = parseRolesModel(text);
|
|
220
|
-
// ONE sound scan of every
|
|
221
|
-
// frontmatter)
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
189
|
+
// ONE sound scan of every candidate file (folder access.md + node
|
|
190
|
+
// frontmatter) — cached in the shared scanner, so mutations returning the
|
|
191
|
+
// fresh roster don't rerun the full-KB sweep — indexed by canonical entry
|
|
192
|
+
// token. Attribution per spelling: a `role/<name>` hit is ALWAYS the
|
|
193
|
+
// role's; a BARE hit is the role's only when no group shadows the name —
|
|
194
|
+
// bare tokens resolve group-first, so a shadowed bare grant belongs to the
|
|
195
|
+
// GROUP (and the groups roster attributes it there — the mirror image).
|
|
196
|
+
const [referencesByToken, activeGroups] = await Promise.all([
|
|
197
|
+
this.references.scan(workspaceId),
|
|
198
|
+
loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f)),
|
|
199
|
+
]);
|
|
200
|
+
const groupOwnedBareNames = new Set(activeGroups.groups.keys());
|
|
225
201
|
const out = [];
|
|
226
202
|
for (const role of model) {
|
|
227
203
|
const canonical = canonicalRoleName(role.displayName);
|
|
204
|
+
const registryEntry = capabilityRoleFor(role.displayName);
|
|
228
205
|
out.push({
|
|
229
206
|
canonical,
|
|
230
207
|
displayName: role.displayName,
|
|
231
|
-
members: role.members,
|
|
208
|
+
members: role.members.filter((m) => !isGroupRefMember(m)),
|
|
209
|
+
groups: role.members
|
|
210
|
+
.filter(isGroupRefMember)
|
|
211
|
+
.map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
|
|
232
212
|
isAdmin: canonical === ADMIN_CANONICAL,
|
|
233
|
-
|
|
213
|
+
capability: registryEntry
|
|
214
|
+
? { description: registryEntry.description, groupAssignable: registryEntry.groupAssignable }
|
|
215
|
+
: null,
|
|
216
|
+
referencedBy: [
|
|
217
|
+
...(groupOwnedBareNames.has(canonical) ? [] : referencesByToken.get(canonical) ?? []),
|
|
218
|
+
...(referencesByToken.get(`${ROLE_TOKEN_PREFIX}${canonical}`) ?? []),
|
|
219
|
+
],
|
|
234
220
|
});
|
|
235
221
|
}
|
|
236
222
|
return out;
|
|
@@ -288,14 +274,17 @@ export class RolesAdminService {
|
|
|
288
274
|
throw new RolesAdminError('roles.yaml is valid — recovery is only available when the file is corrupted.', 409, { kind: 'not-corrupted' });
|
|
289
275
|
}
|
|
290
276
|
if (this.recoveryAdmins.length === 0) {
|
|
277
|
+
// 409, not 500: this is a CONFIGURATION refusal in a break-glass flow —
|
|
278
|
+
// the operator needs the actionable message, and the route helper hides
|
|
279
|
+
// every >=500 body behind a generic "Internal error." on purpose.
|
|
291
280
|
throw new RolesAdminError('Recovery needs a configured admin (ADMIN_EMAIL) to restore — a roles.yaml ' +
|
|
292
|
-
'with no Admin is exactly the unusable state recovery exists to escape.',
|
|
281
|
+
'with no Admin is exactly the unusable state recovery exists to escape.', 409, { kind: 'no-recovery-admins' });
|
|
293
282
|
}
|
|
294
283
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
295
284
|
// Back up the corrupted bytes and restore the good default atomically. The
|
|
296
285
|
// default is valid, so the resolver loads immediately after this commit.
|
|
297
|
-
const fsys = await this.lockingFsForActor(workspaceId, actor);
|
|
298
|
-
await this.mapLockContention(() => fsys.writeFiles([
|
|
286
|
+
const fsys = await this.locked.lockingFsForActor(workspaceId, actor);
|
|
287
|
+
await this.locked.mapLockContention(() => fsys.writeFiles([
|
|
299
288
|
{ path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
|
|
300
289
|
{ path: `${this.kbDirName}/${ROLES_YAML}`, content: renderRecoveryRolesYaml(this.recoveryAdmins) },
|
|
301
290
|
], 'Bevel recovery: reset corrupted roles.yaml'));
|
|
@@ -304,36 +293,34 @@ export class RolesAdminService {
|
|
|
304
293
|
return this.getRoster();
|
|
305
294
|
}
|
|
306
295
|
// ---- Mutations --------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (canonical === ADMIN_CANONICAL) {
|
|
313
|
-
throw new RolesAdminError('The Admin role cannot be deleted', 422);
|
|
314
|
-
}
|
|
315
|
-
await this.runEdit(actor, (text) => editDeleteRole(text, canonical));
|
|
316
|
-
return this.getRoster();
|
|
317
|
-
}
|
|
296
|
+
//
|
|
297
|
+
// MEMBERSHIP ONLY. There is deliberately no createRole / renameRole /
|
|
298
|
+
// deleteRole: roles are app-defined capabilities (see capability-registry),
|
|
299
|
+
// so the admin surface cannot mint, rebrand, or retire one. Legacy roles
|
|
300
|
+
// migrate out via convertRoleToGroup.
|
|
318
301
|
async addMember(actor, canonical, email) {
|
|
319
302
|
await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
|
|
320
303
|
return this.getRoster();
|
|
321
304
|
}
|
|
322
305
|
/**
|
|
323
|
-
* Remove a member. Refuses to
|
|
324
|
-
*
|
|
325
|
-
*
|
|
306
|
+
* Remove a member. Refuses to remove the Admin role's LAST DIRECT EMAIL
|
|
307
|
+
* (422) — the kept invariant: whatever groups Admin references, at least
|
|
308
|
+
* one directory-independent email member must remain, or a broken IdP
|
|
309
|
+
* connection becomes an admin lockout. Removing the caller's OWN last
|
|
310
|
+
* Admin membership requires `confirm` (409 otherwise) — no silent
|
|
311
|
+
* self-lockout, since admin status is read live from this file.
|
|
326
312
|
*/
|
|
327
313
|
async removeMember(actor, canonical, email, confirm) {
|
|
328
314
|
await this.runEdit(actor, (text) => {
|
|
329
315
|
const target = canonicalEmail(email);
|
|
330
316
|
if (canonical === ADMIN_CANONICAL) {
|
|
331
317
|
const admin = parseRolesModel(text).find((r) => canonicalRoleName(r.displayName) === ADMIN_CANONICAL);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
318
|
+
// The invariant counts DIRECT emails only — group refs don't rescue.
|
|
319
|
+
const directMembers = (admin?.members ?? []).filter((m) => !isGroupRefMember(m));
|
|
320
|
+
if (directMembers.includes(target) && directMembers.length <= 1) {
|
|
321
|
+
throw new RolesAdminError('The Admin role must keep at least one direct email member — group references alone are not enough.', 422);
|
|
335
322
|
}
|
|
336
|
-
if (target === canonicalEmail(actor.email) &&
|
|
323
|
+
if (target === canonicalEmail(actor.email) && directMembers.includes(target) && !confirm) {
|
|
337
324
|
throw new RolesAdminError('You are about to remove your own admin access', 409, {
|
|
338
325
|
kind: 'self-admin-removal',
|
|
339
326
|
});
|
|
@@ -344,57 +331,125 @@ export class RolesAdminService {
|
|
|
344
331
|
return this.getRoster();
|
|
345
332
|
}
|
|
346
333
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
334
|
+
* Assign a role to a group — Admin included (the parse-time invariant keeps
|
|
335
|
+
* at least one direct email on Admin, so a group ref can never be its only
|
|
336
|
+
* membership).
|
|
337
|
+
*
|
|
338
|
+
* The ref must name a group the ACTIVE source knows: mergeGroupsIntoRoles
|
|
339
|
+
* ignores an unknown ref with only a log warning, so a typo here would be
|
|
340
|
+
* accepted and then silently grant the role to nobody. The check is
|
|
341
|
+
* BEST-EFFORT validation, not a race-free guarantee: the manual group
|
|
342
|
+
* file's lock is held across validate + write (so a concurrent groups-page
|
|
343
|
+
* deletion/rename can't invalidate the ref mid-flight), but the IdP sync
|
|
344
|
+
* writer commits through its own lock — a provisioning push can retire the
|
|
345
|
+
* group between this check and the next sync. That is fine by design: a
|
|
346
|
+
* dangling ref resolves to nothing, with a resolver warning naming it.
|
|
353
347
|
*/
|
|
354
|
-
async
|
|
355
|
-
const newCanonical = canonicalRoleName(newDisplayName);
|
|
356
|
-
if (canonical === ADMIN_CANONICAL && newCanonical !== ADMIN_CANONICAL) {
|
|
357
|
-
throw new RolesAdminError('The Admin role cannot be renamed to a different name', 400);
|
|
358
|
-
}
|
|
348
|
+
async assignGroup(actor, canonical, groupName) {
|
|
359
349
|
const workspaceId = await this.ensureWorkspace();
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
350
|
+
await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], async () => {
|
|
351
|
+
const { groups } = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
|
|
352
|
+
if (!groups.has(canonicalRoleName(groupName))) {
|
|
353
|
+
throw new RolesAdminError(`No group named "${groupName.trim()}". Pick an existing group.`, 404, {
|
|
354
|
+
kind: 'unknown-group',
|
|
355
|
+
group: groupName.trim(),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
await this.runEdit(actor, (text) => editAddGroupRef(text, canonical, groupName));
|
|
359
|
+
});
|
|
360
|
+
return this.getRoster();
|
|
361
|
+
}
|
|
362
|
+
async unassignGroup(actor, canonical, groupName) {
|
|
363
|
+
await this.runEdit(actor, (text) => editRemoveGroupRef(text, canonical, groupName));
|
|
364
|
+
return this.getRoster();
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Convert a LEGACY people-set role into a manual group — the migration the
|
|
368
|
+
* roles/groups split defines: "Product" was never a capability, it was a
|
|
369
|
+
* team. Atomic two-file move (roles.yaml loses the role, groups.yaml gains
|
|
370
|
+
* the group with the same members) in ONE commit; grant references keep
|
|
371
|
+
* working untouched because the NAME does not change.
|
|
372
|
+
*
|
|
373
|
+
* Refusals: capability roles (they ARE roles — Admin included), roles with
|
|
374
|
+
* group assignments (unwind those first: a group containing a group is
|
|
375
|
+
* nesting), IdP mode (groups are managed in the identity provider — this
|
|
376
|
+
* would write a retired file), and a groups.yaml name collision.
|
|
377
|
+
*/
|
|
378
|
+
async convertRoleToGroup(actor, canonical) {
|
|
379
|
+
const workspaceId = await this.ensureWorkspace();
|
|
380
|
+
if (!isLegacyPeopleSetRole(canonical)) {
|
|
381
|
+
throw new RolesAdminError(`'${canonical}' is a capability role — it cannot become a group`, 422);
|
|
374
382
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (newCanonical !== canonical) {
|
|
378
|
-
const repoDir = await this.repoDir(workspaceId);
|
|
379
|
-
const refWrites = await this.rewriteRoleReferences(workspaceId, repoDir, canonical, newDisplayName.trim());
|
|
380
|
-
writes.push(...refWrites);
|
|
383
|
+
if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
|
|
384
|
+
throw new RolesAdminError('Groups are synced from your identity provider — recreate this team there instead.', 409, { kind: 'idp-mode' });
|
|
381
385
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
386
|
+
await this.assertRolesUnlocked(workspaceId, actor);
|
|
387
|
+
// Hold ALL THREE file locks across read → build → write: candidates are
|
|
388
|
+
// built from a snapshot, and without the hold another admin's edit could
|
|
389
|
+
// land in between and be silently overwritten. The synced file's lock is
|
|
390
|
+
// held too — the directory-sync materializer commits synced-groups.yaml
|
|
391
|
+
// through that same lock, so holding it serializes this conversion with a
|
|
392
|
+
// provisioning push and makes the IdP-mode recheck below race-free. It is
|
|
393
|
+
// a COORDINATION hold (never a write path): synced-groups.yaml is
|
|
394
|
+
// machine-owned — only the sync bot passes its write rule, so a
|
|
395
|
+
// write-intent acquire would refuse every human actor at the gate. The
|
|
396
|
+
// conversion only READS the file; coordination gives it the mutex without
|
|
397
|
+
// claiming write authority.
|
|
398
|
+
await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML, ROLES_YAML], async () => {
|
|
399
|
+
// Re-check UNDER the synced file's lock: the pre-lock check above is a
|
|
400
|
+
// fast-path courtesy, but directory sync could materialize
|
|
401
|
+
// synced-groups.yaml between it and this point — and committing a new
|
|
402
|
+
// groups.yaml group in IdP mode writes a retired file.
|
|
403
|
+
if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
|
|
404
|
+
throw new RolesAdminError('Groups are synced from your identity provider — recreate this team there instead.', 409, { kind: 'idp-mode' });
|
|
405
|
+
}
|
|
406
|
+
const rolesText = await this.readRolesYaml(workspaceId);
|
|
407
|
+
const role = parseRolesModel(rolesText).find((r) => canonicalRoleName(r.displayName) === canonical);
|
|
408
|
+
if (!role)
|
|
409
|
+
throw new RolesAdminError(`role not found: ${canonical}`, 404);
|
|
410
|
+
const groupRefs = role.members.filter(isGroupRefMember);
|
|
411
|
+
if (groupRefs.length > 0) {
|
|
412
|
+
throw new RolesAdminError('This role is assigned to groups — remove those assignments before converting it.', 422);
|
|
413
|
+
}
|
|
414
|
+
// Build both candidates BEFORE any write, and validate both.
|
|
415
|
+
const rolesEdit = this.guardEdit(() => editDeleteRole(rolesText, canonical));
|
|
416
|
+
this.assertLoadable(rolesEdit.text);
|
|
417
|
+
makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
|
|
418
|
+
// Keep absent (null) distinct from existing-but-empty ('') — rollback
|
|
419
|
+
// must DELETE a groups.yaml it created, not truncate one already there.
|
|
420
|
+
// ONE parse + ONE emit: the group candidate is built on the model
|
|
421
|
+
// directly (name-safety + reserved + duplicate checks inline), not via
|
|
422
|
+
// per-member editor round-trips that each re-parse the whole file.
|
|
423
|
+
const groupsOriginal = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
|
|
424
|
+
let groupsCandidate;
|
|
425
|
+
try {
|
|
426
|
+
assertSafeGroupDisplayName(role.displayName);
|
|
427
|
+
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
428
|
+
throw new GroupsEditError(`'${role.displayName}' is a reserved name and cannot be a group`);
|
|
429
|
+
}
|
|
430
|
+
const groupsModel = parseGroupsModel(groupsOriginal ?? '');
|
|
431
|
+
if (groupsModel.some((g) => canonicalRoleName(g.displayName) === canonical)) {
|
|
432
|
+
throw new GroupsEditError(`a group named '${role.displayName}' already exists`);
|
|
433
|
+
}
|
|
434
|
+
groupsModel.push({ displayName: role.displayName, members: [...role.members] });
|
|
435
|
+
groupsCandidate = emitGroupsModel(groupsModel);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
if (err instanceof GroupsEditError)
|
|
439
|
+
throw new RolesAdminError(err.message, err.status);
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
const groupsValid = validateGroupsFile(groupsCandidate, GROUPS_YAML);
|
|
443
|
+
if (!groupsValid.ok) {
|
|
444
|
+
throw new RolesAdminError(`groups.yaml would be invalid: ${groupsValid.errors.join('; ')}`, 422);
|
|
445
|
+
}
|
|
446
|
+
await this.locked.writeAndCommitLocked(workspaceId, actor, [
|
|
447
|
+
{ repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText },
|
|
448
|
+
{ repoRel: GROUPS_YAML, content: groupsCandidate, original: groupsOriginal },
|
|
449
|
+
], `Convert role ${role.displayName} to a group`);
|
|
450
|
+
}, { coordinationFiles: [SYNCED_GROUPS_YAML] });
|
|
396
451
|
this.accessControl.invalidate(workspaceId);
|
|
397
|
-
this.emitWrites(workspaceId, actor,
|
|
452
|
+
this.emitWrites(workspaceId, actor, [ROLES_YAML, GROUPS_YAML]);
|
|
398
453
|
return this.getRoster();
|
|
399
454
|
}
|
|
400
455
|
// ---- Internals --------------------------------------------------------
|
|
@@ -416,240 +471,31 @@ export class RolesAdminService {
|
|
|
416
471
|
}
|
|
417
472
|
}
|
|
418
473
|
/**
|
|
419
|
-
* Single-file roles.yaml mutation
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* to `actor`), the same pipeline the human editor and agent use. So unlike the
|
|
425
|
-
* rename's synchronous atomic commit, the git commit here lands on release
|
|
426
|
-
* (a beat after the write); the HTTP response reads the working tree, which is
|
|
427
|
-
* already on disk, so the returned roster is correct.
|
|
474
|
+
* Single-file roles.yaml mutation under the shared file-lock/commit helper.
|
|
475
|
+
* `pre` produces the candidate (and may throw RolesAdminError for invariant
|
|
476
|
+
* violations before any write). Skips on a no-op. Every candidate passes
|
|
477
|
+
* the resolver's own parser (assertLoadable) plus the pre-disk validator
|
|
478
|
+
* before a byte lands.
|
|
428
479
|
*/
|
|
429
480
|
async runEdit(actor, pre) {
|
|
430
481
|
const workspaceId = await this.ensureWorkspace();
|
|
431
482
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
483
|
+
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => this.runEditLocked(workspaceId, actor, pre));
|
|
484
|
+
}
|
|
485
|
+
async runEditLocked(workspaceId, actor, pre) {
|
|
432
486
|
const text = await this.readRolesYaml(workspaceId);
|
|
433
487
|
const result = this.guardEdit(() => pre(text));
|
|
434
488
|
if (!result.changed)
|
|
435
489
|
return;
|
|
436
490
|
this.assertLoadable(result.text);
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const fsys = await this.lockingFsForActor(workspaceId, actor);
|
|
444
|
-
await this.mapLockContention(() => fsys.writeFile(`${this.kbDirName}/${ROLES_YAML}`, result.text));
|
|
491
|
+
// The lock is ALREADY OURS (withFileLocks; strict same-user acquire), so
|
|
492
|
+
// LockingFilesystem would contend against our own hold. Apply the same
|
|
493
|
+
// pre-disk validator it would have run, then plain-write + a path-scoped
|
|
494
|
+
// atomic commit.
|
|
495
|
+
makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, result.text);
|
|
496
|
+
await this.locked.writeAndCommitLocked(workspaceId, actor, [{ repoRel: ROLES_YAML, content: result.text, original: text }], `Update ${ROLES_YAML}`);
|
|
445
497
|
this.accessControl.invalidate(workspaceId);
|
|
446
498
|
this.emitWrites(workspaceId, actor, [ROLES_YAML]);
|
|
447
499
|
}
|
|
448
|
-
/**
|
|
449
|
-
* Find every genuine role reference to `oldCanonical` across folder access.md
|
|
450
|
-
* AND node frontmatter, and rewrite each to `newDisplayName`. Reference-aware:
|
|
451
|
-
* a line is rewritten ONLY if it PARSES as a role entry whose canonical name
|
|
452
|
-
* == oldCanonical. Prose, comments, `Name <email>` user entries, other keys,
|
|
453
|
-
* and substrings never match. Returns the repo-relative writes to commit.
|
|
454
|
-
*/
|
|
455
|
-
async rewriteRoleReferences(workspaceId, repoDir, oldCanonical, newDisplayName) {
|
|
456
|
-
const writes = [];
|
|
457
|
-
const candidates = await this.collectCandidateFiles(workspaceId);
|
|
458
|
-
for (const repoRel of candidates) {
|
|
459
|
-
const abs = path.join(repoDir, repoRel);
|
|
460
|
-
let text;
|
|
461
|
-
try {
|
|
462
|
-
text = await fs.readFile(abs, 'utf-8');
|
|
463
|
-
}
|
|
464
|
-
catch (err) {
|
|
465
|
-
// Fail closed: a candidate we cannot read might reference the
|
|
466
|
-
// old role. Skipping it would commit a partial rewrite (a half-renamed
|
|
467
|
-
// role pointing at the old name) and silently drop access. Abort the
|
|
468
|
-
// whole atomic rename instead so the admin can retry cleanly.
|
|
469
|
-
throw new RolesAdminError(`Cannot read ${repoRel} while rewriting role references; rename aborted with no changes`, 422, { cause: err?.message });
|
|
470
|
-
}
|
|
471
|
-
const rewritten = rewriteRoleTokensInText(text, oldCanonical, newDisplayName);
|
|
472
|
-
if (rewritten !== text) {
|
|
473
|
-
// REPO-relative (bare repoRel) for commitChanges — repoDir already points
|
|
474
|
-
// at <workspaceDir>/<kbDirName>.
|
|
475
|
-
writes.push({ repoRelativePath: repoRel, content: rewritten });
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
return writes;
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Repo-relative paths of every file that could carry a role reference: all
|
|
482
|
-
* `access.md` files plus every `.md` node (its own frontmatter). Sourced from
|
|
483
|
-
* the workspace file tree (which already skips `.git` and honours
|
|
484
|
-
* `.bevelignore`), filtered to `.md` under the KB dir and returned bare
|
|
485
|
-
* repo-relative. (`roles.yaml` isn't `.md`, so it's excluded; the rename
|
|
486
|
-
* commits it separately.) Under save=share the working tree matches the
|
|
487
|
-
* committed set, so this is the same candidate list git-tracking would give.
|
|
488
|
-
*/
|
|
489
|
-
async collectCandidateFiles(workspaceId) {
|
|
490
|
-
const tree = await this.workspaceService.listFiles(workspaceId);
|
|
491
|
-
const prefix = `${this.kbDirName}/`;
|
|
492
|
-
const out = [];
|
|
493
|
-
const visit = (node) => {
|
|
494
|
-
if (node.type === 'file') {
|
|
495
|
-
if (node.relativePath.startsWith(prefix) && node.relativePath.endsWith('.md')) {
|
|
496
|
-
out.push(node.relativePath.slice(prefix.length));
|
|
497
|
-
}
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
for (const child of node.children ?? [])
|
|
501
|
-
visit(child);
|
|
502
|
-
};
|
|
503
|
-
visit(tree);
|
|
504
|
-
return out;
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* Sound scan of EVERY `.md` (folder `access.md` + node frontmatter)
|
|
508
|
-
* for genuine role references, indexed by canonical role name. Shares the
|
|
509
|
-
* candidate set and the config-region role-entry parse with the rename
|
|
510
|
-
* rewrite (`findRoleRefsInText` / `rewriteRoleTokensInText`), so the delete
|
|
511
|
-
* warning and the rename rewrite see the SAME references — the warning can no
|
|
512
|
-
* longer undercount frontmatter the rename would touch. A file we cannot read
|
|
513
|
-
* is skipped (this is an advisory read, not the atomic write path): missing a
|
|
514
|
-
* reference here only weakens the warning, it cannot drop access.
|
|
515
|
-
*/
|
|
516
|
-
async scanRoleReferences(workspaceId) {
|
|
517
|
-
const repoDir = await this.repoDir(workspaceId);
|
|
518
|
-
const candidates = await this.collectCandidateFiles(workspaceId);
|
|
519
|
-
const byRole = new Map();
|
|
520
|
-
for (const repoRel of candidates) {
|
|
521
|
-
let text;
|
|
522
|
-
try {
|
|
523
|
-
text = await fs.readFile(path.join(repoDir, repoRel), 'utf-8');
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
for (const ref of findRoleRefsInText(text)) {
|
|
529
|
-
const list = byRole.get(ref.role);
|
|
530
|
-
if (list)
|
|
531
|
-
list.push({ path: repoRel, verb: ref.verb });
|
|
532
|
-
else
|
|
533
|
-
byRole.set(ref.role, [{ path: repoRel, verb: ref.verb }]);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return byRole;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Resolve the [start, end) line range that role rewrites may touch — the YAML
|
|
541
|
-
* config region only, NEVER the markdown body (CodeRabbit: a body line like
|
|
542
|
-
* `- Sales` or `owner: Sales` must not be rewritten).
|
|
543
|
-
*
|
|
544
|
-
* - A file with leading `---` frontmatter (folder `access.md`, node `.md`):
|
|
545
|
-
* only the lines BETWEEN the opening and closing `---` are eligible.
|
|
546
|
-
* - A fence-less file (e.g. a bare `roles.yaml`-style access config with no
|
|
547
|
-
* `---`): the whole file is config, so all lines are eligible.
|
|
548
|
-
* - A `.md` file with no frontmatter fence: no config region → empty range,
|
|
549
|
-
* nothing is rewritten.
|
|
550
|
-
*/
|
|
551
|
-
function configLineRange(lines, isMarkdown) {
|
|
552
|
-
if (lines.length > 0 && lines[0].trim() === '---') {
|
|
553
|
-
for (let i = 1; i < lines.length; i++) {
|
|
554
|
-
if (lines[i].trim() === '---')
|
|
555
|
-
return { start: 1, end: i };
|
|
556
|
-
}
|
|
557
|
-
// Unterminated frontmatter — treat nothing as eligible (don't risk the body).
|
|
558
|
-
return { start: 0, end: 0 };
|
|
559
|
-
}
|
|
560
|
-
// No fence: a markdown file has no config region; a non-markdown access file
|
|
561
|
-
// (no body) is entirely config.
|
|
562
|
-
return isMarkdown ? { start: 0, end: 0 } : { start: 0, end: lines.length };
|
|
563
|
-
}
|
|
564
|
-
/** A known access verb key: heads a block list or holds a scalar role value. */
|
|
565
|
-
const VERB_KEY_RE = /^(\s*)(read|write|download|owner)(:\s*)(.*)$/;
|
|
566
|
-
/** A block-list item: ` - <token>` (token may carry a leading `deny `). */
|
|
567
|
-
const LIST_ITEM_RE = /^(\s*-\s+)(.*)$/;
|
|
568
|
-
/**
|
|
569
|
-
* Walk the CONFIG-REGION lines of `text`, invoking `onRoleRef` for every line
|
|
570
|
-
* that PARSES as a genuine role entry — both the block-list form (`- <token>`
|
|
571
|
-
* under a `read:`/`write:`/… key) and the inline scalar form (`owner: <token>`).
|
|
572
|
-
* `verb` is the access verb the reference sits under; for a block list it is the
|
|
573
|
-
* nearest enclosing verb key (lines before any verb key, or under an unknown
|
|
574
|
-
* key, are skipped). This is the SINGLE source of truth for "what is a role
|
|
575
|
-
* reference" — both the delete-warning scan and the rename rewrite drive off it,
|
|
576
|
-
* so they cannot disagree. The callback may mutate `lines[i]` (the rewrite does;
|
|
577
|
-
* the scan does not). User entries, other keys, comments and substrings never
|
|
578
|
-
* fire it.
|
|
579
|
-
*/
|
|
580
|
-
function walkRoleRefs(lines, start, end, onRoleRef) {
|
|
581
|
-
let currentVerb = null;
|
|
582
|
-
/** A role-entry value → its parsed role entry, else null (user/empty/other). */
|
|
583
|
-
const roleEntry = (rawValue) => {
|
|
584
|
-
const parsed = parseAccessEntry(rawValue.replace(/\s+$/, ''));
|
|
585
|
-
return parsed.ok && parsed.entry.kind === 'role'
|
|
586
|
-
? { role: parsed.entry.role, deny: parsed.entry.deny }
|
|
587
|
-
: null;
|
|
588
|
-
};
|
|
589
|
-
for (let i = start; i < end; i++) {
|
|
590
|
-
const line = lines[i];
|
|
591
|
-
// A verb key resets the block context. Its inline value (scalar form,
|
|
592
|
-
// `owner: Sales`) is itself a candidate reference under that same verb.
|
|
593
|
-
const kvM = line.match(VERB_KEY_RE);
|
|
594
|
-
if (kvM) {
|
|
595
|
-
currentVerb = kvM[2];
|
|
596
|
-
const inlineValue = kvM[4];
|
|
597
|
-
if (inlineValue.trim() !== '') {
|
|
598
|
-
const entry = roleEntry(inlineValue);
|
|
599
|
-
if (entry)
|
|
600
|
-
onRoleRef({ i, verb: currentVerb, entry, indent: `${kvM[1]}${kvM[2]}${kvM[3]}`, prefix: '' });
|
|
601
|
-
}
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
// Block-list item — belongs to the nearest enclosing verb key. A list item
|
|
605
|
-
// with no verb in scope is not a resolvable access rule; skip it.
|
|
606
|
-
const listM = line.match(LIST_ITEM_RE);
|
|
607
|
-
if (listM && currentVerb !== null) {
|
|
608
|
-
const entry = roleEntry(listM[2]);
|
|
609
|
-
if (entry)
|
|
610
|
-
onRoleRef({ i, verb: currentVerb, entry, indent: '', prefix: listM[1] });
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
// A non-empty, non-list, non-kv line ends the current block (e.g. a new
|
|
614
|
-
// top-level key whose value isn't a verb, or stray prose in config).
|
|
615
|
-
if (line.trim() !== '' && !listM)
|
|
616
|
-
currentVerb = null;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
/** Every genuine role reference in `text`'s config region, as {role, verb}. */
|
|
620
|
-
export function findRoleRefsInText(text, isMarkdown = true) {
|
|
621
|
-
const lines = text.split('\n');
|
|
622
|
-
const { start, end } = configLineRange(lines, isMarkdown);
|
|
623
|
-
const out = [];
|
|
624
|
-
if (start >= end)
|
|
625
|
-
return out;
|
|
626
|
-
walkRoleRefs(lines, start, end, ({ verb, entry }) => out.push({ role: entry.role, verb }));
|
|
627
|
-
return out;
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Rewrite every CONFIG-REGION line that PARSES as a role reference whose
|
|
631
|
-
* canonical name == `oldCanonical`, replacing the role token with
|
|
632
|
-
* `newDisplayName` (preserving any leading `deny ` and indentation). Only the
|
|
633
|
-
* frontmatter block of a markdown file is touched — the body is left
|
|
634
|
-
* byte-for-byte intact, so a prose line like `- Sales` is never corrupted.
|
|
635
|
-
* Lines that don't parse as a matching role entry (user entries, other keys,
|
|
636
|
-
* substrings) are also untouched. Exported for test.
|
|
637
|
-
*
|
|
638
|
-
* `isMarkdown` (default true) marks files that carry a markdown body below the
|
|
639
|
-
* frontmatter; pass false only for a pure-config file with no body.
|
|
640
|
-
*/
|
|
641
|
-
export function rewriteRoleTokensInText(text, oldCanonical, newDisplayName, isMarkdown = true) {
|
|
642
|
-
const lines = text.split('\n');
|
|
643
|
-
const { start, end } = configLineRange(lines, isMarkdown);
|
|
644
|
-
if (start >= end)
|
|
645
|
-
return text;
|
|
646
|
-
let changed = false;
|
|
647
|
-
walkRoleRefs(lines, start, end, ({ i, entry, indent, prefix }) => {
|
|
648
|
-
if (entry.role !== oldCanonical)
|
|
649
|
-
return;
|
|
650
|
-
lines[i] = `${indent}${prefix}${entry.deny ? 'deny ' : ''}${newDisplayName}`;
|
|
651
|
-
changed = true;
|
|
652
|
-
});
|
|
653
|
-
return changed ? lines.join('\n') : text;
|
|
654
500
|
}
|
|
655
501
|
//# sourceMappingURL=roles-admin.service.js.map
|