@bevel-software/platform-core-backend 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +15 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- 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 +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +144 -52
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +92 -3
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +381 -108
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +159 -62
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +353 -13
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { FileTreeEntry, IWorkspaceService } from '@bevel-software/platform-shared';
|
|
2
|
+
import { WorkflowDomainError } from '../workflow/workflow.errors.js';
|
|
3
|
+
import { accessMdPathForFolder } from './access-mutation.service.js';
|
|
4
|
+
import {
|
|
5
|
+
EVERYONE_CANONICAL,
|
|
6
|
+
KNOWN_VERBS,
|
|
7
|
+
parseAccessFile,
|
|
8
|
+
parseOwnAccessEntries,
|
|
9
|
+
type ParsedEntry,
|
|
10
|
+
type Verb,
|
|
11
|
+
} from './access-control.service.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* WHERE access rules are DECLARED inside a folder — a read-only inventory, not
|
|
15
|
+
* a second resolver.
|
|
16
|
+
*
|
|
17
|
+
* A group's `access.md` is never the whole story: resolution is closeness-first,
|
|
18
|
+
* so a rule written on a single skill or `.tool` inside the folder overrides the
|
|
19
|
+
* folder's rule for the principals it names. An admin looking at a group's share
|
|
20
|
+
* list and seeing "GTM Team can read" would otherwise have no way to know that
|
|
21
|
+
* one battlecard underneath says `deny read: everyone`.
|
|
22
|
+
*
|
|
23
|
+
* So this module answers exactly one question — *which files declare access
|
|
24
|
+
* rules under here, and what do they say* — and it answers it with the SAME
|
|
25
|
+
* parsers the resolver builds its model from (`parseAccessFile` for `access.md`,
|
|
26
|
+
* `parseOwnAccessEntries` for node frontmatter). Nothing here decides whether
|
|
27
|
+
* anybody can do anything; it reports declarations verbatim. That is the whole
|
|
28
|
+
* reason it can exist without touching `IAccessControl`, which enterprise
|
|
29
|
+
* implements: a display of the stored rules cannot drift from the rules that
|
|
30
|
+
* are enforced, because it is reading the same bytes through the same parse.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A principal named by a declaration, in wire form. */
|
|
34
|
+
export type DeclarationPrincipal =
|
|
35
|
+
| { kind: 'role'; role: string }
|
|
36
|
+
| { kind: 'user'; email: string; name: string }
|
|
37
|
+
| { kind: 'everyone' };
|
|
38
|
+
|
|
39
|
+
/** One `verb: principal` line of a declaration, deny-prefix preserved. */
|
|
40
|
+
export interface DeclarationEntry {
|
|
41
|
+
verb: Verb;
|
|
42
|
+
/** True when the line carried the literal `deny ` prefix. */
|
|
43
|
+
deny: boolean;
|
|
44
|
+
principal: DeclarationPrincipal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AccessDeclaration {
|
|
48
|
+
/** Repo-relative path of the file that DECLARES the rules. */
|
|
49
|
+
path: string;
|
|
50
|
+
/**
|
|
51
|
+
* Repo-relative path the rules GOVERN: the containing directory for an
|
|
52
|
+
* `access.md`, the file itself for node frontmatter. Distinct from `path`
|
|
53
|
+
* because that is what a reader needs to recognise ("the battlecards
|
|
54
|
+
* folder"), and what the caller read-filters rows on.
|
|
55
|
+
*/
|
|
56
|
+
governs: string;
|
|
57
|
+
source: 'access-md' | 'frontmatter';
|
|
58
|
+
entries: DeclarationEntry[];
|
|
59
|
+
/**
|
|
60
|
+
* Set (with `entries: []`) when an `access.md` failed to parse. Surfaced
|
|
61
|
+
* rather than swallowed: a broken rule file is exactly the thing somebody
|
|
62
|
+
* managing access has to be told about, and the resolver's forgiveness makes
|
|
63
|
+
* it invisible everywhere else.
|
|
64
|
+
*/
|
|
65
|
+
parseError?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many files one scan will read. A group folder is small by construction;
|
|
70
|
+
* this cap exists so a pathological folder (a whole ontology moved under
|
|
71
|
+
* `Groups/`) degrades into an honest partial answer instead of pinning the
|
|
72
|
+
* event loop. Callers surface `truncated` to the user.
|
|
73
|
+
*/
|
|
74
|
+
export const DECLARATION_SCAN_FILE_CAP = 400;
|
|
75
|
+
|
|
76
|
+
/** 400 for a target that is not a folder. */
|
|
77
|
+
export class AccessDeclarationsError extends WorkflowDomainError {
|
|
78
|
+
constructor(message: string, status = 400) {
|
|
79
|
+
super(message, status);
|
|
80
|
+
this.name = 'AccessDeclarationsError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Node files whose own frontmatter may declare verbs. */
|
|
85
|
+
function isNodeCandidate(name: string): boolean {
|
|
86
|
+
return name.endsWith('.md') || name.endsWith('.tool');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Walk a workspace-relative path down the file tree; null when absent. */
|
|
90
|
+
function findNode(root: FileTreeEntry, wsRelPath: string): FileTreeEntry | null {
|
|
91
|
+
if (wsRelPath === '') return root;
|
|
92
|
+
let node: FileTreeEntry = root;
|
|
93
|
+
for (const segment of wsRelPath.split('/').filter(Boolean)) {
|
|
94
|
+
const next = node.children?.find((c) => c.name === segment);
|
|
95
|
+
if (!next) return null;
|
|
96
|
+
node = next;
|
|
97
|
+
}
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Candidate {
|
|
102
|
+
/** Repo-relative path of the declaring file. */
|
|
103
|
+
repoRel: string;
|
|
104
|
+
/** Workspace-relative path, i.e. what `readFile` takes. */
|
|
105
|
+
wsPath: string;
|
|
106
|
+
isAccessMd: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectCandidates(node: FileTreeEntry, kbPrefix: string, out: Candidate[]): void {
|
|
110
|
+
for (const child of node.children ?? []) {
|
|
111
|
+
if (child.type === 'directory') {
|
|
112
|
+
collectCandidates(child, kbPrefix, out);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const isAccessMd = child.name === 'access.md';
|
|
116
|
+
if (!isAccessMd && !isNodeCandidate(child.name)) continue;
|
|
117
|
+
if (!child.relativePath.startsWith(kbPrefix)) continue;
|
|
118
|
+
out.push({
|
|
119
|
+
repoRel: child.relativePath.slice(kbPrefix.length),
|
|
120
|
+
wsPath: child.relativePath,
|
|
121
|
+
isAccessMd,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The directory a `<dir>/access.md` governs (`''` for the repo root's own). */
|
|
127
|
+
function dirOf(repoRelPath: string): string {
|
|
128
|
+
const slash = repoRelPath.lastIndexOf('/');
|
|
129
|
+
return slash === -1 ? '' : repoRelPath.slice(0, slash);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `ParsedEntry` → wire principal. The canonical built-in `everyone` role
|
|
134
|
+
* becomes its own kind rather than a role named "everyone": it is not a
|
|
135
|
+
* roles.yaml role, it has no members to look up, and the UI says "Everyone"
|
|
136
|
+
* about it — collapsing it here keeps that decision out of three renderers.
|
|
137
|
+
*/
|
|
138
|
+
function toPrincipal(entry: ParsedEntry): DeclarationPrincipal {
|
|
139
|
+
if (entry.kind === 'user') {
|
|
140
|
+
return { kind: 'user', email: entry.email, name: entry.displayName };
|
|
141
|
+
}
|
|
142
|
+
if (entry.role === EVERYONE_CANONICAL) return { kind: 'everyone' };
|
|
143
|
+
return { kind: 'role', role: entry.displayRole };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Flatten a per-verb entry map into wire entries, verb order then file order. */
|
|
147
|
+
function toEntries(byVerb: Record<Verb, ParsedEntry[]>): DeclarationEntry[] {
|
|
148
|
+
const out: DeclarationEntry[] = [];
|
|
149
|
+
for (const verb of KNOWN_VERBS) {
|
|
150
|
+
for (const entry of byVerb[verb] ?? []) {
|
|
151
|
+
out.push({ verb, deny: entry.deny, principal: toPrincipal(entry) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every access declaration living INSIDE `folderRepoRel`.
|
|
159
|
+
*
|
|
160
|
+
* The folder's own `access.md` is deliberately excluded — that file is the
|
|
161
|
+
* folder's summary, already shown as the main share list, and repeating it in a
|
|
162
|
+
* list headed "item-specific rules" would read as a contradiction of itself.
|
|
163
|
+
*
|
|
164
|
+
* Failure modes are asymmetric on purpose, matching the resolver: a node whose
|
|
165
|
+
* frontmatter is malformed is skipped silently (a typo in a skill's `owner:`
|
|
166
|
+
* must not make the group's access page unreadable), while a malformed
|
|
167
|
+
* `access.md` is REPORTED with its `parseError` (that file was written to
|
|
168
|
+
* control access and currently controls nothing).
|
|
169
|
+
*
|
|
170
|
+
* Throws `AccessDeclarationsError` (400) when the target is a file. A target
|
|
171
|
+
* that does not exist in the clone yields an empty result — an unmigrated or
|
|
172
|
+
* not-yet-created folder is a normal state, not an error.
|
|
173
|
+
*/
|
|
174
|
+
export async function listAccessDeclarationsUnder(
|
|
175
|
+
workspaceService: IWorkspaceService,
|
|
176
|
+
workspaceId: string,
|
|
177
|
+
kbDirName: string,
|
|
178
|
+
folderRepoRel: string,
|
|
179
|
+
): Promise<{ overrides: AccessDeclaration[]; truncated: boolean }> {
|
|
180
|
+
const tree = await workspaceService.listFiles(workspaceId);
|
|
181
|
+
const kbPrefix = `${kbDirName}/`;
|
|
182
|
+
const wsFolderPath = folderRepoRel ? `${kbPrefix}${folderRepoRel}` : kbDirName;
|
|
183
|
+
|
|
184
|
+
const node = findNode(tree, wsFolderPath);
|
|
185
|
+
if (!node) return { overrides: [], truncated: false };
|
|
186
|
+
if (node.type !== 'directory') {
|
|
187
|
+
throw new AccessDeclarationsError('path must be a folder');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const ownAccessMd = accessMdPathForFolder(folderRepoRel);
|
|
191
|
+
const candidates: Candidate[] = [];
|
|
192
|
+
collectCandidates(node, kbPrefix, candidates);
|
|
193
|
+
|
|
194
|
+
const scannable = candidates
|
|
195
|
+
.filter((c) => c.repoRel !== ownAccessMd)
|
|
196
|
+
.sort((a, b) => (a.repoRel < b.repoRel ? -1 : a.repoRel > b.repoRel ? 1 : 0));
|
|
197
|
+
const truncated = scannable.length > DECLARATION_SCAN_FILE_CAP;
|
|
198
|
+
const scanned = truncated ? scannable.slice(0, DECLARATION_SCAN_FILE_CAP) : scannable;
|
|
199
|
+
|
|
200
|
+
const rows = await Promise.all(
|
|
201
|
+
scanned.map(async (candidate): Promise<AccessDeclaration | null> => {
|
|
202
|
+
let text: string;
|
|
203
|
+
try {
|
|
204
|
+
text = await workspaceService.readFile(workspaceId, candidate.wsPath);
|
|
205
|
+
} catch {
|
|
206
|
+
// The tree said it was there and the disk disagrees (a concurrent
|
|
207
|
+
// delete, a broken symlink). Nothing to declare — drop the row rather
|
|
208
|
+
// than fail the whole scan for one file.
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (candidate.isAccessMd) {
|
|
213
|
+
const parsed = parseAccessFile(text, candidate.repoRel);
|
|
214
|
+
if (!parsed.ok) {
|
|
215
|
+
return {
|
|
216
|
+
path: candidate.repoRel,
|
|
217
|
+
governs: dirOf(candidate.repoRel),
|
|
218
|
+
source: 'access-md',
|
|
219
|
+
entries: [],
|
|
220
|
+
parseError: parsed.errors.join('; '),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// An `access.md` that parses to nothing (empty, or only unknown keys)
|
|
224
|
+
// declares nothing: closeness-first resolution yields no verdict at that
|
|
225
|
+
// scope, so the parent's rules apply unchanged. Listing it under
|
|
226
|
+
// "item-specific rules" would announce an override that does not exist.
|
|
227
|
+
const entries = toEntries(parsed.file.entries);
|
|
228
|
+
if (entries.length === 0) return null;
|
|
229
|
+
return {
|
|
230
|
+
path: candidate.repoRel,
|
|
231
|
+
governs: dirOf(candidate.repoRel),
|
|
232
|
+
source: 'access-md',
|
|
233
|
+
entries,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const own = parseOwnAccessEntries(text);
|
|
238
|
+
if (!own) return null;
|
|
239
|
+
const entries = toEntries(own);
|
|
240
|
+
if (entries.length === 0) return null;
|
|
241
|
+
return {
|
|
242
|
+
path: candidate.repoRel,
|
|
243
|
+
governs: candidate.repoRel,
|
|
244
|
+
source: 'frontmatter',
|
|
245
|
+
entries,
|
|
246
|
+
};
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return { overrides: rows.filter((r): r is AccessDeclaration => r !== null), truncated };
|
|
251
|
+
}
|
|
@@ -144,7 +144,7 @@ export class AccessMutationService {
|
|
|
144
144
|
const current = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
|
|
145
145
|
let result;
|
|
146
146
|
try {
|
|
147
|
-
result = spliceGrant(current, verb, principal, { allowScalar });
|
|
147
|
+
result = spliceGrant(current, verb, principal, { allowScalar, target: kind === 'folder' ? 'folder' : 'node' });
|
|
148
148
|
} catch (err) {
|
|
149
149
|
throw this.toMutationError(err);
|
|
150
150
|
}
|
|
@@ -196,7 +196,7 @@ export class AccessMutationService {
|
|
|
196
196
|
try {
|
|
197
197
|
const verbsToRevoke = verb ? [verb] : KNOWN_VERBS;
|
|
198
198
|
for (const v of verbsToRevoke) {
|
|
199
|
-
const r = spliceRevoke(next, v, principal);
|
|
199
|
+
const r = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' });
|
|
200
200
|
next = r.text;
|
|
201
201
|
changed = changed || r.changed;
|
|
202
202
|
}
|
|
@@ -259,9 +259,9 @@ export class AccessMutationService {
|
|
|
259
259
|
for (const v of verbsToDeny) {
|
|
260
260
|
// (1) Strip any same-scope GRANT for this principal so grant-beats-deny
|
|
261
261
|
// can't silently swallow the deny we're about to add.
|
|
262
|
-
next = spliceRevoke(next, v, principal).text;
|
|
262
|
+
next = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' }).text;
|
|
263
263
|
// (2) Add the deny under the same verb.
|
|
264
|
-
next = spliceGrant(next, v, principal, { allowScalar, deny: true }).text;
|
|
264
|
+
next = spliceGrant(next, v, principal, { allowScalar, deny: true, target: kind === 'folder' ? 'folder' : 'node' }).text;
|
|
265
265
|
}
|
|
266
266
|
} catch (err) {
|
|
267
267
|
throw this.toMutationError(err);
|
|
@@ -32,8 +32,22 @@ import {
|
|
|
32
32
|
parseAccessEntry,
|
|
33
33
|
canonicalEmail,
|
|
34
34
|
canonicalRoleName,
|
|
35
|
+
accessMdDeclaresBodyRules,
|
|
35
36
|
} from './access-control.service.js';
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Which rule block a mutation edits.
|
|
40
|
+
*
|
|
41
|
+
* - `'node'` (default): the file's YAML frontmatter — node files, and every
|
|
42
|
+
* call that predates the two-format `access.md` story.
|
|
43
|
+
* - `'folder'`: the block that governs the CONTAINING FOLDER of an
|
|
44
|
+
* `access.md` — the body when the file is in the new (body-governed)
|
|
45
|
+
* format, the frontmatter otherwise. Callers mutating folder rules pass
|
|
46
|
+
* this so a grant can never land in a new-format file's frontmatter
|
|
47
|
+
* (which governs the FILE itself, not the folder).
|
|
48
|
+
*/
|
|
49
|
+
export type SpliceTarget = 'node' | 'folder';
|
|
50
|
+
|
|
37
51
|
/** A principal to grant/revoke, in the resolver's canonical terms. */
|
|
38
52
|
export type Principal =
|
|
39
53
|
| { kind: 'user'; email: string; displayName: string }
|
|
@@ -181,6 +195,28 @@ function joinFrontmatter(f: Frontmatter): string {
|
|
|
181
195
|
return [...f.pre, ...f.fm, ...f.post].join(f.eol);
|
|
182
196
|
}
|
|
183
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The line region a mutation edits, per {@link SpliceTarget}. `lines` aliases
|
|
200
|
+
* the region's array (edited in place, like `f.fm` always was); `join()`
|
|
201
|
+
* reassembles the whole file around it.
|
|
202
|
+
*/
|
|
203
|
+
function pickRegion(
|
|
204
|
+
text: string,
|
|
205
|
+
f: Frontmatter & { hasFrontmatter: boolean },
|
|
206
|
+
target: SpliceTarget,
|
|
207
|
+
): { lines: string[]; join(): string } {
|
|
208
|
+
if (target === 'folder' && f.hasFrontmatter && accessMdDeclaresBodyRules(text)) {
|
|
209
|
+
// New format: post = [closing '---', ...body]. Edit the body lines.
|
|
210
|
+
const closing = f.post[0];
|
|
211
|
+
const body = f.post.slice(1);
|
|
212
|
+
return {
|
|
213
|
+
lines: body,
|
|
214
|
+
join: () => [...f.pre, ...f.fm, closing, ...body].join(f.eol),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { lines: f.fm, join: () => joinFrontmatter(f) };
|
|
218
|
+
}
|
|
219
|
+
|
|
184
220
|
/** Leading-space count of a line. */
|
|
185
221
|
function indentOf(line: string): number {
|
|
186
222
|
const m = line.match(/^( *)/);
|
|
@@ -311,7 +347,7 @@ export function spliceGrant(
|
|
|
311
347
|
text: string,
|
|
312
348
|
verb: Verb,
|
|
313
349
|
rawPrincipal: Principal,
|
|
314
|
-
opts: { allowScalar?: boolean; deny?: boolean } = {},
|
|
350
|
+
opts: { allowScalar?: boolean; deny?: boolean; target?: SpliceTarget } = {},
|
|
315
351
|
): SpliceResult {
|
|
316
352
|
const principal = validatePrincipal(rawPrincipal);
|
|
317
353
|
const deny = opts.deny ?? false;
|
|
@@ -329,8 +365,9 @@ export function spliceGrant(
|
|
|
329
365
|
return { text: text2, changed: true };
|
|
330
366
|
}
|
|
331
367
|
|
|
332
|
-
const
|
|
333
|
-
const
|
|
368
|
+
const region = pickRegion(text, f, opts.target ?? 'node');
|
|
369
|
+
const block = findVerbBlock(region.lines, verb);
|
|
370
|
+
const existing = blockEntries(region.lines, block);
|
|
334
371
|
|
|
335
372
|
// Idempotency: an entry for this principal with the same deny-ness is already
|
|
336
373
|
// present (a grant when granting, a deny when denying).
|
|
@@ -341,17 +378,17 @@ export function spliceGrant(
|
|
|
341
378
|
const itemValue = renderEntry(principal, deny);
|
|
342
379
|
|
|
343
380
|
if (block.keyLine === -1) {
|
|
344
|
-
// Verb key absent — append a new block at the end of the
|
|
381
|
+
// Verb key absent — append a new block at the end of the region.
|
|
345
382
|
const lines = opts.allowScalar ? [`${verb}: ${itemValue}`] : [`${verb}:`, ` - ${itemValue}`];
|
|
346
|
-
|
|
347
|
-
return { text:
|
|
383
|
+
region.lines.push(...lines);
|
|
384
|
+
return { text: region.join(), changed: true };
|
|
348
385
|
}
|
|
349
386
|
|
|
350
387
|
if (block.inlineScalarValue !== null) {
|
|
351
388
|
// Scalar form — promote to a block list with both the old and new entry.
|
|
352
389
|
const old = block.inlineScalarValue;
|
|
353
|
-
|
|
354
|
-
return { text:
|
|
390
|
+
region.lines.splice(block.keyLine, 1, `${verb}:`, ` - ${old}`, ` - ${itemValue}`);
|
|
391
|
+
return { text: region.join(), changed: true };
|
|
355
392
|
}
|
|
356
393
|
|
|
357
394
|
// Block-list (possibly inline `[]`). Insert after the last existing item, or
|
|
@@ -360,14 +397,14 @@ export function spliceGrant(
|
|
|
360
397
|
const newLine = `${indent}- ${itemValue}`;
|
|
361
398
|
if (block.inlineEmpty) {
|
|
362
399
|
// `verb: []` -> `verb:` + one item.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return { text:
|
|
400
|
+
region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}:`;
|
|
401
|
+
region.lines.splice(block.keyLine + 1, 0, newLine);
|
|
402
|
+
return { text: region.join(), changed: true };
|
|
366
403
|
}
|
|
367
404
|
const insertAt =
|
|
368
405
|
block.itemLines.length > 0 ? block.itemLines[block.itemLines.length - 1] + 1 : block.keyLine + 1;
|
|
369
|
-
|
|
370
|
-
return { text:
|
|
406
|
+
region.lines.splice(insertAt, 0, newLine);
|
|
407
|
+
return { text: region.join(), changed: true };
|
|
371
408
|
}
|
|
372
409
|
|
|
373
410
|
/**
|
|
@@ -377,37 +414,43 @@ export function spliceGrant(
|
|
|
377
414
|
* file still parses cleanly (the resolver reads `[]` as an empty list).
|
|
378
415
|
* Preserves everything else.
|
|
379
416
|
*/
|
|
380
|
-
export function spliceRevoke(
|
|
417
|
+
export function spliceRevoke(
|
|
418
|
+
text: string,
|
|
419
|
+
verb: Verb,
|
|
420
|
+
rawPrincipal: Principal,
|
|
421
|
+
opts: { target?: SpliceTarget } = {},
|
|
422
|
+
): SpliceResult {
|
|
381
423
|
const principal = validatePrincipal(rawPrincipal);
|
|
382
424
|
const f = splitFrontmatter(text);
|
|
383
425
|
if (!f.hasFrontmatter) return { text, changed: false };
|
|
384
426
|
|
|
385
|
-
const
|
|
427
|
+
const region = pickRegion(text, f, opts.target ?? 'node');
|
|
428
|
+
const block = findVerbBlock(region.lines, verb);
|
|
386
429
|
if (block.keyLine === -1) return { text, changed: false };
|
|
387
430
|
|
|
388
431
|
if (block.inlineScalarValue !== null) {
|
|
389
432
|
const r = parseAccessEntry(block.inlineScalarValue);
|
|
390
433
|
if (r.ok && entryMatches(r.entry, principal)) {
|
|
391
434
|
// Lone scalar entry removed -> collapse to empty list.
|
|
392
|
-
|
|
393
|
-
return { text:
|
|
435
|
+
region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
|
|
436
|
+
return { text: region.join(), changed: true };
|
|
394
437
|
}
|
|
395
438
|
return { text, changed: false };
|
|
396
439
|
}
|
|
397
440
|
|
|
398
|
-
const entries = blockEntries(
|
|
441
|
+
const entries = blockEntries(region.lines, block);
|
|
399
442
|
const victims = entries.filter((e) => entryMatches(e.entry, principal)).map((e) => e.line);
|
|
400
443
|
if (victims.length === 0) return { text, changed: false };
|
|
401
444
|
|
|
402
445
|
// Delete from the bottom up so earlier indices stay valid.
|
|
403
446
|
const remaining = block.itemLines.filter((l) => !victims.includes(l));
|
|
404
|
-
for (const line of [...victims].sort((a, b) => b - a))
|
|
447
|
+
for (const line of [...victims].sort((a, b) => b - a)) region.lines.splice(line, 1);
|
|
405
448
|
|
|
406
449
|
if (remaining.length === 0) {
|
|
407
450
|
// Verb block is now empty — collapse `verb:` to `verb: []`. The key line
|
|
408
451
|
// index is unchanged because we only removed lines that came after it.
|
|
409
|
-
|
|
452
|
+
region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
|
|
410
453
|
}
|
|
411
|
-
return { text:
|
|
454
|
+
return { text: region.join(), changed: true };
|
|
412
455
|
}
|
|
413
456
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type TargetKind,
|
|
21
21
|
} from './access-mutation.service.js';
|
|
22
22
|
import { canonicalRoleName, EVERYONE_CANONICAL, type Verb } from './access-control.service.js';
|
|
23
|
+
import { listAccessDeclarationsUnder } from './access-declarations.js';
|
|
23
24
|
import { RolesAdminService, RolesAdminError } from './roles-admin.service.js';
|
|
24
25
|
import type { Principal } from './access-splice.js';
|
|
25
26
|
import type { Database } from '../database/connection.js';
|
|
@@ -171,6 +172,66 @@ export function createAccessRoutes(
|
|
|
171
172
|
}
|
|
172
173
|
});
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* GET /api/workspace/:id/access/overrides?path=<folder>
|
|
177
|
+
*
|
|
178
|
+
* Every access declaration living INSIDE a folder — the descendant
|
|
179
|
+
* `access.md` files and the node frontmatter that override the folder's own
|
|
180
|
+
* rules for the principals they name. Display-only: the group access surface
|
|
181
|
+
* shows it so nobody reads a folder's share list as the whole story.
|
|
182
|
+
*
|
|
183
|
+
* Gating is stricter than the sibling `GET /access`, which hands its eligible
|
|
184
|
+
* lists to any authenticated caller: here the caller must RESOLVE read on the
|
|
185
|
+
* folder (403 otherwise), and every returned row is additionally filtered by
|
|
186
|
+
* `canReadBatch` on what it governs. So the endpoint can only ever tell you
|
|
187
|
+
* about rules on things you can already see. It writes nothing and resolves
|
|
188
|
+
* nothing — it reports declarations through the resolver's own parsers.
|
|
189
|
+
*/
|
|
190
|
+
router.get('/workspace/:id/access/overrides', async (req, res) => {
|
|
191
|
+
const user = await requireUser(req, res);
|
|
192
|
+
if (!user) return;
|
|
193
|
+
|
|
194
|
+
const rawPath = req.query.path;
|
|
195
|
+
if (typeof rawPath !== 'string' || !rawPath) {
|
|
196
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const folder = toRepoRelative(rawPath);
|
|
202
|
+
assertRepoRelativeTarget(folder, 'folder');
|
|
203
|
+
|
|
204
|
+
if (!(await accessControl.canRead(req.params.id, user.email, folder))) {
|
|
205
|
+
res.status(403).json({ error: 'You do not have access to this folder.' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { overrides, truncated } = await listAccessDeclarationsUnder(
|
|
210
|
+
workspaceService,
|
|
211
|
+
req.params.id,
|
|
212
|
+
kbDirName,
|
|
213
|
+
folder,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Read-filter per ROW. Folder read does not imply read on everything
|
|
217
|
+
// underneath — a `deny read` on one skill is precisely the kind of rule
|
|
218
|
+
// this endpoint lists, and echoing that skill's name back to the person
|
|
219
|
+
// it was denied to would leak the thing the rule exists to hide.
|
|
220
|
+
const governs = [...new Set(overrides.map((o) => o.governs))];
|
|
221
|
+
const readable = governs.length
|
|
222
|
+
? await accessControl.canReadBatch(req.params.id, user.email, governs)
|
|
223
|
+
: new Map<string, boolean>();
|
|
224
|
+
|
|
225
|
+
res.json({
|
|
226
|
+
overrides: overrides.filter((o) => readable.get(o.governs) === true),
|
|
227
|
+
truncated,
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const { status, body } = toHttpError(err);
|
|
231
|
+
res.status(status).json(body);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
174
235
|
/**
|
|
175
236
|
* POST /api/workspace/:id/access/batch
|
|
176
237
|
* Body: `{ paths: string[] }`. Returns `{ results: { [path]: boolean } }`.
|
|
@@ -87,6 +87,16 @@ export interface ICreatorAccess {
|
|
|
87
87
|
noteAccessFileWritten(workspaceId: string): void;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The splice-safe principal for a creator — exported because group
|
|
92
|
+
* provisioning (`GroupProvisionService`) writes the same `Name <email>`
|
|
93
|
+
* entries into the access.md files it seeds, and two spellings of the same
|
|
94
|
+
* person would read as two people.
|
|
95
|
+
*/
|
|
96
|
+
export function creatorPrincipal(creator: Creator): Principal {
|
|
97
|
+
return { kind: 'user', email: creator.email, displayName: safeDisplayName(creator) };
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
/**
|
|
91
101
|
* `Name <email>`-safe display name: `validatePrincipal` rejects control
|
|
92
102
|
* chars, `<`, `>`, and `#`, so strip those from the user's name and fall
|
|
@@ -159,6 +169,11 @@ export class CreatorAccessService implements ICreatorAccess {
|
|
|
159
169
|
warnSkipped(rel, err);
|
|
160
170
|
return null;
|
|
161
171
|
}
|
|
172
|
+
// NOTE: a direct group folder (`Groups/<Name>`) is no longer special
|
|
173
|
+
// here. Groups — and personal folders — are made by the dedicated
|
|
174
|
+
// provisioning endpoint (`GroupProvisionService`), which writes the
|
|
175
|
+
// full ownership template itself; this generic read-grant only covers
|
|
176
|
+
// ad-hoc folder creation elsewhere in the tree.
|
|
162
177
|
return {
|
|
163
178
|
kind: 'seed-access-md',
|
|
164
179
|
wsRelPath: `${this.kbDirName}/${acc}/access.md`,
|
|
@@ -166,7 +181,12 @@ export class CreatorAccessService implements ICreatorAccess {
|
|
|
166
181
|
try {
|
|
167
182
|
// Idempotent merge into whatever is on disk by write time — a
|
|
168
183
|
// concurrent creator's grant survives; ours lands next to it.
|
|
169
|
-
|
|
184
|
+
// `target: 'folder'` so a new-format access.md (body-governed)
|
|
185
|
+
// gets the grant in its FOLDER rules, never its self-frontmatter.
|
|
186
|
+
return spliceGrant(current, 'read', principal, {
|
|
187
|
+
allowScalar: false,
|
|
188
|
+
target: 'folder',
|
|
189
|
+
}).text;
|
|
170
190
|
} catch (err) {
|
|
171
191
|
warnSkipped(rel, err);
|
|
172
192
|
return current;
|
|
@@ -249,7 +269,7 @@ export class CreatorAccessService implements ICreatorAccess {
|
|
|
249
269
|
}
|
|
250
270
|
|
|
251
271
|
private principalFor(creator: Creator): Principal {
|
|
252
|
-
return
|
|
272
|
+
return creatorPrincipal(creator);
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
275
|
|
|
@@ -17,7 +17,7 @@ export class AdminAccessService implements IAdminAccessService {
|
|
|
17
17
|
private readonly accessControl: IAccessControl,
|
|
18
18
|
private readonly workspaceService: WorkspaceService,
|
|
19
19
|
/** Branch whose `roles.yaml` defines admins (the authoritative default branch). */
|
|
20
|
-
private readonly branch: string,
|
|
20
|
+
private readonly branch: string | (() => string),
|
|
21
21
|
/**
|
|
22
22
|
* Emails treated as admin WITHOUT consulting `roles.yaml` — the env
|
|
23
23
|
* bootstrap admin (`ADMIN_EMAIL`). A fresh deployment's KB may not list
|
|
@@ -36,8 +36,9 @@ export class AdminAccessService implements IAdminAccessService {
|
|
|
36
36
|
// Ensure the authoritative clone is on disk, then resolve Admin-role
|
|
37
37
|
// membership via the access model. Any failure (missing/invalid
|
|
38
38
|
// roles.yaml, clone error) resolves to "not admin" — the safe default.
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const branch = typeof this.branch === 'function' ? this.branch() : this.branch;
|
|
40
|
+
await this.workspaceService.getOrCreateForBranch(branch);
|
|
41
|
+
return await this.accessControl.canWrite(workspaceIdForBranch(branch), email, 'roles.yaml');
|
|
41
42
|
} catch (err) {
|
|
42
43
|
// Log so an admin lockout caused by a clone / roles.yaml failure is
|
|
43
44
|
// diagnosable rather than a silent denial.
|
|
@@ -215,3 +215,60 @@ describe('AuthService.listAccounts', () => {
|
|
|
215
215
|
expect(JSON.stringify(accounts)).not.toContain('scrypt:');
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `ALLOWED_EMAIL_DOMAINS` is the SSO allow-list, and only that.
|
|
221
|
+
*
|
|
222
|
+
* It exists because SSO AUTO-PROVISIONS — `loginWithSso` upserts the account
|
|
223
|
+
* the first time the issuer authenticates someone, with nobody approving it.
|
|
224
|
+
* Against a multi-tenant issuer (Google, the Entra `common` endpoint) it is the
|
|
225
|
+
* only thing between "has an account somewhere" and "has an account here.
|
|
226
|
+
*
|
|
227
|
+
* The other two entry points do not have that property, which is why the guard
|
|
228
|
+
* was taken off them: an admin-created account is vetted by the act of creating
|
|
229
|
+
* it, and password login can only reach an account that already exists. The
|
|
230
|
+
* check gated nothing there — while being able to lock out a bootstrap admin
|
|
231
|
+
* whose own address sits outside the list, which the last test pins.
|
|
232
|
+
*/
|
|
233
|
+
describe('AuthService — the SSO domain allow-list', () => {
|
|
234
|
+
const config = makeConfig({ allowedEmailDomains: ['bevel.software'] });
|
|
235
|
+
|
|
236
|
+
it('refuses an SSO sign-in from outside the allow-list', async () => {
|
|
237
|
+
const { db } = makeFakeDb([[]]);
|
|
238
|
+
await expect(
|
|
239
|
+
new AuthService(db, config).loginWithSso('someone@gmail.com', 'Someone'),
|
|
240
|
+
).rejects.toThrow(/domain is not allowed/i);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('admits a subdomain of an allowed domain', async () => {
|
|
244
|
+
const { db } = makeFakeDb([[{ ...ROW, email: 'eu@eu.bevel.software', name: 'EU' }]]);
|
|
245
|
+
const out = await new AuthService(db, config).loginWithSso('eu@eu.bevel.software', 'EU');
|
|
246
|
+
expect(out.user.email).toBe('eu@eu.bevel.software');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does NOT gate an account an admin creates', async () => {
|
|
250
|
+
const { db } = makeFakeDb([[{ ...ROW, email: 'contractor@gmail.com', name: 'Contractor' }]]);
|
|
251
|
+
const account = await new AuthService(db, config).createAccount(
|
|
252
|
+
'contractor@gmail.com',
|
|
253
|
+
'Contractor',
|
|
254
|
+
'pw-longer-than-8',
|
|
255
|
+
);
|
|
256
|
+
expect(account.email).toBe('contractor@gmail.com');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('does NOT lock the bootstrap admin out of their own deployment', async () => {
|
|
260
|
+
// The owner's address need not sit inside the SSO allow-list — the list is
|
|
261
|
+
// about who may provision themselves, not about who owns the deployment.
|
|
262
|
+
const outsideConfig = makeConfig({
|
|
263
|
+
allowedEmailDomains: ['bevel.software'],
|
|
264
|
+
adminEmail: 'root@gmail.com',
|
|
265
|
+
adminPassword: 'sup3r-secret',
|
|
266
|
+
});
|
|
267
|
+
const { db } = makeFakeDb([[{ ...ROW, email: 'root@gmail.com', name: 'root' }]]);
|
|
268
|
+
const out = await new AuthService(db, outsideConfig).loginWithPassword(
|
|
269
|
+
'root@gmail.com',
|
|
270
|
+
'sup3r-secret',
|
|
271
|
+
);
|
|
272
|
+
expect(out.user.email).toBe('root@gmail.com');
|
|
273
|
+
});
|
|
274
|
+
});
|