@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,70 +1,73 @@
|
|
|
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.
|
|
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.
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
35
|
-
*
|
|
36
|
-
* All reads and writes target the DEFAULT branch — the file admin status itself
|
|
37
|
-
* derives from. Editing any other branch's copy would show a roster that
|
|
38
|
-
* doesn't match who is actually an admin.
|
|
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
34
|
|
|
41
|
-
import path from 'node:path';
|
|
42
|
-
import { promises as fs } from 'node:fs';
|
|
43
|
-
|
|
44
35
|
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
45
|
-
import { LockingFilesystem } from '../workflow/locking-filesystem.js';
|
|
46
36
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
47
|
-
import type { AuthUser,
|
|
37
|
+
import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
|
|
48
38
|
import { WorkflowDomainError } from '../workflow/workflow.errors.js';
|
|
49
39
|
import type { IAccessControl } from './access-control.interface.js';
|
|
50
40
|
import {
|
|
51
41
|
ADMIN_CANONICAL,
|
|
42
|
+
ROLE_TOKEN_PREFIX,
|
|
52
43
|
canonicalRoleName,
|
|
53
44
|
canonicalEmail,
|
|
54
|
-
|
|
45
|
+
loadActiveGroups,
|
|
55
46
|
} from './access-control.service.js';
|
|
56
47
|
import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
|
|
57
48
|
import { renderRolesYaml } from './render-roles-yaml.js';
|
|
58
49
|
import {
|
|
59
50
|
parseRolesModel,
|
|
60
|
-
createRole as editCreateRole,
|
|
61
51
|
deleteRole as editDeleteRole,
|
|
62
52
|
addMember as editAddMember,
|
|
63
53
|
removeMember as editRemoveMember,
|
|
64
|
-
|
|
54
|
+
addRoleGroupRef as editAddGroupRef,
|
|
55
|
+
removeRoleGroupRef as editRemoveGroupRef,
|
|
56
|
+
isGroupRefMember,
|
|
65
57
|
RolesEditError,
|
|
66
58
|
type EditResult,
|
|
67
59
|
} from './roles-edit.js';
|
|
60
|
+
import { GROUPS_YAML, SYNCED_GROUPS_YAML, validateGroupsFile } from './group-files.js';
|
|
61
|
+
import {
|
|
62
|
+
GroupsEditError,
|
|
63
|
+
assertSafeGroupDisplayName,
|
|
64
|
+
emitGroupsModel,
|
|
65
|
+
parseGroupsModel,
|
|
66
|
+
} from './groups-edit.js';
|
|
67
|
+
import { capabilityRoleFor, isLegacyPeopleSetRole } from './capability-registry.js';
|
|
68
|
+
import { GROUP_REF_PREFIX, RESERVED_ROLE_NAMES } from './access-control.service.js';
|
|
69
|
+
import { AdminLockedCommits } from './admin-locked-commit.js';
|
|
70
|
+
import { KbReferenceScanner } from './reference-scan.js';
|
|
68
71
|
|
|
69
72
|
/** A mutation that cannot proceed (invariant violation, contention, not found). */
|
|
70
73
|
export class RolesAdminError extends WorkflowDomainError {
|
|
@@ -78,12 +81,24 @@ export class RolesAdminError extends WorkflowDomainError {
|
|
|
78
81
|
export interface RoleRosterEntry {
|
|
79
82
|
canonical: string;
|
|
80
83
|
displayName: string;
|
|
84
|
+
/** Individual member EMAILS (group assignments are split into `groups`). */
|
|
81
85
|
members: string[];
|
|
86
|
+
/** Canonical names of groups this role is assigned to (`group:` members). */
|
|
87
|
+
groups: string[];
|
|
82
88
|
isAdmin: boolean;
|
|
83
89
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
90
|
+
* Registry metadata when this is a CAPABILITY role (Admin today); null for
|
|
91
|
+
* a legacy pre-split people-set role, which the UI flags with a
|
|
92
|
+
* convert-to-group action.
|
|
93
|
+
*/
|
|
94
|
+
capability: { description: string; groupAssignable: boolean } | null;
|
|
95
|
+
/**
|
|
96
|
+
* Every access rule referencing this ROLE — folder `access.md` AND node
|
|
97
|
+
* frontmatter. `role/<name>` hits always count; a BARE-token hit counts
|
|
98
|
+
* only when no group shadows the name (bare tokens resolve group-first, so
|
|
99
|
+
* a shadowed bare grant is the GROUP's reference, listed on the groups
|
|
100
|
+
* roster instead). Sound: this is the SAME scan the group rename/delete
|
|
101
|
+
* rewrites use.
|
|
87
102
|
*/
|
|
88
103
|
referencedBy: { path: string; verb: string }[];
|
|
89
104
|
}
|
|
@@ -116,6 +131,11 @@ export interface RolesConfigHealth {
|
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
export class RolesAdminService {
|
|
134
|
+
/** Shared lock/commit plumbing — see module doc. */
|
|
135
|
+
private readonly locked: AdminLockedCommits;
|
|
136
|
+
/** Shared cached reference scanner — see module doc. */
|
|
137
|
+
private readonly references: KbReferenceScanner;
|
|
138
|
+
|
|
119
139
|
constructor(
|
|
120
140
|
private readonly workspaceService: IWorkspaceService,
|
|
121
141
|
private readonly workflowService: IWorkflowService,
|
|
@@ -144,7 +164,25 @@ export class RolesAdminService {
|
|
|
144
164
|
* recovery exists to escape.
|
|
145
165
|
*/
|
|
146
166
|
private readonly recoveryAdmins: readonly string[] = [],
|
|
147
|
-
) {
|
|
167
|
+
) {
|
|
168
|
+
this.locked = new AdminLockedCommits({
|
|
169
|
+
workspaceService,
|
|
170
|
+
workflowService,
|
|
171
|
+
kbDirName,
|
|
172
|
+
defaultBranchOf,
|
|
173
|
+
makeError: (message, status, payload) => new RolesAdminError(message, status, payload),
|
|
174
|
+
logTag: 'roles-admin',
|
|
175
|
+
contendedSubject: 'Roles',
|
|
176
|
+
// Defence in depth: this surface already validates candidates before
|
|
177
|
+
// writing, but the same pre-disk gate the editor/agent use guarantees a
|
|
178
|
+
// malformed roles.yaml can never land through here either.
|
|
179
|
+
validateWrite: makeRolesYamlWriteValidator(kbDirName),
|
|
180
|
+
});
|
|
181
|
+
// The event bus keeps the roster's `referencedBy` fresh: a share-dialog
|
|
182
|
+
// grant/revoke on any access.md happens outside this service, and the
|
|
183
|
+
// scanner invalidates its cache on those writes' events (TTL as backstop).
|
|
184
|
+
this.references = new KbReferenceScanner(workspaceService, kbDirName, eventBus);
|
|
185
|
+
}
|
|
148
186
|
|
|
149
187
|
/** Resolved per call — see the constructor note on `defaultBranchOf`. */
|
|
150
188
|
private get defaultBranch(): string {
|
|
@@ -184,38 +222,11 @@ export class RolesAdminService {
|
|
|
184
222
|
return this.workspaceId;
|
|
185
223
|
}
|
|
186
224
|
|
|
187
|
-
private async repoDir(workspaceId: string): Promise<string> {
|
|
188
|
-
const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
189
|
-
return path.join(wsDir, this.kbDirName);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* A lock-aware filesystem scoped to `actor` — writes auto-commit + push as the
|
|
194
|
-
* actor on release, through the same pipeline as the human editor and agent.
|
|
195
|
-
* Built per-op because the lock context captures the acting user.
|
|
196
|
-
*/
|
|
197
|
-
private async lockingFsForActor(workspaceId: string, actor: AuthUser): Promise<LockingFilesystem> {
|
|
198
|
-
const basePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
199
|
-
return new LockingFilesystem(
|
|
200
|
-
{ basePath, contained: true },
|
|
201
|
-
{
|
|
202
|
-
workflow: this.workflowService,
|
|
203
|
-
workspaceId,
|
|
204
|
-
branch: this.defaultBranch,
|
|
205
|
-
user: actor,
|
|
206
|
-
// Defence in depth: this surface already validates candidates before
|
|
207
|
-
// writing, but the same pre-disk gate the editor/agent use guarantees a
|
|
208
|
-
// malformed roles.yaml can never land through here either.
|
|
209
|
-
validateWrite: makeRolesYamlWriteValidator(this.kbDirName),
|
|
210
|
-
},
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
225
|
/**
|
|
215
226
|
* Fail fast with a friendly 409 if roles.yaml is already locked by someone
|
|
216
|
-
* else. The actual lock is taken by the
|
|
217
|
-
*
|
|
218
|
-
*
|
|
227
|
+
* else. The actual lock is taken by the write that follows; this only
|
|
228
|
+
* preserves the explicit "being edited by X" contract (the raw contention
|
|
229
|
+
* error is status-less and would surface as 500).
|
|
219
230
|
*/
|
|
220
231
|
private async assertRolesUnlocked(workspaceId: string, actor: AuthUser): Promise<void> {
|
|
221
232
|
const held = await this.workflowService.getLock(
|
|
@@ -231,33 +242,8 @@ export class RolesAdminService {
|
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
|
|
234
|
-
/**
|
|
235
|
-
* Run a lock-aware filesystem write, mapping its status-less contention error
|
|
236
|
-
* ("Skipped editing … — locked by …") to the friendly 409. {@link
|
|
237
|
-
* assertRolesUnlocked} pre-checks the common case (and names the holder), but
|
|
238
|
-
* it's a TOCTOU check — another admin can grab the lock between it and the
|
|
239
|
-
* actual acquire inside the write. This catches that race so contention always
|
|
240
|
-
* surfaces as a 409, never an unhandled 500.
|
|
241
|
-
*/
|
|
242
|
-
private async mapLockContention<T>(write: () => Promise<T>): Promise<T> {
|
|
243
|
-
try {
|
|
244
|
-
return await write();
|
|
245
|
-
} catch (err) {
|
|
246
|
-
if (err instanceof Error && /locked by /.test(err.message)) {
|
|
247
|
-
throw new RolesAdminError('Roles are being edited by another admin. Try again in a moment.', 409);
|
|
248
|
-
}
|
|
249
|
-
throw err;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
245
|
private async readRolesYaml(workspaceId: string): Promise<string> {
|
|
254
|
-
|
|
255
|
-
return await this.workspaceService.readFile(workspaceId, path.posix.join(this.kbDirName, ROLES_YAML));
|
|
256
|
-
} catch (err) {
|
|
257
|
-
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
258
|
-
if (code === 'ENOENT' || code === 'ENOTDIR') return '';
|
|
259
|
-
throw err;
|
|
260
|
-
}
|
|
246
|
+
return (await this.locked.readKbFile(workspaceId, ROLES_YAML)) ?? '';
|
|
261
247
|
}
|
|
262
248
|
|
|
263
249
|
// ---- Read -------------------------------------------------------------
|
|
@@ -267,20 +253,37 @@ export class RolesAdminService {
|
|
|
267
253
|
const workspaceId = await this.ensureWorkspace();
|
|
268
254
|
const text = await this.readRolesYaml(workspaceId);
|
|
269
255
|
const model = parseRolesModel(text);
|
|
270
|
-
// ONE sound scan of every
|
|
271
|
-
// frontmatter)
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
256
|
+
// ONE sound scan of every candidate file (folder access.md + node
|
|
257
|
+
// frontmatter) — cached in the shared scanner, so mutations returning the
|
|
258
|
+
// fresh roster don't rerun the full-KB sweep — indexed by canonical entry
|
|
259
|
+
// token. Attribution per spelling: a `role/<name>` hit is ALWAYS the
|
|
260
|
+
// role's; a BARE hit is the role's only when no group shadows the name —
|
|
261
|
+
// bare tokens resolve group-first, so a shadowed bare grant belongs to the
|
|
262
|
+
// GROUP (and the groups roster attributes it there — the mirror image).
|
|
263
|
+
const [referencesByToken, activeGroups] = await Promise.all([
|
|
264
|
+
this.references.scan(workspaceId),
|
|
265
|
+
loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f)),
|
|
266
|
+
]);
|
|
267
|
+
const groupOwnedBareNames = new Set(activeGroups.groups.keys());
|
|
275
268
|
const out: RoleRosterEntry[] = [];
|
|
276
269
|
for (const role of model) {
|
|
277
270
|
const canonical = canonicalRoleName(role.displayName);
|
|
271
|
+
const registryEntry = capabilityRoleFor(role.displayName);
|
|
278
272
|
out.push({
|
|
279
273
|
canonical,
|
|
280
274
|
displayName: role.displayName,
|
|
281
|
-
members: role.members,
|
|
275
|
+
members: role.members.filter((m) => !isGroupRefMember(m)),
|
|
276
|
+
groups: role.members
|
|
277
|
+
.filter(isGroupRefMember)
|
|
278
|
+
.map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
|
|
282
279
|
isAdmin: canonical === ADMIN_CANONICAL,
|
|
283
|
-
|
|
280
|
+
capability: registryEntry
|
|
281
|
+
? { description: registryEntry.description, groupAssignable: registryEntry.groupAssignable }
|
|
282
|
+
: null,
|
|
283
|
+
referencedBy: [
|
|
284
|
+
...(groupOwnedBareNames.has(canonical) ? [] : referencesByToken.get(canonical) ?? []),
|
|
285
|
+
...(referencesByToken.get(`${ROLE_TOKEN_PREFIX}${canonical}`) ?? []),
|
|
286
|
+
],
|
|
284
287
|
});
|
|
285
288
|
}
|
|
286
289
|
return out;
|
|
@@ -350,10 +353,13 @@ export class RolesAdminService {
|
|
|
350
353
|
}
|
|
351
354
|
|
|
352
355
|
if (this.recoveryAdmins.length === 0) {
|
|
356
|
+
// 409, not 500: this is a CONFIGURATION refusal in a break-glass flow —
|
|
357
|
+
// the operator needs the actionable message, and the route helper hides
|
|
358
|
+
// every >=500 body behind a generic "Internal error." on purpose.
|
|
353
359
|
throw new RolesAdminError(
|
|
354
360
|
'Recovery needs a configured admin (ADMIN_EMAIL) to restore — a roles.yaml ' +
|
|
355
361
|
'with no Admin is exactly the unusable state recovery exists to escape.',
|
|
356
|
-
|
|
362
|
+
409,
|
|
357
363
|
{ kind: 'no-recovery-admins' },
|
|
358
364
|
);
|
|
359
365
|
}
|
|
@@ -362,8 +368,8 @@ export class RolesAdminService {
|
|
|
362
368
|
|
|
363
369
|
// Back up the corrupted bytes and restore the good default atomically. The
|
|
364
370
|
// default is valid, so the resolver loads immediately after this commit.
|
|
365
|
-
const fsys = await this.lockingFsForActor(workspaceId, actor);
|
|
366
|
-
await this.mapLockContention(() =>
|
|
371
|
+
const fsys = await this.locked.lockingFsForActor(workspaceId, actor);
|
|
372
|
+
await this.locked.mapLockContention(() =>
|
|
367
373
|
fsys.writeFiles(
|
|
368
374
|
[
|
|
369
375
|
{ path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
|
|
@@ -378,19 +384,11 @@ export class RolesAdminService {
|
|
|
378
384
|
}
|
|
379
385
|
|
|
380
386
|
// ---- Mutations --------------------------------------------------------
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
async deleteRole(actor: AuthUser, canonical: string): Promise<RoleRosterEntry[]> {
|
|
388
|
-
if (canonical === ADMIN_CANONICAL) {
|
|
389
|
-
throw new RolesAdminError('The Admin role cannot be deleted', 422);
|
|
390
|
-
}
|
|
391
|
-
await this.runEdit(actor, (text) => editDeleteRole(text, canonical));
|
|
392
|
-
return this.getRoster();
|
|
393
|
-
}
|
|
387
|
+
//
|
|
388
|
+
// MEMBERSHIP ONLY. There is deliberately no createRole / renameRole /
|
|
389
|
+
// deleteRole: roles are app-defined capabilities (see capability-registry),
|
|
390
|
+
// so the admin surface cannot mint, rebrand, or retire one. Legacy roles
|
|
391
|
+
// migrate out via convertRoleToGroup.
|
|
394
392
|
|
|
395
393
|
async addMember(actor: AuthUser, canonical: string, email: string): Promise<RoleRosterEntry[]> {
|
|
396
394
|
await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
|
|
@@ -398,9 +396,12 @@ export class RolesAdminService {
|
|
|
398
396
|
}
|
|
399
397
|
|
|
400
398
|
/**
|
|
401
|
-
* Remove a member. Refuses to
|
|
402
|
-
*
|
|
403
|
-
*
|
|
399
|
+
* Remove a member. Refuses to remove the Admin role's LAST DIRECT EMAIL
|
|
400
|
+
* (422) — the kept invariant: whatever groups Admin references, at least
|
|
401
|
+
* one directory-independent email member must remain, or a broken IdP
|
|
402
|
+
* connection becomes an admin lockout. Removing the caller's OWN last
|
|
403
|
+
* Admin membership requires `confirm` (409 otherwise) — no silent
|
|
404
|
+
* self-lockout, since admin status is read live from this file.
|
|
404
405
|
*/
|
|
405
406
|
async removeMember(
|
|
406
407
|
actor: AuthUser,
|
|
@@ -412,11 +413,15 @@ export class RolesAdminService {
|
|
|
412
413
|
const target = canonicalEmail(email);
|
|
413
414
|
if (canonical === ADMIN_CANONICAL) {
|
|
414
415
|
const admin = parseRolesModel(text).find((r) => canonicalRoleName(r.displayName) === ADMIN_CANONICAL);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
416
|
+
// The invariant counts DIRECT emails only — group refs don't rescue.
|
|
417
|
+
const directMembers = (admin?.members ?? []).filter((m) => !isGroupRefMember(m));
|
|
418
|
+
if (directMembers.includes(target) && directMembers.length <= 1) {
|
|
419
|
+
throw new RolesAdminError(
|
|
420
|
+
'The Admin role must keep at least one direct email member — group references alone are not enough.',
|
|
421
|
+
422,
|
|
422
|
+
);
|
|
418
423
|
}
|
|
419
|
-
if (target === canonicalEmail(actor.email) &&
|
|
424
|
+
if (target === canonicalEmail(actor.email) && directMembers.includes(target) && !confirm) {
|
|
420
425
|
throw new RolesAdminError('You are about to remove your own admin access', 409, {
|
|
421
426
|
kind: 'self-admin-removal',
|
|
422
427
|
});
|
|
@@ -428,68 +433,149 @@ export class RolesAdminService {
|
|
|
428
433
|
}
|
|
429
434
|
|
|
430
435
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
436
|
+
* Assign a role to a group — Admin included (the parse-time invariant keeps
|
|
437
|
+
* at least one direct email on Admin, so a group ref can never be its only
|
|
438
|
+
* membership).
|
|
439
|
+
*
|
|
440
|
+
* The ref must name a group the ACTIVE source knows: mergeGroupsIntoRoles
|
|
441
|
+
* ignores an unknown ref with only a log warning, so a typo here would be
|
|
442
|
+
* accepted and then silently grant the role to nobody. The check is
|
|
443
|
+
* BEST-EFFORT validation, not a race-free guarantee: the manual group
|
|
444
|
+
* file's lock is held across validate + write (so a concurrent groups-page
|
|
445
|
+
* deletion/rename can't invalidate the ref mid-flight), but the IdP sync
|
|
446
|
+
* writer commits through its own lock — a provisioning push can retire the
|
|
447
|
+
* group between this check and the next sync. That is fine by design: a
|
|
448
|
+
* dangling ref resolves to nothing, with a resolver warning naming it.
|
|
437
449
|
*/
|
|
438
|
-
async
|
|
439
|
-
const newCanonical = canonicalRoleName(newDisplayName);
|
|
440
|
-
if (canonical === ADMIN_CANONICAL && newCanonical !== ADMIN_CANONICAL) {
|
|
441
|
-
throw new RolesAdminError('The Admin role cannot be renamed to a different name', 400);
|
|
442
|
-
}
|
|
443
|
-
|
|
450
|
+
async assignGroup(actor: AuthUser, canonical: string, groupName: string): Promise<RoleRosterEntry[]> {
|
|
444
451
|
const workspaceId = await this.ensureWorkspace();
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
452
|
+
await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], async () => {
|
|
453
|
+
const { groups } = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
|
|
454
|
+
if (!groups.has(canonicalRoleName(groupName))) {
|
|
455
|
+
throw new RolesAdminError(`No group named "${groupName.trim()}". Pick an existing group.`, 404, {
|
|
456
|
+
kind: 'unknown-group',
|
|
457
|
+
group: groupName.trim(),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
await this.runEdit(actor, (text) => editAddGroupRef(text, canonical, groupName));
|
|
461
|
+
});
|
|
462
|
+
return this.getRoster();
|
|
463
|
+
}
|
|
454
464
|
|
|
455
|
-
|
|
456
|
-
|
|
465
|
+
async unassignGroup(actor: AuthUser, canonical: string, groupName: string): Promise<RoleRosterEntry[]> {
|
|
466
|
+
await this.runEdit(actor, (text) => editRemoveGroupRef(text, canonical, groupName));
|
|
467
|
+
return this.getRoster();
|
|
468
|
+
}
|
|
457
469
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Convert a LEGACY people-set role into a manual group — the migration the
|
|
472
|
+
* roles/groups split defines: "Product" was never a capability, it was a
|
|
473
|
+
* team. Atomic two-file move (roles.yaml loses the role, groups.yaml gains
|
|
474
|
+
* the group with the same members) in ONE commit; grant references keep
|
|
475
|
+
* working untouched because the NAME does not change.
|
|
476
|
+
*
|
|
477
|
+
* Refusals: capability roles (they ARE roles — Admin included), roles with
|
|
478
|
+
* group assignments (unwind those first: a group containing a group is
|
|
479
|
+
* nesting), IdP mode (groups are managed in the identity provider — this
|
|
480
|
+
* would write a retired file), and a groups.yaml name collision.
|
|
481
|
+
*/
|
|
482
|
+
async convertRoleToGroup(actor: AuthUser, canonical: string): Promise<RoleRosterEntry[]> {
|
|
483
|
+
const workspaceId = await this.ensureWorkspace();
|
|
484
|
+
if (!isLegacyPeopleSetRole(canonical)) {
|
|
485
|
+
throw new RolesAdminError(`'${canonical}' is a capability role — it cannot become a group`, 422);
|
|
462
486
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
writes.push(...refWrites);
|
|
487
|
+
if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
|
|
488
|
+
throw new RolesAdminError(
|
|
489
|
+
'Groups are synced from your identity provider — recreate this team there instead.',
|
|
490
|
+
409,
|
|
491
|
+
{ kind: 'idp-mode' },
|
|
492
|
+
);
|
|
470
493
|
}
|
|
494
|
+
await this.assertRolesUnlocked(workspaceId, actor);
|
|
471
495
|
|
|
472
|
-
|
|
496
|
+
// Hold ALL THREE file locks across read → build → write: candidates are
|
|
497
|
+
// built from a snapshot, and without the hold another admin's edit could
|
|
498
|
+
// land in between and be silently overwritten. The synced file's lock is
|
|
499
|
+
// held too — the directory-sync materializer commits synced-groups.yaml
|
|
500
|
+
// through that same lock, so holding it serializes this conversion with a
|
|
501
|
+
// provisioning push and makes the IdP-mode recheck below race-free. It is
|
|
502
|
+
// a COORDINATION hold (never a write path): synced-groups.yaml is
|
|
503
|
+
// machine-owned — only the sync bot passes its write rule, so a
|
|
504
|
+
// write-intent acquire would refuse every human actor at the gate. The
|
|
505
|
+
// conversion only READS the file; coordination gives it the mutex without
|
|
506
|
+
// claiming write authority.
|
|
507
|
+
await this.locked.withFileLocks(
|
|
508
|
+
workspaceId,
|
|
509
|
+
actor,
|
|
510
|
+
[GROUPS_YAML, ROLES_YAML],
|
|
511
|
+
async () => {
|
|
512
|
+
// Re-check UNDER the synced file's lock: the pre-lock check above is a
|
|
513
|
+
// fast-path courtesy, but directory sync could materialize
|
|
514
|
+
// synced-groups.yaml between it and this point — and committing a new
|
|
515
|
+
// groups.yaml group in IdP mode writes a retired file.
|
|
516
|
+
if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
|
|
517
|
+
throw new RolesAdminError(
|
|
518
|
+
'Groups are synced from your identity provider — recreate this team there instead.',
|
|
519
|
+
409,
|
|
520
|
+
{ kind: 'idp-mode' },
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const rolesText = await this.readRolesYaml(workspaceId);
|
|
524
|
+
const role = parseRolesModel(rolesText).find((r) => canonicalRoleName(r.displayName) === canonical);
|
|
525
|
+
if (!role) throw new RolesAdminError(`role not found: ${canonical}`, 404);
|
|
526
|
+
const groupRefs = role.members.filter(isGroupRefMember);
|
|
527
|
+
if (groupRefs.length > 0) {
|
|
528
|
+
throw new RolesAdminError(
|
|
529
|
+
'This role is assigned to groups — remove those assignments before converting it.',
|
|
530
|
+
422,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
473
533
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
534
|
+
// Build both candidates BEFORE any write, and validate both.
|
|
535
|
+
const rolesEdit = this.guardEdit(() => editDeleteRole(rolesText, canonical));
|
|
536
|
+
this.assertLoadable(rolesEdit.text);
|
|
537
|
+
makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
|
|
538
|
+
// Keep absent (null) distinct from existing-but-empty ('') — rollback
|
|
539
|
+
// must DELETE a groups.yaml it created, not truncate one already there.
|
|
540
|
+
// ONE parse + ONE emit: the group candidate is built on the model
|
|
541
|
+
// directly (name-safety + reserved + duplicate checks inline), not via
|
|
542
|
+
// per-member editor round-trips that each re-parse the whole file.
|
|
543
|
+
const groupsOriginal = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
|
|
544
|
+
let groupsCandidate: string;
|
|
545
|
+
try {
|
|
546
|
+
assertSafeGroupDisplayName(role.displayName);
|
|
547
|
+
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
548
|
+
throw new GroupsEditError(`'${role.displayName}' is a reserved name and cannot be a group`);
|
|
549
|
+
}
|
|
550
|
+
const groupsModel = parseGroupsModel(groupsOriginal ?? '');
|
|
551
|
+
if (groupsModel.some((g) => canonicalRoleName(g.displayName) === canonical)) {
|
|
552
|
+
throw new GroupsEditError(`a group named '${role.displayName}' already exists`);
|
|
553
|
+
}
|
|
554
|
+
groupsModel.push({ displayName: role.displayName, members: [...role.members] });
|
|
555
|
+
groupsCandidate = emitGroupsModel(groupsModel);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (err instanceof GroupsEditError) throw new RolesAdminError(err.message, err.status);
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
const groupsValid = validateGroupsFile(groupsCandidate, GROUPS_YAML);
|
|
561
|
+
if (!groupsValid.ok) {
|
|
562
|
+
throw new RolesAdminError(`groups.yaml would be invalid: ${groupsValid.errors.join('; ')}`, 422);
|
|
563
|
+
}
|
|
478
564
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
565
|
+
await this.locked.writeAndCommitLocked(
|
|
566
|
+
workspaceId,
|
|
567
|
+
actor,
|
|
568
|
+
[
|
|
569
|
+
{ repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText },
|
|
570
|
+
{ repoRel: GROUPS_YAML, content: groupsCandidate, original: groupsOriginal },
|
|
571
|
+
],
|
|
572
|
+
`Convert role ${role.displayName} to a group`,
|
|
573
|
+
);
|
|
574
|
+
},
|
|
575
|
+
{ coordinationFiles: [SYNCED_GROUPS_YAML] },
|
|
490
576
|
);
|
|
491
577
|
this.accessControl.invalidate(workspaceId);
|
|
492
|
-
this.emitWrites(workspaceId, actor,
|
|
578
|
+
this.emitWrites(workspaceId, actor, [ROLES_YAML, GROUPS_YAML]);
|
|
493
579
|
return this.getRoster();
|
|
494
580
|
}
|
|
495
581
|
|
|
@@ -513,15 +599,11 @@ export class RolesAdminService {
|
|
|
513
599
|
}
|
|
514
600
|
|
|
515
601
|
/**
|
|
516
|
-
* Single-file roles.yaml mutation
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* to `actor`), the same pipeline the human editor and agent use. So unlike the
|
|
522
|
-
* rename's synchronous atomic commit, the git commit here lands on release
|
|
523
|
-
* (a beat after the write); the HTTP response reads the working tree, which is
|
|
524
|
-
* already on disk, so the returned roster is correct.
|
|
602
|
+
* Single-file roles.yaml mutation under the shared file-lock/commit helper.
|
|
603
|
+
* `pre` produces the candidate (and may throw RolesAdminError for invariant
|
|
604
|
+
* violations before any write). Skips on a no-op. Every candidate passes
|
|
605
|
+
* the resolver's own parser (assertLoadable) plus the pre-disk validator
|
|
606
|
+
* before a byte lands.
|
|
525
607
|
*/
|
|
526
608
|
private async runEdit(
|
|
527
609
|
actor: AuthUser,
|
|
@@ -529,242 +611,32 @@ export class RolesAdminService {
|
|
|
529
611
|
): Promise<void> {
|
|
530
612
|
const workspaceId = await this.ensureWorkspace();
|
|
531
613
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
614
|
+
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
|
|
615
|
+
this.runEditLocked(workspaceId, actor, pre),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async runEditLocked(
|
|
620
|
+
workspaceId: string,
|
|
621
|
+
actor: AuthUser,
|
|
622
|
+
pre: (currentText: string) => EditResult,
|
|
623
|
+
): Promise<void> {
|
|
532
624
|
const text = await this.readRolesYaml(workspaceId);
|
|
533
625
|
const result = this.guardEdit(() => pre(text));
|
|
534
626
|
if (!result.changed) return;
|
|
535
627
|
this.assertLoadable(result.text);
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
628
|
+
// The lock is ALREADY OURS (withFileLocks; strict same-user acquire), so
|
|
629
|
+
// LockingFilesystem would contend against our own hold. Apply the same
|
|
630
|
+
// pre-disk validator it would have run, then plain-write + a path-scoped
|
|
631
|
+
// atomic commit.
|
|
632
|
+
makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, result.text);
|
|
633
|
+
await this.locked.writeAndCommitLocked(
|
|
634
|
+
workspaceId,
|
|
635
|
+
actor,
|
|
636
|
+
[{ repoRel: ROLES_YAML, content: result.text, original: text }],
|
|
637
|
+
`Update ${ROLES_YAML}`,
|
|
638
|
+
);
|
|
544
639
|
this.accessControl.invalidate(workspaceId);
|
|
545
640
|
this.emitWrites(workspaceId, actor, [ROLES_YAML]);
|
|
546
641
|
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Find every genuine role reference to `oldCanonical` across folder access.md
|
|
550
|
-
* AND node frontmatter, and rewrite each to `newDisplayName`. Reference-aware:
|
|
551
|
-
* a line is rewritten ONLY if it PARSES as a role entry whose canonical name
|
|
552
|
-
* == oldCanonical. Prose, comments, `Name <email>` user entries, other keys,
|
|
553
|
-
* and substrings never match. Returns the repo-relative writes to commit.
|
|
554
|
-
*/
|
|
555
|
-
private async rewriteRoleReferences(
|
|
556
|
-
workspaceId: string,
|
|
557
|
-
repoDir: string,
|
|
558
|
-
oldCanonical: string,
|
|
559
|
-
newDisplayName: string,
|
|
560
|
-
): Promise<{ repoRelativePath: string; content: string }[]> {
|
|
561
|
-
const writes: { repoRelativePath: string; content: string }[] = [];
|
|
562
|
-
const candidates = await this.collectCandidateFiles(workspaceId);
|
|
563
|
-
for (const repoRel of candidates) {
|
|
564
|
-
const abs = path.join(repoDir, repoRel);
|
|
565
|
-
let text: string;
|
|
566
|
-
try {
|
|
567
|
-
text = await fs.readFile(abs, 'utf-8');
|
|
568
|
-
} catch (err) {
|
|
569
|
-
// Fail closed: a candidate we cannot read might reference the
|
|
570
|
-
// old role. Skipping it would commit a partial rewrite (a half-renamed
|
|
571
|
-
// role pointing at the old name) and silently drop access. Abort the
|
|
572
|
-
// whole atomic rename instead so the admin can retry cleanly.
|
|
573
|
-
throw new RolesAdminError(
|
|
574
|
-
`Cannot read ${repoRel} while rewriting role references; rename aborted with no changes`,
|
|
575
|
-
422,
|
|
576
|
-
{ cause: (err as Error)?.message },
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
const rewritten = rewriteRoleTokensInText(text, oldCanonical, newDisplayName);
|
|
580
|
-
if (rewritten !== text) {
|
|
581
|
-
// REPO-relative (bare repoRel) for commitChanges — repoDir already points
|
|
582
|
-
// at <workspaceDir>/<kbDirName>.
|
|
583
|
-
writes.push({ repoRelativePath: repoRel, content: rewritten });
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return writes;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Repo-relative paths of every file that could carry a role reference: all
|
|
591
|
-
* `access.md` files plus every `.md` node (its own frontmatter). Sourced from
|
|
592
|
-
* the workspace file tree (which already skips `.git` and honours
|
|
593
|
-
* `.bevelignore`), filtered to `.md` under the KB dir and returned bare
|
|
594
|
-
* repo-relative. (`roles.yaml` isn't `.md`, so it's excluded; the rename
|
|
595
|
-
* commits it separately.) Under save=share the working tree matches the
|
|
596
|
-
* committed set, so this is the same candidate list git-tracking would give.
|
|
597
|
-
*/
|
|
598
|
-
private async collectCandidateFiles(workspaceId: string): Promise<string[]> {
|
|
599
|
-
const tree = await this.workspaceService.listFiles(workspaceId);
|
|
600
|
-
const prefix = `${this.kbDirName}/`;
|
|
601
|
-
const out: string[] = [];
|
|
602
|
-
const visit = (node: FileTreeEntry): void => {
|
|
603
|
-
if (node.type === 'file') {
|
|
604
|
-
if (node.relativePath.startsWith(prefix) && node.relativePath.endsWith('.md')) {
|
|
605
|
-
out.push(node.relativePath.slice(prefix.length));
|
|
606
|
-
}
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
for (const child of node.children ?? []) visit(child);
|
|
610
|
-
};
|
|
611
|
-
visit(tree);
|
|
612
|
-
return out;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Sound scan of EVERY `.md` (folder `access.md` + node frontmatter)
|
|
617
|
-
* for genuine role references, indexed by canonical role name. Shares the
|
|
618
|
-
* candidate set and the config-region role-entry parse with the rename
|
|
619
|
-
* rewrite (`findRoleRefsInText` / `rewriteRoleTokensInText`), so the delete
|
|
620
|
-
* warning and the rename rewrite see the SAME references — the warning can no
|
|
621
|
-
* longer undercount frontmatter the rename would touch. A file we cannot read
|
|
622
|
-
* is skipped (this is an advisory read, not the atomic write path): missing a
|
|
623
|
-
* reference here only weakens the warning, it cannot drop access.
|
|
624
|
-
*/
|
|
625
|
-
private async scanRoleReferences(
|
|
626
|
-
workspaceId: string,
|
|
627
|
-
): Promise<Map<string, { path: string; verb: string }[]>> {
|
|
628
|
-
const repoDir = await this.repoDir(workspaceId);
|
|
629
|
-
const candidates = await this.collectCandidateFiles(workspaceId);
|
|
630
|
-
const byRole = new Map<string, { path: string; verb: string }[]>();
|
|
631
|
-
for (const repoRel of candidates) {
|
|
632
|
-
let text: string;
|
|
633
|
-
try {
|
|
634
|
-
text = await fs.readFile(path.join(repoDir, repoRel), 'utf-8');
|
|
635
|
-
} catch {
|
|
636
|
-
continue;
|
|
637
|
-
}
|
|
638
|
-
for (const ref of findRoleRefsInText(text)) {
|
|
639
|
-
const list = byRole.get(ref.role);
|
|
640
|
-
if (list) list.push({ path: repoRel, verb: ref.verb });
|
|
641
|
-
else byRole.set(ref.role, [{ path: repoRel, verb: ref.verb }]);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return byRole;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Resolve the [start, end) line range that role rewrites may touch — the YAML
|
|
650
|
-
* config region only, NEVER the markdown body (CodeRabbit: a body line like
|
|
651
|
-
* `- Sales` or `owner: Sales` must not be rewritten).
|
|
652
|
-
*
|
|
653
|
-
* - A file with leading `---` frontmatter (folder `access.md`, node `.md`):
|
|
654
|
-
* only the lines BETWEEN the opening and closing `---` are eligible.
|
|
655
|
-
* - A fence-less file (e.g. a bare `roles.yaml`-style access config with no
|
|
656
|
-
* `---`): the whole file is config, so all lines are eligible.
|
|
657
|
-
* - A `.md` file with no frontmatter fence: no config region → empty range,
|
|
658
|
-
* nothing is rewritten.
|
|
659
|
-
*/
|
|
660
|
-
function configLineRange(lines: string[], isMarkdown: boolean): { start: number; end: number } {
|
|
661
|
-
if (lines.length > 0 && lines[0].trim() === '---') {
|
|
662
|
-
for (let i = 1; i < lines.length; i++) {
|
|
663
|
-
if (lines[i].trim() === '---') return { start: 1, end: i };
|
|
664
|
-
}
|
|
665
|
-
// Unterminated frontmatter — treat nothing as eligible (don't risk the body).
|
|
666
|
-
return { start: 0, end: 0 };
|
|
667
|
-
}
|
|
668
|
-
// No fence: a markdown file has no config region; a non-markdown access file
|
|
669
|
-
// (no body) is entirely config.
|
|
670
|
-
return isMarkdown ? { start: 0, end: 0 } : { start: 0, end: lines.length };
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** A known access verb key: heads a block list or holds a scalar role value. */
|
|
674
|
-
const VERB_KEY_RE = /^(\s*)(read|write|download|owner)(:\s*)(.*)$/;
|
|
675
|
-
/** A block-list item: ` - <token>` (token may carry a leading `deny `). */
|
|
676
|
-
const LIST_ITEM_RE = /^(\s*-\s+)(.*)$/;
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Walk the CONFIG-REGION lines of `text`, invoking `onRoleRef` for every line
|
|
680
|
-
* that PARSES as a genuine role entry — both the block-list form (`- <token>`
|
|
681
|
-
* under a `read:`/`write:`/… key) and the inline scalar form (`owner: <token>`).
|
|
682
|
-
* `verb` is the access verb the reference sits under; for a block list it is the
|
|
683
|
-
* nearest enclosing verb key (lines before any verb key, or under an unknown
|
|
684
|
-
* key, are skipped). This is the SINGLE source of truth for "what is a role
|
|
685
|
-
* reference" — both the delete-warning scan and the rename rewrite drive off it,
|
|
686
|
-
* so they cannot disagree. The callback may mutate `lines[i]` (the rewrite does;
|
|
687
|
-
* the scan does not). User entries, other keys, comments and substrings never
|
|
688
|
-
* fire it.
|
|
689
|
-
*/
|
|
690
|
-
function walkRoleRefs(
|
|
691
|
-
lines: string[],
|
|
692
|
-
start: number,
|
|
693
|
-
end: number,
|
|
694
|
-
onRoleRef: (ctx: { i: number; verb: string; entry: { role: string; deny: boolean }; indent: string; prefix: string }) => void,
|
|
695
|
-
): void {
|
|
696
|
-
let currentVerb: string | null = null;
|
|
697
|
-
/** A role-entry value → its parsed role entry, else null (user/empty/other). */
|
|
698
|
-
const roleEntry = (rawValue: string): { role: string; deny: boolean } | null => {
|
|
699
|
-
const parsed = parseAccessEntry(rawValue.replace(/\s+$/, ''));
|
|
700
|
-
return parsed.ok && parsed.entry.kind === 'role'
|
|
701
|
-
? { role: parsed.entry.role, deny: parsed.entry.deny }
|
|
702
|
-
: null;
|
|
703
|
-
};
|
|
704
|
-
for (let i = start; i < end; i++) {
|
|
705
|
-
const line = lines[i];
|
|
706
|
-
// A verb key resets the block context. Its inline value (scalar form,
|
|
707
|
-
// `owner: Sales`) is itself a candidate reference under that same verb.
|
|
708
|
-
const kvM = line.match(VERB_KEY_RE);
|
|
709
|
-
if (kvM) {
|
|
710
|
-
currentVerb = kvM[2];
|
|
711
|
-
const inlineValue = kvM[4];
|
|
712
|
-
if (inlineValue.trim() !== '') {
|
|
713
|
-
const entry = roleEntry(inlineValue);
|
|
714
|
-
if (entry) onRoleRef({ i, verb: currentVerb, entry, indent: `${kvM[1]}${kvM[2]}${kvM[3]}`, prefix: '' });
|
|
715
|
-
}
|
|
716
|
-
continue;
|
|
717
|
-
}
|
|
718
|
-
// Block-list item — belongs to the nearest enclosing verb key. A list item
|
|
719
|
-
// with no verb in scope is not a resolvable access rule; skip it.
|
|
720
|
-
const listM = line.match(LIST_ITEM_RE);
|
|
721
|
-
if (listM && currentVerb !== null) {
|
|
722
|
-
const entry = roleEntry(listM[2]);
|
|
723
|
-
if (entry) onRoleRef({ i, verb: currentVerb, entry, indent: '', prefix: listM[1] });
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
// A non-empty, non-list, non-kv line ends the current block (e.g. a new
|
|
727
|
-
// top-level key whose value isn't a verb, or stray prose in config).
|
|
728
|
-
if (line.trim() !== '' && !listM) currentVerb = null;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Every genuine role reference in `text`'s config region, as {role, verb}. */
|
|
733
|
-
export function findRoleRefsInText(text: string, isMarkdown = true): { role: string; verb: string }[] {
|
|
734
|
-
const lines = text.split('\n');
|
|
735
|
-
const { start, end } = configLineRange(lines, isMarkdown);
|
|
736
|
-
const out: { role: string; verb: string }[] = [];
|
|
737
|
-
if (start >= end) return out;
|
|
738
|
-
walkRoleRefs(lines, start, end, ({ verb, entry }) => out.push({ role: entry.role, verb }));
|
|
739
|
-
return out;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* Rewrite every CONFIG-REGION line that PARSES as a role reference whose
|
|
744
|
-
* canonical name == `oldCanonical`, replacing the role token with
|
|
745
|
-
* `newDisplayName` (preserving any leading `deny ` and indentation). Only the
|
|
746
|
-
* frontmatter block of a markdown file is touched — the body is left
|
|
747
|
-
* byte-for-byte intact, so a prose line like `- Sales` is never corrupted.
|
|
748
|
-
* Lines that don't parse as a matching role entry (user entries, other keys,
|
|
749
|
-
* substrings) are also untouched. Exported for test.
|
|
750
|
-
*
|
|
751
|
-
* `isMarkdown` (default true) marks files that carry a markdown body below the
|
|
752
|
-
* frontmatter; pass false only for a pure-config file with no body.
|
|
753
|
-
*/
|
|
754
|
-
export function rewriteRoleTokensInText(
|
|
755
|
-
text: string,
|
|
756
|
-
oldCanonical: string,
|
|
757
|
-
newDisplayName: string,
|
|
758
|
-
isMarkdown = true,
|
|
759
|
-
): string {
|
|
760
|
-
const lines = text.split('\n');
|
|
761
|
-
const { start, end } = configLineRange(lines, isMarkdown);
|
|
762
|
-
if (start >= end) return text;
|
|
763
|
-
let changed = false;
|
|
764
|
-
walkRoleRefs(lines, start, end, ({ i, entry, indent, prefix }) => {
|
|
765
|
-
if (entry.role !== oldCanonical) return;
|
|
766
|
-
lines[i] = `${indent}${prefix}${entry.deny ? 'deny ' : ''}${newDisplayName}`;
|
|
767
|
-
changed = true;
|
|
768
|
-
});
|
|
769
|
-
return changed ? lines.join('\n') : text;
|
|
770
642
|
}
|