@bevel-software/platform-core-backend 0.22.0 → 0.23.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 +11 -1
- 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 +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +30 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +112 -23
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +34 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +89 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +0 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +11 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +13 -15
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +176 -7
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +41 -24
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +35 -27
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +4 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +69 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +153 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +55 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +111 -4
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +92 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +115 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +255 -25
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +2 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +309 -115
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +0 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +170 -1
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +32 -14
- package/src/core/create-core-services.ts +129 -23
- package/src/core/lifecycle.ts +98 -13
- package/src/core-config.ts +13 -14
- package/src/index.ts +12 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access.routes.ts +13 -15
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +179 -6
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +4 -2
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/git.service.ts +168 -0
- package/src/modules/workflow/pending-commits.service.ts +59 -1
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +121 -4
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +256 -24
- package/src/modules/workspace/workspace.tools.ts +338 -119
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
- package/src/tenancy/static-tenant-source.ts +0 -3
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
8
|
+
import { AccessControlService } from '../access-control.service.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `previewAccessAfterRelocation`: the caller's OWN verbs at a destination as
|
|
12
|
+
* they will be once a move or a copy has landed there.
|
|
13
|
+
*
|
|
14
|
+
* The question the four gates cannot answer, because the `access.md` files a
|
|
15
|
+
* folder carries are not at the destination yet — which is how a rename came
|
|
16
|
+
* to warn about losing owner access the folder's own rules were about to hand
|
|
17
|
+
* straight back. Driven over a real on-disk tree, and every answer checked
|
|
18
|
+
* against the ordinary gates once the tree really says what the preview said
|
|
19
|
+
* it would.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const KB_DIR = 'knowledge-base';
|
|
23
|
+
const MOVER = 'mover@x.io';
|
|
24
|
+
|
|
25
|
+
function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
|
|
26
|
+
return {
|
|
27
|
+
getWorkspacePath: async (id: string) => {
|
|
28
|
+
if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
|
|
29
|
+
return workspaceDir;
|
|
30
|
+
},
|
|
31
|
+
ensureRemotesFetched: async () => undefined,
|
|
32
|
+
} as unknown as WorkspaceService;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rules = (body: string) => `---\n${body}---\n`;
|
|
36
|
+
|
|
37
|
+
describe('AccessControlService.previewAccessAfterRelocation', () => {
|
|
38
|
+
let root: string;
|
|
39
|
+
let repo: string;
|
|
40
|
+
const workspaceId = 'ws-preview-relocation';
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-preview-reloc-'));
|
|
44
|
+
repo = path.join(root, workspaceId, KB_DIR);
|
|
45
|
+
});
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
if (root) await fs.rm(root, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function write(files: Record<string, string>): Promise<void> {
|
|
51
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
52
|
+
const abs = path.join(repo, rel);
|
|
53
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
54
|
+
await fs.writeFile(abs, contents);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A service reading the tree as it is NOW. Built fresh after every change
|
|
60
|
+
* on purpose: in production the file-change notifier invalidates the model,
|
|
61
|
+
* and a test that leaned on the five-second cache would be asserting the
|
|
62
|
+
* cache rather than the resolver.
|
|
63
|
+
*/
|
|
64
|
+
const service = () =>
|
|
65
|
+
new AccessControlService(
|
|
66
|
+
stubWorkspaceService(workspaceId, path.join(root, workspaceId)),
|
|
67
|
+
KB_DIR,
|
|
68
|
+
new NodeFs(),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/** The caller's four verbs at a path, through the ordinary gates. */
|
|
72
|
+
async function verbsAt(svc: AccessControlService, rel: string) {
|
|
73
|
+
const [read, write, download, owner] = await Promise.all([
|
|
74
|
+
svc.canRead(workspaceId, MOVER, rel),
|
|
75
|
+
svc.canWrite(workspaceId, MOVER, rel),
|
|
76
|
+
svc.canDownload(workspaceId, MOVER, rel),
|
|
77
|
+
svc.canOwner(workspaceId, MOVER, rel),
|
|
78
|
+
]);
|
|
79
|
+
return { read, write, download, owner };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Move `from` to `to` on disk, as the move itself does. */
|
|
83
|
+
async function reallyMove(from: string, to: string): Promise<void> {
|
|
84
|
+
const dest = path.join(repo, to);
|
|
85
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
86
|
+
await fs.rename(path.join(repo, from), dest);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Copy `from` to `to` on disk, leaving the source where it is. */
|
|
90
|
+
async function reallyCopy(from: string, to: string): Promise<void> {
|
|
91
|
+
const dest = path.join(repo, to);
|
|
92
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
93
|
+
await fs.cp(path.join(repo, from), dest, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BASE = {
|
|
97
|
+
'roles.yaml': 'roles:\n Admin:\n - admin@x.io\n',
|
|
98
|
+
'access.md': rules('read:\n - everyone\n'),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it('a folder keeps the access its own access.md gives it when it is renamed', async () => {
|
|
102
|
+
await write({
|
|
103
|
+
...BASE,
|
|
104
|
+
'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
105
|
+
'Sales/deal.md': '# Deal\n',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const svc = service();
|
|
109
|
+
const before = await verbsAt(svc, 'Sales');
|
|
110
|
+
const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
|
|
111
|
+
|
|
112
|
+
expect(before.owner).toBe(true);
|
|
113
|
+
expect(after).toEqual(before);
|
|
114
|
+
|
|
115
|
+
// The root grants read and nothing else, so the destination AS IT STANDS
|
|
116
|
+
// says the opposite — which is the warning people were shown.
|
|
117
|
+
expect(await verbsAt(svc, 'Revenue')).toMatchObject({ write: false, owner: false });
|
|
118
|
+
|
|
119
|
+
await reallyMove('Sales', 'Revenue');
|
|
120
|
+
expect(await verbsAt(service(), 'Revenue')).toEqual(after);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a folder with no access.md of its own takes the new parent\'s rules, as it always did', async () => {
|
|
124
|
+
await write({
|
|
125
|
+
...BASE,
|
|
126
|
+
'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
|
|
127
|
+
'Work/Notes/note.md': '# Note\n',
|
|
128
|
+
'Legal/access.md': rules('read:\n - everyone\n'),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const svc = service();
|
|
132
|
+
const before = await verbsAt(svc, 'Work/Notes');
|
|
133
|
+
const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Notes', 'Legal/Notes');
|
|
134
|
+
|
|
135
|
+
expect(before.write).toBe(true);
|
|
136
|
+
expect(after.write).toBe(false);
|
|
137
|
+
// Nothing travels, so the preview is exactly what the gates say today.
|
|
138
|
+
expect(after).toEqual(await verbsAt(svc, 'Legal/Notes'));
|
|
139
|
+
|
|
140
|
+
await reallyMove('Work/Notes', 'Legal/Notes');
|
|
141
|
+
expect(await verbsAt(service(), 'Legal/Notes')).toEqual(after);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('what the folder inherited is left behind; what it carries comes along', async () => {
|
|
145
|
+
await write({
|
|
146
|
+
...BASE,
|
|
147
|
+
'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
148
|
+
'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
|
|
149
|
+
'Work/Team/plan.md': '# Plan\n',
|
|
150
|
+
'Legal/access.md': rules('read:\n - everyone\n'),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const svc = service();
|
|
154
|
+
expect(await verbsAt(svc, 'Work/Team')).toMatchObject({ write: true, owner: true });
|
|
155
|
+
|
|
156
|
+
const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team');
|
|
157
|
+
|
|
158
|
+
// Write is the folder's own and travels; owner came from `Work/` and
|
|
159
|
+
// does not.
|
|
160
|
+
expect(after).toMatchObject({ write: true, owner: false });
|
|
161
|
+
|
|
162
|
+
await reallyMove('Work/Team', 'Legal/Team');
|
|
163
|
+
expect(await verbsAt(service(), 'Legal/Team')).toEqual(after);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('a nested access.md does not change the answer at the folder itself', async () => {
|
|
167
|
+
const tree = {
|
|
168
|
+
...BASE,
|
|
169
|
+
'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
170
|
+
'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
|
|
171
|
+
'Work/Team/plan.md': '# Plan\n',
|
|
172
|
+
'Legal/access.md': rules('read:\n - everyone\n'),
|
|
173
|
+
};
|
|
174
|
+
await write(tree);
|
|
175
|
+
const withoutNested = await service().previewAccessAfterRelocation(
|
|
176
|
+
workspaceId, MOVER, 'Work/Team', 'Legal/Team',
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// `Sub/` shuts the caller out entirely, and it moves too — but it governs
|
|
180
|
+
// `Legal/Team/Sub`, not `Legal/Team`.
|
|
181
|
+
await write({ 'Work/Team/Sub/access.md': rules(`read:\n - deny Mover <${MOVER}>\n`) });
|
|
182
|
+
const svc = service();
|
|
183
|
+
expect(await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'))
|
|
184
|
+
.toEqual(withoutNested);
|
|
185
|
+
// It is counted, though — at the path it lands on.
|
|
186
|
+
expect(
|
|
187
|
+
await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'),
|
|
188
|
+
).not.toEqual(
|
|
189
|
+
await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team/Sub', 'Legal/Team/Sub'),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await reallyMove('Work/Team', 'Legal/Team');
|
|
193
|
+
const after = service();
|
|
194
|
+
expect(await verbsAt(after, 'Legal/Team')).toEqual(withoutNested);
|
|
195
|
+
expect(await verbsAt(after, 'Legal/Team/Sub')).toMatchObject({ read: false });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('a copy leaves the source rules where they are and lands a second set', async () => {
|
|
199
|
+
await write({
|
|
200
|
+
...BASE,
|
|
201
|
+
'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
202
|
+
'Sales/deal.md': '# Deal\n',
|
|
203
|
+
// A destination governed DIFFERENTLY from the source, so the answer can
|
|
204
|
+
// only come from the rules the copy carries: Legal grants read and
|
|
205
|
+
// nothing else, as the root does.
|
|
206
|
+
'Legal/access.md': rules('read:\n - everyone\n'),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const svc = service();
|
|
210
|
+
const after = await svc.previewAccessAfterRelocation(
|
|
211
|
+
workspaceId, MOVER, 'Sales', 'Legal/Sales-Copy', { sourceRemains: true },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// The copied `access.md` governs the copy, which is the opposite of what
|
|
215
|
+
// the destination says today.
|
|
216
|
+
expect(after).toMatchObject({ write: true, owner: true });
|
|
217
|
+
expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
|
|
218
|
+
|
|
219
|
+
await reallyCopy('Sales', 'Legal/Sales-Copy');
|
|
220
|
+
const done = service();
|
|
221
|
+
expect(await verbsAt(done, 'Legal/Sales-Copy')).toEqual(after);
|
|
222
|
+
// `sourceRemains`: both sets of rules are real afterwards, and the source
|
|
223
|
+
// keeps the access it always had.
|
|
224
|
+
expect(await verbsAt(done, 'Sales')).toMatchObject({ owner: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A lone `access.md` is a single FILE whose rules are a whole folder's.
|
|
229
|
+
* Keyed by the directory it sits in, it is not a path "under" the source,
|
|
230
|
+
* so the folder walk cannot see it — and `copy_file` will copy one, which
|
|
231
|
+
* is how a preview came to answer the destination's old rules about a
|
|
232
|
+
* directory that was about to be governed by the file landing in it.
|
|
233
|
+
*/
|
|
234
|
+
it('a lone access.md governs the folder it is copied into', async () => {
|
|
235
|
+
await write({
|
|
236
|
+
...BASE,
|
|
237
|
+
'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
238
|
+
'Legal/note.md': '# Note\n',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const svc = service();
|
|
242
|
+
// `Legal/` has no rules of its own; the root grants read and nothing else.
|
|
243
|
+
expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
|
|
244
|
+
|
|
245
|
+
const after = await svc.previewAccessAfterRelocation(
|
|
246
|
+
workspaceId, MOVER, 'Sales/access.md', 'Legal/access.md', { sourceRemains: true },
|
|
247
|
+
);
|
|
248
|
+
expect(after).toMatchObject({ write: true, owner: true });
|
|
249
|
+
|
|
250
|
+
await fs.copyFile(path.join(repo, 'Sales/access.md'), path.join(repo, 'Legal/access.md'));
|
|
251
|
+
const done = service();
|
|
252
|
+
expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
|
|
253
|
+
// What landed governs the folder, not only itself.
|
|
254
|
+
expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The name the bytes land under decides what they GOVERN, and it decides
|
|
259
|
+
* how they are PARSED: the same frontmatter is a folder's rules in an
|
|
260
|
+
* `access.md` and the file's own rules anywhere else. So these bytes
|
|
261
|
+
* govern no folder at `Legal/rules-copy.md` — and still grant the caller
|
|
262
|
+
* write and owner, on that one file. Reading them as the source's name said
|
|
263
|
+
* `write: false, owner: false`, and the copy then handed over both: a
|
|
264
|
+
* preview contradicting the operation it was previewing, which is the
|
|
265
|
+
* entire fault this ticket exists to kill.
|
|
266
|
+
*/
|
|
267
|
+
it('a file copied under any other name governs no folder, but keeps its own grants', async () => {
|
|
268
|
+
await write({
|
|
269
|
+
...BASE,
|
|
270
|
+
'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
271
|
+
'Legal/note.md': '# Note\n',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const svc = service();
|
|
275
|
+
const after = await svc.previewAccessAfterRelocation(
|
|
276
|
+
workspaceId, MOVER, 'Sales/access.md', 'Legal/rules-copy.md', { sourceRemains: true },
|
|
277
|
+
);
|
|
278
|
+
// Pinned, not merely equal to the outcome: both sides reading `false`
|
|
279
|
+
// would satisfy the comparison below and be wrong together.
|
|
280
|
+
expect(after).toMatchObject({ write: true, owner: true });
|
|
281
|
+
|
|
282
|
+
await reallyCopy('Sales/access.md', 'Legal/rules-copy.md');
|
|
283
|
+
const done = service();
|
|
284
|
+
expect(await verbsAt(done, 'Legal/rules-copy.md')).toEqual(after);
|
|
285
|
+
// The folder it landed in is untouched: an ordinary note governs nothing
|
|
286
|
+
// but itself, whatever its frontmatter says.
|
|
287
|
+
expect(await verbsAt(done, 'Legal')).toMatchObject({ write: false, owner: false });
|
|
288
|
+
expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: false, owner: false });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* And the mirror, which `copy_file` also allows: ordinary bytes landing as
|
|
293
|
+
* a directory's `access.md` become that directory's rules the moment they
|
|
294
|
+
* land. The preview reads them as the destination will.
|
|
295
|
+
*/
|
|
296
|
+
it('a file copied TO an access.md gives the destination folder its rules', async () => {
|
|
297
|
+
await write({
|
|
298
|
+
...BASE,
|
|
299
|
+
'Draft/proposed-rules.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
300
|
+
'Legal/note.md': '# Note\n',
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const svc = service();
|
|
304
|
+
expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
|
|
305
|
+
|
|
306
|
+
const after = await svc.previewAccessAfterRelocation(
|
|
307
|
+
workspaceId, MOVER, 'Draft/proposed-rules.md', 'Legal/access.md', { sourceRemains: true },
|
|
308
|
+
);
|
|
309
|
+
expect(after).toMatchObject({ write: true, owner: true });
|
|
310
|
+
|
|
311
|
+
await reallyCopy('Draft/proposed-rules.md', 'Legal/access.md');
|
|
312
|
+
const done = service();
|
|
313
|
+
expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
|
|
314
|
+
// It governs the folder now, which is the whole difference the name makes.
|
|
315
|
+
expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
|
|
316
|
+
expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: true, owner: true });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The same rule for the other thing that travels with a file: its own
|
|
321
|
+
* frontmatter. Asking the DESTINATION for it always answered null — nothing
|
|
322
|
+
* is there yet — so a rename of a self-governing file previewed a loss the
|
|
323
|
+
* move hands straight back, and the preview contradicted the access the
|
|
324
|
+
* caller really has afterwards (Specification requirement 6).
|
|
325
|
+
*/
|
|
326
|
+
it("a file's own frontmatter travels with its bytes, so a rename costs nothing", async () => {
|
|
327
|
+
await write({
|
|
328
|
+
...BASE,
|
|
329
|
+
'Work/access.md': rules('read:\n - everyone\n'),
|
|
330
|
+
'Work/plan.md': rules(`owner:\n - Mover <${MOVER}>\n`) + '# Plan\n',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const svc = service();
|
|
334
|
+
const before = await verbsAt(svc, 'Work/plan.md');
|
|
335
|
+
expect(before.owner).toBe(true);
|
|
336
|
+
|
|
337
|
+
const after = await svc.previewAccessAfterRelocation(
|
|
338
|
+
workspaceId, MOVER, 'Work/plan.md', 'Work/roadmap.md',
|
|
339
|
+
);
|
|
340
|
+
expect(after).toEqual(before);
|
|
341
|
+
|
|
342
|
+
await reallyMove('Work/plan.md', 'Work/roadmap.md');
|
|
343
|
+
expect(await verbsAt(service(), 'Work/roadmap.md')).toEqual(after);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('a single file answers exactly as the gates at the destination do', async () => {
|
|
347
|
+
await write({
|
|
348
|
+
...BASE,
|
|
349
|
+
'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
|
|
350
|
+
'Work/note.md': '# Note\n',
|
|
351
|
+
'Legal/access.md': rules('read:\n - everyone\n'),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const svc = service();
|
|
355
|
+
const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/note.md', 'Legal/note.md');
|
|
356
|
+
|
|
357
|
+
expect(after).toEqual(await verbsAt(svc, 'Legal/note.md'));
|
|
358
|
+
|
|
359
|
+
await reallyMove('Work/note.md', 'Legal/note.md');
|
|
360
|
+
expect(await verbsAt(service(), 'Legal/note.md')).toEqual(after);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('answers the caller\'s own verbs and nothing else, and writes nothing', async () => {
|
|
364
|
+
await write({
|
|
365
|
+
...BASE,
|
|
366
|
+
'Sales/access.md': rules(`read:\n - deny everyone\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
|
|
367
|
+
'Sales/deal.md': '# Deal\n',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const svc = service();
|
|
371
|
+
const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
|
|
372
|
+
|
|
373
|
+
// Four booleans. Nothing about who else holds what, and nothing out of
|
|
374
|
+
// an `access.md` the caller may not read.
|
|
375
|
+
expect(Object.keys(after).sort()).toEqual(['download', 'owner', 'read', 'write']);
|
|
376
|
+
for (const v of Object.values(after)) expect(typeof v).toBe('boolean');
|
|
377
|
+
|
|
378
|
+
// A stranger gets their own answer, not the caller's.
|
|
379
|
+
expect(await svc.previewAccessAfterRelocation(workspaceId, 'stranger@x.io', 'Sales', 'Revenue'))
|
|
380
|
+
.toMatchObject({ read: false, owner: false });
|
|
381
|
+
|
|
382
|
+
expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('owner:');
|
|
383
|
+
await expect(fs.access(path.join(repo, 'Revenue'))).rejects.toThrow();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -9,11 +9,12 @@ import { AccessControlService } from '../access-control.service.js';
|
|
|
9
9
|
import { holderPrincipals } from '../access-view.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* `prospectiveHolders`: who can open and edit a file where it
|
|
13
|
-
* could once a move puts it somewhere else. Driven over a real
|
|
14
|
-
* through the public resolver, because the whole point is that
|
|
15
|
-
* side must be the SAME resolution as a real one — the
|
|
16
|
-
*
|
|
12
|
+
* `prospectiveHolders`: who can open and edit a file or folder where it
|
|
13
|
+
* sits, and who could once a move puts it somewhere else. Driven over a real
|
|
14
|
+
* on-disk tree through the public resolver, because the whole point is that
|
|
15
|
+
* the "after" side must be the SAME resolution as a real one — the
|
|
16
|
+
* destination's folder chain with whatever travels laid over it, a file's own
|
|
17
|
+
* frontmatter or a folder's `access.md` files — for a path that does not
|
|
17
18
|
* exist yet and therefore cannot be asked for in the ordinary way.
|
|
18
19
|
*/
|
|
19
20
|
|
|
@@ -136,25 +137,100 @@ describe('AccessControlService.prospectiveHolders', () => {
|
|
|
136
137
|
expect(holderPrincipals(before.read)).toContainEqual({ kind: 'group', name: 'Engineering' });
|
|
137
138
|
});
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
/**
|
|
141
|
+
* A folder carries its own `access.md`, and that file MOVES WITH IT. The
|
|
142
|
+
* resolver used to refuse the question rather than answer it as if the
|
|
143
|
+
* folder were a file — the right refusal for the wrong reason, since the
|
|
144
|
+
* answer it could not give is the one a folder move actually needs.
|
|
145
|
+
*/
|
|
146
|
+
it('answers a folder source with the rules it carries, at the path they land on', async () => {
|
|
140
147
|
const svc = await makeService(TREE);
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
const { before, after } = await svc.prospectiveHolders(
|
|
150
|
+
workspaceId,
|
|
151
|
+
'Knowledge/Legal',
|
|
152
|
+
'Knowledge/Sales/Legal',
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// `Knowledge/Legal/access.md` travels, so Engineering keeps both verbs at
|
|
156
|
+
// the new path — the rules the folder brings, not the ones it is landing
|
|
157
|
+
// among. Read as a FILE the answer would have been Product's, from
|
|
158
|
+
// `Knowledge/Sales/access.md` alone.
|
|
159
|
+
expect(names(before.read)).toContain('Engineering');
|
|
160
|
+
expect(names(before.write)).toContain('Engineering');
|
|
161
|
+
expect(names(after.read)).toContain('Engineering');
|
|
162
|
+
expect(names(after.write)).toContain('Engineering');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('leaves the old parent behind and picks the new one up', async () => {
|
|
166
|
+
const svc = await makeService({
|
|
167
|
+
...TREE,
|
|
168
|
+
// Engineering's write on Team comes from Legal above it, not from Team.
|
|
169
|
+
'Knowledge/Legal/Team/access.md': rules('read:\n - Product\n'),
|
|
170
|
+
'Knowledge/Legal/Team/plan.md': '# Plan\n',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const { before, after } = await svc.prospectiveHolders(
|
|
174
|
+
workspaceId,
|
|
175
|
+
'Knowledge/Legal/Team',
|
|
176
|
+
'Knowledge/Sales/Team',
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(names(before.write)).toContain('Engineering');
|
|
180
|
+
// Sales grants nobody write, and Team's own file grants only read — so
|
|
181
|
+
// the write Team held by inheritance does not come along.
|
|
182
|
+
expect(names(after.write)).not.toContain('Engineering');
|
|
183
|
+
expect(names(after.read)).toContain('Product');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('a rename of a folder that grants its own access changes nothing', async () => {
|
|
187
|
+
const svc = await makeService(TREE);
|
|
188
|
+
|
|
189
|
+
const { before, after } = await svc.prospectiveHolders(
|
|
190
|
+
workspaceId,
|
|
191
|
+
'Knowledge/Legal',
|
|
192
|
+
'Knowledge/Revenue',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(names(after.read)).toEqual(names(before.read));
|
|
196
|
+
expect(names(after.write)).toEqual(names(before.write));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A SYMLINK is neither of the two shapes the preview knows. `move_file`
|
|
201
|
+
* refuses a symlink source outright (`assertNoSymlinkOnPath`), so a link to
|
|
202
|
+
* a directory must not be described as a folder taking its `access.md`
|
|
203
|
+
* files along — that would promise the target's rules at a path nothing is
|
|
204
|
+
* ever going to arrive at. Nothing is read through the link either.
|
|
205
|
+
*/
|
|
206
|
+
it('a symlink to a folder is not previewed as the folder it points at', async () => {
|
|
207
|
+
const svc = await makeService(TREE);
|
|
208
|
+
const repo = path.join(root, workspaceId, KB_DIR);
|
|
209
|
+
await fs.symlink(path.join(repo, 'Knowledge/Legal'), path.join(repo, 'Knowledge/Sales/alias'));
|
|
210
|
+
|
|
211
|
+
const { after } = await svc.prospectiveHolders(
|
|
212
|
+
workspaceId,
|
|
213
|
+
'Knowledge/Sales/alias',
|
|
214
|
+
'Knowledge/alias',
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Legal's own rules stayed behind the link: `Knowledge/` has no
|
|
218
|
+
// `access.md`, so nobody holds anything at the destination.
|
|
219
|
+
expect(names(after.read)).not.toContain('Engineering');
|
|
220
|
+
expect(names(after.write)).not.toContain('Engineering');
|
|
148
221
|
});
|
|
149
222
|
|
|
150
223
|
it('a probe that fails for any reason but absence throws, never a silent "file"', async () => {
|
|
151
224
|
const svc = await makeService(TREE);
|
|
152
|
-
// A disk fault (EACCES here) must never read as "not a directory":
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
225
|
+
// A disk fault (EACCES here) must never read as "not a directory": the
|
|
226
|
+
// probe is what chooses between the two resolutions, so a folder read as
|
|
227
|
+
// a file would be answered with frontmatter it does not have and without
|
|
228
|
+
// the `access.md` files it carries — the wrong principals, named with
|
|
229
|
+
// the same confidence as the right ones. Spied rather than staged with
|
|
230
|
+
// mode bits, so the property holds as root and on Windows too. `lstat`,
|
|
231
|
+
// because that is the probe: a symlink is classified by what it IS.
|
|
232
|
+
const real = fs.lstat;
|
|
233
|
+
const spy = vi.spyOn(fs, 'lstat').mockImplementation(((p: string) =>
|
|
158
234
|
String(p).endsWith('contract.md')
|
|
159
235
|
? Promise.reject(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
|
|
160
236
|
: (real as (p: string) => Promise<unknown>).call(fs, p)) as never);
|
|
@@ -10,7 +10,6 @@ import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
|
10
10
|
import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
11
11
|
import type { Database } from '../../database/connection.js';
|
|
12
12
|
import { createAccessRoutes } from '../access.routes.js';
|
|
13
|
-
import { WorkflowDomainError } from '../../../shared/domain-errors.js';
|
|
14
13
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -192,15 +191,15 @@ describe('GET /access/prospective', () => {
|
|
|
192
191
|
expect(await res.json()).toEqual({ error: 'Internal error.' });
|
|
193
192
|
});
|
|
194
193
|
|
|
195
|
-
it('
|
|
194
|
+
it('asks about a folder source the same way, keeping its name at the destination', async () => {
|
|
196
195
|
h = await makeHarness();
|
|
197
|
-
h.prospectiveHolders.mockRejectedValueOnce(
|
|
198
|
-
new WorkflowDomainError('prospective access answers for a file, not a folder', 400),
|
|
199
|
-
);
|
|
200
196
|
|
|
201
|
-
const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir
|
|
197
|
+
const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir=${encodeURIComponent('Knowledge/Sales')}`);
|
|
202
198
|
|
|
203
|
-
expect(res.status).toBe(
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
// The route derives the destination from the name, folder or file alike;
|
|
201
|
+
// which of the two it is, and what travels with it, is the resolver's.
|
|
202
|
+
expect(h.prospectiveHolders).toHaveBeenCalledWith(WS, 'Knowledge/Legal', 'Knowledge/Sales/Legal');
|
|
204
203
|
});
|
|
205
204
|
|
|
206
205
|
it('falls back to the name-only roles list when the resolver omits kinds', async () => {
|
|
@@ -84,11 +84,14 @@ export type HolderList = {
|
|
|
84
84
|
export type PathHolders = { read: HolderList; write: HolderList };
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* One file's holders where it is now and where a move would put
|
|
88
|
-
* {@link IAccessControl.prospectiveHolders}.
|
|
87
|
+
* One file's or folder's holders where it is now and where a move would put
|
|
88
|
+
* it — see {@link IAccessControl.prospectiveHolders}.
|
|
89
89
|
*/
|
|
90
90
|
export type ProspectiveHolders = { before: PathHolders; after: PathHolders };
|
|
91
91
|
|
|
92
|
+
/** One caller's own verdict per access verb on one path. */
|
|
93
|
+
export type PathVerbs = { read: boolean; write: boolean; download: boolean; owner: boolean };
|
|
94
|
+
|
|
92
95
|
/**
|
|
93
96
|
* Per-verb sources of a principal's access on a target. Only verbs the principal
|
|
94
97
|
* actually holds (via a named file entry) appear; each maps to the closest-first
|
|
@@ -358,19 +361,17 @@ export interface IAccessControl {
|
|
|
358
361
|
}>;
|
|
359
362
|
|
|
360
363
|
/**
|
|
361
|
-
* Who can open and who can edit one file where it IS, and where a
|
|
362
|
-
* would put it. `toPath` names a path that does not exist yet — the
|
|
363
|
-
* of the call is to answer before the move happens — so the
|
|
364
|
-
*
|
|
365
|
-
*
|
|
364
|
+
* Who can open and who can edit one file or folder where it IS, and where a
|
|
365
|
+
* move would put it. `toPath` names a path that does not exist yet — the
|
|
366
|
+
* point of the call is to answer before the move happens — so the
|
|
367
|
+
* resolution is over what travels with the bytes: a file's OWN rules (its
|
|
368
|
+
* frontmatter, read from `fromPath`) layered over the destination's folder
|
|
369
|
+
* chain, or a folder's `access.md` files keyed at the directories they
|
|
370
|
+
* land in. Either way the rules the source inherited from its old parent
|
|
371
|
+
* are left behind and the new parent's apply.
|
|
366
372
|
*
|
|
367
373
|
* Writes nothing and moves nothing. The move confirmation diffs the two
|
|
368
374
|
* sides to name who loses and who gains access.
|
|
369
|
-
*
|
|
370
|
-
* A FILE question only: a `fromPath` that is a directory is refused with a
|
|
371
|
-
* 400. A folder's access is its own `access.md` — which moves with it and
|
|
372
|
-
* governs everything beneath it — so resolving it as a file would name the
|
|
373
|
-
* wrong principals with the same confidence as the right ones.
|
|
374
375
|
*/
|
|
375
376
|
prospectiveHolders(
|
|
376
377
|
workspaceId: string,
|
|
@@ -378,6 +379,36 @@ export interface IAccessControl {
|
|
|
378
379
|
toPath: string,
|
|
379
380
|
): Promise<ProspectiveHolders>;
|
|
380
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The CALLER'S OWN `{ read, write, download, owner }` at `toPath` as they
|
|
384
|
+
* will be once `fromPath` has been moved (`sourceRemains: false`, the
|
|
385
|
+
* default) or copied (`sourceRemains: true`) there — the question a move's
|
|
386
|
+
* or copy's dry run asks, which the same four gates at `toPath` cannot
|
|
387
|
+
* answer because what travels with the bytes is not at the destination yet:
|
|
388
|
+
* the `access.md` files inside a folder, keyed at the directories they land
|
|
389
|
+
* in, and a file's own frontmatter.
|
|
390
|
+
*
|
|
391
|
+
* What a single file's rules GOVERN follows the name it lands under, not the
|
|
392
|
+
* one it leaves: landing as a directory's `access.md` they are that
|
|
393
|
+
* directory's rules (so copying `Sales/access.md` to `Legal/access.md`
|
|
394
|
+
* previews the access `Legal/` is about to have), and landing under any
|
|
395
|
+
* other name they are the file's own and govern nothing but it. Either way
|
|
396
|
+
* the answer is the one the gates will give a moment later.
|
|
397
|
+
*
|
|
398
|
+
* PREVIEW ONLY. It describes a tree that does not exist, so it must never
|
|
399
|
+
* decide whether an operation may run: the gates keep resolving against the
|
|
400
|
+
* tree that does. It writes nothing and moves nothing, and it answers the
|
|
401
|
+
* caller's own verbs only — never the content of an `access.md` they may
|
|
402
|
+
* not read.
|
|
403
|
+
*/
|
|
404
|
+
previewAccessAfterRelocation(
|
|
405
|
+
workspaceId: string,
|
|
406
|
+
userEmail: string,
|
|
407
|
+
fromPath: string,
|
|
408
|
+
toPath: string,
|
|
409
|
+
opts?: { sourceRemains?: boolean },
|
|
410
|
+
): Promise<PathVerbs>;
|
|
411
|
+
|
|
381
412
|
/**
|
|
382
413
|
* Finite expanded email set for configured users who could approve this path
|
|
383
414
|
* — role members + direct user grants, minus anyone denied. The built-in
|