@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
|
@@ -4,6 +4,7 @@ import { accessMdPathForFolder } from './access-mutation.service.js';
|
|
|
4
4
|
import {
|
|
5
5
|
EVERYONE_CANONICAL,
|
|
6
6
|
KNOWN_VERBS,
|
|
7
|
+
hasAccessFrontmatterExtension,
|
|
7
8
|
parseAccessFile,
|
|
8
9
|
parseOwnAccessEntries,
|
|
9
10
|
type ParsedEntry,
|
|
@@ -81,9 +82,11 @@ export class AccessDeclarationsError extends WorkflowDomainError {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/** Node files whose own frontmatter may declare verbs
|
|
85
|
+
/** Node files whose own frontmatter may declare verbs — the resolver's shared
|
|
86
|
+
* extension set (`ACCESS_FRONTMATTER_EXTENSIONS`), which the reference
|
|
87
|
+
* scanner mirrors. */
|
|
85
88
|
function isNodeCandidate(name: string): boolean {
|
|
86
|
-
return
|
|
89
|
+
return hasAccessFrontmatterExtension(name);
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
/** Walk a workspace-relative path down the file tree; null when absent. */
|
|
@@ -36,20 +36,26 @@ import path from 'node:path';
|
|
|
36
36
|
|
|
37
37
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
38
38
|
import type { IAccessControl } from './access-control.interface.js';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
type Verb,
|
|
41
|
+
KNOWN_VERBS,
|
|
42
|
+
ROLE_TOKEN_PREFIX,
|
|
43
|
+
canonicalRoleName,
|
|
44
|
+
} from './access-control.service.js';
|
|
40
45
|
import {
|
|
41
46
|
spliceRevoke,
|
|
42
47
|
spliceGrant,
|
|
43
48
|
validatePrincipal,
|
|
44
49
|
AccessSpliceError,
|
|
45
50
|
type Principal,
|
|
51
|
+
type TokenMatch,
|
|
46
52
|
} from './access-splice.js';
|
|
47
53
|
import { WorkflowDomainError } from '../workflow/workflow.errors.js';
|
|
48
54
|
|
|
49
55
|
/** Whether the dialog target is a folder (edit folder access.md) or a file (edit node frontmatter). */
|
|
50
56
|
export type TargetKind = 'folder' | 'file';
|
|
51
57
|
|
|
52
|
-
/** Bad-request-class mutation failure (invalid principal, unknown
|
|
58
|
+
/** Bad-request-class mutation failure (invalid principal, unknown role/group, lockout). */
|
|
53
59
|
export class AccessMutationError extends WorkflowDomainError {
|
|
54
60
|
constructor(message: string, status = 400, payload?: Record<string, unknown>) {
|
|
55
61
|
super(message, status, payload);
|
|
@@ -121,6 +127,41 @@ export class AccessMutationService {
|
|
|
121
127
|
return path.posix.join(this.kbDirName, repoRelative);
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
/**
|
|
131
|
+
* How a REVOKE of `principal` matches file tokens — the shadowing rule:
|
|
132
|
+
*
|
|
133
|
+
* - When a GROUP owns the principal's bare name (group-first precedence),
|
|
134
|
+
* the two spellings are DIFFERENT principals: the bare token is the
|
|
135
|
+
* group's, `role/<name>` is the role's. Matching is `'exact'` so
|
|
136
|
+
* revoking the role never strips the group's bare grant (and revoking
|
|
137
|
+
* the group never strips the role's explicit grant).
|
|
138
|
+
* - Unshadowed, both spellings resolve to the ROLE (bare falls back to
|
|
139
|
+
* it), so matching is `'name'`: revoking the role also removes legacy
|
|
140
|
+
* bare spellings — the historical cleanup behavior.
|
|
141
|
+
*
|
|
142
|
+
* Judged against THIS workspace's merged principal index (`kbPrincipals` —
|
|
143
|
+
* the same model the resolver reads), so revoke agrees with resolution on
|
|
144
|
+
* who owns the bare key. A model that fails to load yields no groups, i.e.
|
|
145
|
+
* unshadowed — matching degrades to the pre-groups name-level behavior.
|
|
146
|
+
*
|
|
147
|
+
* GROUP revokes never take the name-level path: the route passes
|
|
148
|
+
* `tokenMatch: 'exact'` explicitly (see `revoke`'s `opts`), because this
|
|
149
|
+
* shadow probe cannot tell a group apart from a role once the group has
|
|
150
|
+
* VANISHED from the active source — the bare name then reads "unshadowed"
|
|
151
|
+
* and a name-level group revoke would strip a same-named role's live
|
|
152
|
+
* `role/<Name>` grant.
|
|
153
|
+
*/
|
|
154
|
+
private async revokeTokenMatch(workspaceId: string, principal: Principal): Promise<TokenMatch> {
|
|
155
|
+
if (principal.kind !== 'role') return 'exact'; // user matching ignores the mode
|
|
156
|
+
const canonical = canonicalRoleName(principal.role);
|
|
157
|
+
const bare = canonical.startsWith(ROLE_TOKEN_PREFIX)
|
|
158
|
+
? canonical.slice(ROLE_TOKEN_PREFIX.length)
|
|
159
|
+
: canonical;
|
|
160
|
+
const { groups } = await this.accessControl.kbPrincipals(workspaceId);
|
|
161
|
+
const shadowed = groups.some((g) => canonicalRoleName(g) === bare);
|
|
162
|
+
return shadowed ? 'exact' : 'name';
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
/**
|
|
125
166
|
* Grant `principal` `verb` on `target`. Adds the principal under exactly this
|
|
126
167
|
* verb and touches no other verb — verbs are independent, so a principal may
|
|
@@ -177,26 +218,38 @@ export class AccessMutationService {
|
|
|
177
218
|
kind: TargetKind,
|
|
178
219
|
repoRelTarget: string,
|
|
179
220
|
principal: Principal,
|
|
180
|
-
// Kept on the signature: the route supplies it
|
|
181
|
-
//
|
|
221
|
+
// Kept on the signature: the route supplies it, for attribution / future
|
|
222
|
+
// per-principal checks. Not consumed yet.
|
|
182
223
|
_actingUserEmail: string,
|
|
183
224
|
// When present, strip ONLY this verb (a per-checkbox toggle in the share UI);
|
|
184
225
|
// absent strips the principal from every verb (the whole-principal Remove).
|
|
185
226
|
verb?: Verb,
|
|
227
|
+
// `tokenMatch` overrides the shadow-derived matching. The route passes
|
|
228
|
+
// 'exact' for GROUP principals: a group's grant is its bare token only,
|
|
229
|
+
// and once the group has vanished the shadow probe can no longer tell it
|
|
230
|
+
// from a role — name-level matching would then strip a same-named role's
|
|
231
|
+
// `role/<Name>` grant.
|
|
232
|
+
opts?: { tokenMatch?: TokenMatch },
|
|
186
233
|
): Promise<{ changed: boolean; editPath: string }> {
|
|
187
|
-
void _actingUserEmail; // referenced to satisfy no-unused-vars until
|
|
234
|
+
void _actingUserEmail; // referenced to satisfy no-unused-vars until something consumes it
|
|
188
235
|
this.assertPrincipalSafe(principal); // same injection/shape guard grant runs
|
|
189
236
|
const { editPath } = this.fileToEdit(kind, repoRelTarget);
|
|
190
237
|
// Allow a missing target only for a folder (no access.md yet is normal); a
|
|
191
238
|
// missing FILE node is a bad target and should surface, not silently no-op
|
|
192
239
|
// — same rule grant() uses.
|
|
193
240
|
const original = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
|
|
241
|
+
// Alias-tolerant vs exact-token matching, decided by group shadowing —
|
|
242
|
+
// see revokeTokenMatch — unless the caller pinned it.
|
|
243
|
+
const tokenMatch = opts?.tokenMatch ?? (await this.revokeTokenMatch(workspaceId, principal));
|
|
194
244
|
let next = original;
|
|
195
245
|
let changed = false;
|
|
196
246
|
try {
|
|
197
247
|
const verbsToRevoke = verb ? [verb] : KNOWN_VERBS;
|
|
198
248
|
for (const v of verbsToRevoke) {
|
|
199
|
-
const r = spliceRevoke(next, v, principal, {
|
|
249
|
+
const r = spliceRevoke(next, v, principal, {
|
|
250
|
+
target: kind === 'folder' ? 'folder' : 'node',
|
|
251
|
+
tokenMatch,
|
|
252
|
+
});
|
|
200
253
|
next = r.text;
|
|
201
254
|
changed = changed || r.changed;
|
|
202
255
|
}
|
|
@@ -248,18 +301,26 @@ export class AccessMutationService {
|
|
|
248
301
|
repoRelTarget: string,
|
|
249
302
|
principal: Principal,
|
|
250
303
|
verb?: Verb,
|
|
304
|
+
// Same override as `revoke` — the route pins 'exact' for GROUP principals.
|
|
305
|
+
opts?: { tokenMatch?: TokenMatch },
|
|
251
306
|
): Promise<{ changed: boolean; editPath: string }> {
|
|
252
307
|
this.assertPrincipalSafe(principal);
|
|
253
308
|
const { editPath, allowScalar } = this.fileToEdit(kind, repoRelTarget);
|
|
254
309
|
const original = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
|
|
255
310
|
|
|
256
311
|
const verbsToDeny = verb ? [verb] : KNOWN_VERBS;
|
|
312
|
+
// Same shadowing-aware matching as revoke(): the strip must not swallow a
|
|
313
|
+
// same-named OTHER principal's grant (bare = group vs role/<name> = role).
|
|
314
|
+
const tokenMatch = opts?.tokenMatch ?? (await this.revokeTokenMatch(workspaceId, principal));
|
|
257
315
|
let next = original;
|
|
258
316
|
try {
|
|
259
317
|
for (const v of verbsToDeny) {
|
|
260
318
|
// (1) Strip any same-scope GRANT for this principal so grant-beats-deny
|
|
261
319
|
// can't silently swallow the deny we're about to add.
|
|
262
|
-
next = spliceRevoke(next, v, principal, {
|
|
320
|
+
next = spliceRevoke(next, v, principal, {
|
|
321
|
+
target: kind === 'folder' ? 'folder' : 'node',
|
|
322
|
+
tokenMatch,
|
|
323
|
+
}).text;
|
|
263
324
|
// (2) Add the deny under the same verb.
|
|
264
325
|
next = spliceGrant(next, v, principal, { allowScalar, deny: true, target: kind === 'folder' ? 'folder' : 'node' }).text;
|
|
265
326
|
}
|
|
@@ -277,13 +338,18 @@ export class AccessMutationService {
|
|
|
277
338
|
this.accessControl.invalidate(workspaceId);
|
|
278
339
|
|
|
279
340
|
// (3) Assert the deny actually removed effective access on the targeted
|
|
280
|
-
// verb(s) — else roll back.
|
|
341
|
+
// verb(s) — else roll back. The check evaluates the SAME token identity
|
|
342
|
+
// the deny above spliced (`tokenMatch` rides along): with a pinned exact
|
|
343
|
+
// GROUP deny whose group has vanished, alias-tolerant matching would read
|
|
344
|
+
// a same-named role's surviving `role/<Name>` grant as the group still
|
|
345
|
+
// having access — rolling back a fully effective deny as "ineffective".
|
|
281
346
|
const stillHas = await this.principalStillHasAccess(
|
|
282
347
|
workspaceId,
|
|
283
348
|
kind,
|
|
284
349
|
repoRelTarget,
|
|
285
350
|
principal,
|
|
286
351
|
verb,
|
|
352
|
+
tokenMatch,
|
|
287
353
|
);
|
|
288
354
|
if (stillHas) {
|
|
289
355
|
await this.workspaceService.writeFile(workspaceId, wsRelative, original);
|
|
@@ -305,7 +371,7 @@ export class AccessMutationService {
|
|
|
305
371
|
* access — not just "is there a removable file entry."
|
|
306
372
|
*
|
|
307
373
|
* For a USER, that distinction matters: effective access includes admin-rescue
|
|
308
|
-
* on `access.md`/`roles.yaml`,
|
|
374
|
+
* on `access.md`/`roles.yaml`, role/group membership, and the built-in `everyone` —
|
|
309
375
|
* none of which `grantSources` reports (it is MECE over file-backed
|
|
310
376
|
* direct/ancestor entries only). So we ask the same `canRead/canWrite/
|
|
311
377
|
* canDownload/canOwner` resolver every real access decision uses; if the
|
|
@@ -319,6 +385,10 @@ export class AccessMutationService {
|
|
|
319
385
|
* ever holds access by being NAMED in a file (no rescue / role-via-role /
|
|
320
386
|
* everyone indirection applies to a role token), so `grantSources` IS its
|
|
321
387
|
* complete effective-access answer (scoped to `verb` when given).
|
|
388
|
+
* `tokenMatch` keeps the check on the same token identity the preceding
|
|
389
|
+
* splice used: 'exact' asks `grantSources` to count only the literally
|
|
390
|
+
* spelled token — see the denyHere call site for why that matters when a
|
|
391
|
+
* same-named group and role diverge.
|
|
322
392
|
*/
|
|
323
393
|
private async principalStillHasAccess(
|
|
324
394
|
workspaceId: string,
|
|
@@ -326,6 +396,7 @@ export class AccessMutationService {
|
|
|
326
396
|
repoRelTarget: string,
|
|
327
397
|
principal: Principal,
|
|
328
398
|
verb?: Verb,
|
|
399
|
+
tokenMatch?: TokenMatch,
|
|
329
400
|
): Promise<boolean> {
|
|
330
401
|
if (principal.kind === 'user') {
|
|
331
402
|
const email = principal.email;
|
|
@@ -339,10 +410,13 @@ export class AccessMutationService {
|
|
|
339
410
|
const results = await Promise.all(verbs.map((v) => canOf[v]()));
|
|
340
411
|
return results.some(Boolean);
|
|
341
412
|
}
|
|
342
|
-
const sources = await this.accessControl.grantSources(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
413
|
+
const sources = await this.accessControl.grantSources(
|
|
414
|
+
workspaceId,
|
|
415
|
+
kind,
|
|
416
|
+
repoRelTarget,
|
|
417
|
+
{ kind: 'role', role: principal.role },
|
|
418
|
+
tokenMatch ? { tokenMatch } : undefined,
|
|
419
|
+
);
|
|
346
420
|
return verb ? sources[verb] !== undefined : Object.keys(sources).length > 0;
|
|
347
421
|
}
|
|
348
422
|
|
|
@@ -351,7 +425,7 @@ export class AccessMutationService {
|
|
|
351
425
|
* asynchronously in the route (it needs the roles model). Here we only run the
|
|
352
426
|
* cheap injection/shape validation so a malformed principal never reaches the
|
|
353
427
|
* splice. Role-exists-in-roles.yaml is the route's job (so it can offer
|
|
354
|
-
*
|
|
428
|
+
* an honest 404 for an unknown role/group).
|
|
355
429
|
*/
|
|
356
430
|
private assertPrincipalSafe(principal: Principal): void {
|
|
357
431
|
try {
|
|
@@ -122,7 +122,7 @@ export function validatePrincipal(p: Principal): Principal {
|
|
|
122
122
|
}
|
|
123
123
|
const role = p.role.trim();
|
|
124
124
|
const canonical = canonicalRoleName(role);
|
|
125
|
-
if (!canonical) throw new AccessSpliceError('
|
|
125
|
+
if (!canonical) throw new AccessSpliceError('role grant needs a name');
|
|
126
126
|
if (
|
|
127
127
|
CONTROL_CHARS.test(role) ||
|
|
128
128
|
role.includes('<') ||
|
|
@@ -130,24 +130,48 @@ export function validatePrincipal(p: Principal): Principal {
|
|
|
130
130
|
role.includes(':') ||
|
|
131
131
|
role.includes('#')
|
|
132
132
|
) {
|
|
133
|
-
throw new AccessSpliceError(`invalid
|
|
133
|
+
throw new AccessSpliceError(`invalid role name: ${JSON.stringify(p.role)}`);
|
|
134
134
|
}
|
|
135
135
|
// `deny` is never a grantee — it's the denial prefix. `everyone` IS grantable
|
|
136
136
|
// (the built-in public role); the access route restricts it to the read verb.
|
|
137
137
|
if (canonical === 'deny') {
|
|
138
|
-
throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a
|
|
138
|
+
throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a role`);
|
|
139
139
|
}
|
|
140
140
|
const round = parseAccessEntry(role);
|
|
141
141
|
if (!round.ok || round.entry.kind !== 'role' || round.entry.deny) {
|
|
142
|
-
throw new AccessSpliceError(`
|
|
142
|
+
throw new AccessSpliceError(`role grant does not round-trip: ${JSON.stringify(p)}`);
|
|
143
143
|
}
|
|
144
144
|
return { kind: 'role', role };
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* How a role-shaped principal's TOKEN is compared against a file entry:
|
|
149
|
+
*
|
|
150
|
+
* - `'exact'`: the entry must carry the SAME spelling — a bare token and a
|
|
151
|
+
* `role/<name>` token are DIFFERENT principals (bare = whoever owns the
|
|
152
|
+
* bare key under group-first precedence, `role/` = the role). Grants
|
|
153
|
+
* always use this: granting the group `X` while `role/X` is present (or
|
|
154
|
+
* vice versa) must NOT be treated as already-granted, or the other
|
|
155
|
+
* principal silently never receives the grant.
|
|
156
|
+
* - `'name'`: both spellings of the name match. Revokes use this ONLY when
|
|
157
|
+
* no group shadows the bare name — then both spellings resolve to the
|
|
158
|
+
* role, and revoking the role must also remove legacy bare spellings.
|
|
159
|
+
*/
|
|
160
|
+
export type TokenMatch = 'exact' | 'name';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True when a parsed entry names the given principal (ignoring deny prefix).
|
|
164
|
+
* `tokenMatch` (role-shaped principals only) selects exact-spelling vs
|
|
165
|
+
* name-level matching — see {@link TokenMatch}.
|
|
166
|
+
*/
|
|
167
|
+
function entryMatches(entry: ParsedEntry, p: Principal, tokenMatch: TokenMatch): boolean {
|
|
149
168
|
if (p.kind === 'user') return entry.kind === 'user' && entry.email === canonicalEmail(p.email);
|
|
150
|
-
|
|
169
|
+
if (entry.kind !== 'role') return false;
|
|
170
|
+
const canonicalPrincipal = canonicalRoleName(p.role);
|
|
171
|
+
if (tokenMatch === 'exact') return entry.role === canonicalPrincipal;
|
|
172
|
+
const strip = (token: string): string =>
|
|
173
|
+
token.startsWith('role/') ? canonicalRoleName(token.slice('role/'.length)) : token;
|
|
174
|
+
return strip(entry.role) === strip(canonicalPrincipal);
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
// ---------------------------------------------------------------------------
|
|
@@ -325,10 +349,15 @@ function blockEntries(fm: string[], block: VerbBlock): { entry: ParsedEntry; lin
|
|
|
325
349
|
// ---------------------------------------------------------------------------
|
|
326
350
|
|
|
327
351
|
/**
|
|
328
|
-
* Add `principal` under `verb`. Idempotent: if an
|
|
329
|
-
* exists with the SAME deny-ness (a grant when
|
|
330
|
-
* returns `changed: false`.
|
|
331
|
-
*
|
|
352
|
+
* Add `principal` under `verb`. Idempotent: if an entry with the EXACT same
|
|
353
|
+
* token spelling already exists with the SAME deny-ness (a grant when
|
|
354
|
+
* granting, a deny when denying), returns `changed: false`. Idempotency is
|
|
355
|
+
* deliberately exact-token, never name-level: a bare token and a `role/`
|
|
356
|
+
* token are different principals (group-first precedence gives the bare key
|
|
357
|
+
* to a same-named group), so a bare-`X` GROUP grant must go through even when
|
|
358
|
+
* `role/X` is present, and vice versa. Creates the verb key (and the
|
|
359
|
+
* frontmatter fence, for a fresh file) as needed. Preserves everything else
|
|
360
|
+
* byte-for-byte.
|
|
332
361
|
*
|
|
333
362
|
* `allowScalar` controls whether a fresh single-entry verb may be written in
|
|
334
363
|
* the node-frontmatter scalar form (`owner: Name <email>`). access.md always
|
|
@@ -369,9 +398,10 @@ export function spliceGrant(
|
|
|
369
398
|
const block = findVerbBlock(region.lines, verb);
|
|
370
399
|
const existing = blockEntries(region.lines, block);
|
|
371
400
|
|
|
372
|
-
// Idempotency: an entry for this
|
|
373
|
-
// present (a grant when granting, a deny when denying).
|
|
374
|
-
|
|
401
|
+
// Idempotency: an entry for this EXACT token with the same deny-ness is
|
|
402
|
+
// already present (a grant when granting, a deny when denying). Exact-token
|
|
403
|
+
// on purpose — see the doc above.
|
|
404
|
+
if (existing.some((e) => entryMatches(e.entry, principal, 'exact') && e.entry.deny === deny)) {
|
|
375
405
|
return { text, changed: false };
|
|
376
406
|
}
|
|
377
407
|
|
|
@@ -413,14 +443,22 @@ export function spliceGrant(
|
|
|
413
443
|
* last item empties the verb, the `verb:` key is collapsed to `verb: []` so the
|
|
414
444
|
* file still parses cleanly (the resolver reads `[]` as an empty list).
|
|
415
445
|
* Preserves everything else.
|
|
446
|
+
*
|
|
447
|
+
* Token matching for role-shaped principals is `tokenMatch` (default `'name'`:
|
|
448
|
+
* BOTH spellings — revoking the role removes legacy bare spellings too).
|
|
449
|
+
* Callers pass `'exact'` when a GROUP shadows the principal's bare name: the
|
|
450
|
+
* bare token then belongs to the group and the `role/` token to the role, so a
|
|
451
|
+
* revoke of one must never strip the other (see AccessMutationService, which
|
|
452
|
+
* derives the flag from the merged principal index).
|
|
416
453
|
*/
|
|
417
454
|
export function spliceRevoke(
|
|
418
455
|
text: string,
|
|
419
456
|
verb: Verb,
|
|
420
457
|
rawPrincipal: Principal,
|
|
421
|
-
opts: { target?: SpliceTarget } = {},
|
|
458
|
+
opts: { target?: SpliceTarget; tokenMatch?: TokenMatch } = {},
|
|
422
459
|
): SpliceResult {
|
|
423
460
|
const principal = validatePrincipal(rawPrincipal);
|
|
461
|
+
const tokenMatch = opts.tokenMatch ?? 'name';
|
|
424
462
|
const f = splitFrontmatter(text);
|
|
425
463
|
if (!f.hasFrontmatter) return { text, changed: false };
|
|
426
464
|
|
|
@@ -430,7 +468,7 @@ export function spliceRevoke(
|
|
|
430
468
|
|
|
431
469
|
if (block.inlineScalarValue !== null) {
|
|
432
470
|
const r = parseAccessEntry(block.inlineScalarValue);
|
|
433
|
-
if (r.ok && entryMatches(r.entry, principal)) {
|
|
471
|
+
if (r.ok && entryMatches(r.entry, principal, tokenMatch)) {
|
|
434
472
|
// Lone scalar entry removed -> collapse to empty list.
|
|
435
473
|
region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
|
|
436
474
|
return { text: region.join(), changed: true };
|
|
@@ -439,7 +477,7 @@ export function spliceRevoke(
|
|
|
439
477
|
}
|
|
440
478
|
|
|
441
479
|
const entries = blockEntries(region.lines, block);
|
|
442
|
-
const victims = entries.filter((e) => entryMatches(e.entry, principal)).map((e) => e.line);
|
|
480
|
+
const victims = entries.filter((e) => entryMatches(e.entry, principal, tokenMatch)).map((e) => e.line);
|
|
443
481
|
if (victims.length === 0) return { text, changed: false };
|
|
444
482
|
|
|
445
483
|
// Delete from the bottom up so earlier indices stay valid.
|