@bevel-software/platform-core-backend 0.29.0 → 0.31.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/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +3 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +35 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +135 -17
- 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 +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +3 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +3 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +10 -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/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +142 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +645 -128
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +9 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +3 -2
- package/src/core/create-core-services.ts +39 -4
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +138 -15
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +3 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +10 -2
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +719 -126
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +23 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/no-link-below.ts +29 -0
- package/src/test-setup.ts +5 -0
|
@@ -4,6 +4,7 @@ import express from 'express';
|
|
|
4
4
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
5
5
|
|
|
6
6
|
import type { IAccessControl } from '../access-control.interface.js';
|
|
7
|
+
import { AccessControlService } from '../access-control.service.js';
|
|
7
8
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
8
9
|
import type { AuthService } from '../../auth/auth.service.js';
|
|
9
10
|
import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
@@ -31,6 +32,10 @@ async function makeHarness(opts: {
|
|
|
31
32
|
files?: Record<string, string>;
|
|
32
33
|
roles?: string[];
|
|
33
34
|
groups?: string[];
|
|
35
|
+
/** Whether the caller is an admin, who is offered every group. Defaults to true. */
|
|
36
|
+
admin?: boolean;
|
|
37
|
+
/** The groups the caller is a member of — all a non-admin is offered, or may grant to. */
|
|
38
|
+
memberOf?: string[];
|
|
34
39
|
}): Promise<{ server: Server; baseUrl: string; files: Map<string, string> }> {
|
|
35
40
|
const files = new Map<string, string>(Object.entries(opts.files ?? {}));
|
|
36
41
|
|
|
@@ -46,6 +51,12 @@ async function makeHarness(opts: {
|
|
|
46
51
|
groups: opts.groups ?? [],
|
|
47
52
|
people: [],
|
|
48
53
|
})),
|
|
54
|
+
holdsAdminRootWrite: vi.fn(async () => opts.admin ?? true),
|
|
55
|
+
groupsOfUser: vi.fn(async () => opts.memberOf ?? []),
|
|
56
|
+
// The real policy over the two stubs above: who sees which group is the
|
|
57
|
+
// resolver's one answer, not something a route (or this double) decides.
|
|
58
|
+
visibleGroups: (wsId: string, email: string, groups: readonly string[]) =>
|
|
59
|
+
AccessControlService.prototype.visibleGroups.call(accessControl, wsId, email, groups),
|
|
49
60
|
eligibleWriters: vi.fn(async () => ({ roles: [], users: [] })),
|
|
50
61
|
eligibleReaders: vi.fn(async () => ({ restricted: true, roles: [], users: [] })),
|
|
51
62
|
eligibleOwners: vi.fn(async () => ({ roles: [], users: [] })),
|
|
@@ -213,6 +224,61 @@ describe('group principals on the share surface', () => {
|
|
|
213
224
|
expect(body.plugins).toEqual(body.roles);
|
|
214
225
|
});
|
|
215
226
|
|
|
227
|
+
it("suggest offers someone who is not an admin only the groups they are in, never another group's name", async () => {
|
|
228
|
+
h = await makeHarness({ roles: ['Everyone'], groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
|
|
229
|
+
const res = await fetch(`${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/suggest?q=`);
|
|
230
|
+
expect(res.status).toBe(200);
|
|
231
|
+
const body = (await res.json()) as { roles: string[]; groups: string[] };
|
|
232
|
+
expect(body.groups).toEqual(['Product']);
|
|
233
|
+
expect(body.roles).toEqual(['Everyone']); // roles are not withheld
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('suggest offers someone in no group, who is not an admin, no groups at all', async () => {
|
|
237
|
+
h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false });
|
|
238
|
+
const res = await fetch(`${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/suggest?q=`);
|
|
239
|
+
const body = (await res.json()) as { groups: string[] };
|
|
240
|
+
expect(body.groups).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('a non-admin grants to a group they are in', async () => {
|
|
244
|
+
h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
|
|
245
|
+
const res = await post('grant', {
|
|
246
|
+
path: `${KB}/Sales`,
|
|
247
|
+
kind: 'folder',
|
|
248
|
+
verb: 'read',
|
|
249
|
+
principal: { kind: 'group', group: 'Product' },
|
|
250
|
+
});
|
|
251
|
+
expect(res.status).toBe(200);
|
|
252
|
+
expect(h.files.get(`${KB}/Sales/access.md`)).toContain('- Product');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a non-admin granting to a group they are not in is refused exactly like an unknown group', async () => {
|
|
256
|
+
h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
|
|
257
|
+
const res = await post('grant', {
|
|
258
|
+
path: `${KB}/Sales`,
|
|
259
|
+
kind: 'folder',
|
|
260
|
+
verb: 'read',
|
|
261
|
+
principal: { kind: 'group', group: 'GTM Team' },
|
|
262
|
+
});
|
|
263
|
+
// Same status, same body as a group that does not exist — the WHOLE
|
|
264
|
+
// answer, message included, with only the name asked about differing —
|
|
265
|
+
// so the grant route confirms no group name the picker would not have
|
|
266
|
+
// offered.
|
|
267
|
+
const ghost = await post('grant', {
|
|
268
|
+
path: `${KB}/Sales`,
|
|
269
|
+
kind: 'folder',
|
|
270
|
+
verb: 'read',
|
|
271
|
+
principal: { kind: 'group', group: 'Ghost Team' },
|
|
272
|
+
});
|
|
273
|
+
expect(res.status).toBe(404);
|
|
274
|
+
expect(ghost.status).toBe(404);
|
|
275
|
+
const hiddenBody = (await res.json()) as Record<string, unknown>;
|
|
276
|
+
const ghostBody = (await ghost.json()) as Record<string, unknown>;
|
|
277
|
+
expect(hiddenBody).toMatchObject({ kind: 'unknown-group', group: 'GTM Team' });
|
|
278
|
+
expect(JSON.stringify(hiddenBody).replaceAll('GTM Team', 'Ghost Team')).toBe(JSON.stringify(ghostBody));
|
|
279
|
+
expect(h.files.has(`${KB}/Sales/access.md`)).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
216
282
|
it('revokes with a group principal (name-based, no existence check)', async () => {
|
|
217
283
|
h = await makeHarness({
|
|
218
284
|
groups: ['GTM Team'],
|
|
@@ -55,6 +55,8 @@ async function makeHarness(opts: {
|
|
|
55
55
|
grantSources: vi.fn(async () => ({ read: [{ kind: 'direct' }] })),
|
|
56
56
|
invalidate: vi.fn(),
|
|
57
57
|
kbPrincipals: vi.fn(async () => ({ roles: [], groups: [], people: opts.kbPeople ?? [] })),
|
|
58
|
+
// No groups in this source, so the answer is the empty list the service gives for one.
|
|
59
|
+
visibleGroups: vi.fn(async (_w: string, _e: string, groups: readonly string[]) => [...groups]),
|
|
58
60
|
eligibleWriters: vi.fn(async () => ({ roles: [], users: granted })),
|
|
59
61
|
eligibleReaders: vi.fn(async () => ({ restricted: true, roles: [], users: granted })),
|
|
60
62
|
eligibleOwners: vi.fn(async () => ({ roles: [], users: [] })),
|
|
@@ -234,6 +234,28 @@ export interface IAccessControl {
|
|
|
234
234
|
*/
|
|
235
235
|
canReadAsEveryoneBatch(workspaceId: string, relativePaths: string[]): Promise<Map<string, boolean>>;
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The display names of the groups `userEmail` is a member of, in the
|
|
239
|
+
* active group source (`groups.yaml`, or the IdP-synced file), spelled as
|
|
240
|
+
* `kbPrincipals` spells them. Direct membership only: a role that lists a
|
|
241
|
+
* group does not make its holders members of it. Empty when the person is
|
|
242
|
+
* in no group.
|
|
243
|
+
*/
|
|
244
|
+
groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]>;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Which of `groups` (names from the active group source) `userEmail` may
|
|
248
|
+
* SEE: every one for an admin, who manages them all, and only the groups
|
|
249
|
+
* they are a member of ({@link groupsOfUser}) for anyone else. A group's
|
|
250
|
+
* name is information — on a deployment that gives each client, author or
|
|
251
|
+
* customer a group of its own, the list of groups is the client list — so
|
|
252
|
+
* this ONE answer is what the Library's Groups tab lists, what the share
|
|
253
|
+
* dialog offers as a grantee, and what a grant may name; a group outside it
|
|
254
|
+
* is treated as one that does not exist. Order and spelling of `groups` are
|
|
255
|
+
* kept.
|
|
256
|
+
*/
|
|
257
|
+
visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]>;
|
|
258
|
+
|
|
237
259
|
/**
|
|
238
260
|
* Batched canWrite for PR diffs and commit-time gating. Returns a map keyed
|
|
239
261
|
* by the input paths, with `true` / `false` for each. Reuses one config
|
|
@@ -5,9 +5,10 @@ import { logger } from '../../shared/logging.js';
|
|
|
5
5
|
const log = logger('access');
|
|
6
6
|
|
|
7
7
|
import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.contract.js';
|
|
8
|
-
import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
8
|
+
import { assertOneSpecPerLine, isCommitSha, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
9
9
|
import { hashEmail } from '../../shared/email-identity.js';
|
|
10
10
|
import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
|
|
11
|
+
import { readRevSha } from '../kb-fs/index.js';
|
|
11
12
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
12
13
|
import type {
|
|
13
14
|
IAccessControl,
|
|
@@ -1370,7 +1371,13 @@ function ineligibleNamedEmailsResolved(
|
|
|
1370
1371
|
* was built from (not the ref name that led there), so the per-file
|
|
1371
1372
|
* own-entries reads that follow a gate resolve against exactly that tree.
|
|
1372
1373
|
*/
|
|
1373
|
-
|
|
1374
|
+
/**
|
|
1375
|
+
* A model built at a commit, with the commit it was built at and, when the
|
|
1376
|
+
* tree is not too large to hold, every path the commit carries: what the
|
|
1377
|
+
* own-entries reads need to tell a path that is absent at the commit (a
|
|
1378
|
+
* fact, remembered) from a blob the batch could not read (never remembered).
|
|
1379
|
+
*/
|
|
1380
|
+
type AtRefModel = { model: AccessModel; resolvedRef: string; tree: ReadonlySet<string> | null };
|
|
1374
1381
|
/**
|
|
1375
1382
|
* One build's outcome: a model, no roles.yaml at the commit, or one that
|
|
1376
1383
|
* doesn't parse. A git read failure is none of these: it throws
|
|
@@ -1440,6 +1447,25 @@ export class AccessControlService implements IAccessControl {
|
|
|
1440
1447
|
private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
|
|
1441
1448
|
private static readonly AT_REF_CACHE_MAX = 512;
|
|
1442
1449
|
|
|
1450
|
+
/**
|
|
1451
|
+
* A path's own access entries AT A COMMIT, keyed by commit + path. A blob
|
|
1452
|
+
* at a commit never changes, so neither does what it declares for itself:
|
|
1453
|
+
* no TTL, no invalidation, and no workspace in the key (every clone holds
|
|
1454
|
+
* the same objects). This is the read every at-ref gate makes after the
|
|
1455
|
+
* model: one `cat-file --batch` per call for the paths asked, which a
|
|
1456
|
+
* change-request listing made once per ref per listing for the same paths
|
|
1457
|
+
* at the same commits. Bounded FIFO; the bound only caps memory.
|
|
1458
|
+
*/
|
|
1459
|
+
private readonly ownEntriesAtCommit = new Map<string, OwnEntries | null>();
|
|
1460
|
+
private static readonly OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
|
|
1461
|
+
/** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
|
|
1462
|
+
private static readonly TREE_INDEX_MAX_PATHS = 20_000;
|
|
1463
|
+
/** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
|
|
1464
|
+
private static readonly TREE_INDEX_BUDGET = 500_000;
|
|
1465
|
+
private readonly treeIndexMaxPaths: number;
|
|
1466
|
+
private readonly treeIndexBudget: number;
|
|
1467
|
+
private treePathsHeld = 0;
|
|
1468
|
+
|
|
1443
1469
|
/**
|
|
1444
1470
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
1445
1471
|
* Held on the service rather than read per-model because it comes from the
|
|
@@ -1473,10 +1499,19 @@ export class AccessControlService implements IAccessControl {
|
|
|
1473
1499
|
* run on the default layout.
|
|
1474
1500
|
*/
|
|
1475
1501
|
private readonly kb: Pick<KbContext, 'layout'> = { layout: DEFAULT_KB_LAYOUT },
|
|
1502
|
+
/**
|
|
1503
|
+
* How much of the commits' trees the at-ref models may hold (see
|
|
1504
|
+
* `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
|
|
1505
|
+
* deployment's; a suite lowers them to reach the bounds with a small
|
|
1506
|
+
* repository.
|
|
1507
|
+
*/
|
|
1508
|
+
limits: { treeIndexMaxPaths?: number; treeIndexBudget?: number } = {},
|
|
1476
1509
|
) {
|
|
1477
1510
|
this.deploymentOwners = new Set(
|
|
1478
1511
|
deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)),
|
|
1479
1512
|
);
|
|
1513
|
+
this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
|
|
1514
|
+
this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
|
|
1480
1515
|
}
|
|
1481
1516
|
|
|
1482
1517
|
/**
|
|
@@ -1618,6 +1653,23 @@ export class AccessControlService implements IAccessControl {
|
|
|
1618
1653
|
return result;
|
|
1619
1654
|
}
|
|
1620
1655
|
|
|
1656
|
+
async groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]> {
|
|
1657
|
+
const model = await this.loadModel(workspaceId);
|
|
1658
|
+
const email = canonicalEmail(userEmail);
|
|
1659
|
+
const names: string[] = [];
|
|
1660
|
+
for (const principal of model.roles.byCanonical.values()) {
|
|
1661
|
+
if (principal.kind === 'group' && principal.emails.has(email)) names.push(principal.displayName);
|
|
1662
|
+
}
|
|
1663
|
+
return names;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]> {
|
|
1667
|
+
if (groups.length === 0) return [];
|
|
1668
|
+
if (await this.holdsAdminRootWrite(workspaceId, userEmail)) return [...groups];
|
|
1669
|
+
const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
|
|
1670
|
+
return groups.filter((g) => mine.has(g));
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1621
1673
|
async canWriteBatch(
|
|
1622
1674
|
workspaceId: string,
|
|
1623
1675
|
userEmail: string,
|
|
@@ -2135,7 +2187,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2135
2187
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2136
2188
|
if (!loaded) return null;
|
|
2137
2189
|
const repoDir = await this.repoDir(workspaceId);
|
|
2138
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2190
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2139
2191
|
return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
|
|
2140
2192
|
}
|
|
2141
2193
|
|
|
@@ -2148,7 +2200,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2148
2200
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2149
2201
|
if (!loaded) return null;
|
|
2150
2202
|
const repoDir = await this.repoDir(workspaceId);
|
|
2151
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2203
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2152
2204
|
return canReadResolved(loaded.model, userEmail, relativePath, own);
|
|
2153
2205
|
}
|
|
2154
2206
|
|
|
@@ -2171,7 +2223,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2171
2223
|
const repoDir = await this.repoDir(workspaceId);
|
|
2172
2224
|
// One `git cat-file --batch` for the whole path set — a per-path `git
|
|
2173
2225
|
// show` spawn made CR owner-routing take minutes on large change sets.
|
|
2174
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2226
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2175
2227
|
const result = new Map<string, boolean>();
|
|
2176
2228
|
for (const p of relativePaths) {
|
|
2177
2229
|
const machineOwned = machineAnswers.get(p);
|
|
@@ -2196,7 +2248,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2196
2248
|
if (!loaded) return null;
|
|
2197
2249
|
const repoDir = await this.repoDir(workspaceId);
|
|
2198
2250
|
// One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
|
|
2199
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2251
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2200
2252
|
const result = new Map<string, boolean>();
|
|
2201
2253
|
for (const p of relativePaths) {
|
|
2202
2254
|
result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
|
|
@@ -2219,7 +2271,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2219
2271
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2220
2272
|
if (!loaded) return null;
|
|
2221
2273
|
const repoDir = await this.repoDir(workspaceId);
|
|
2222
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2274
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2223
2275
|
return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
|
|
2224
2276
|
}
|
|
2225
2277
|
|
|
@@ -2333,7 +2385,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2333
2385
|
if (!loaded) return null;
|
|
2334
2386
|
const repoDir = await this.repoDir(workspaceId);
|
|
2335
2387
|
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
2336
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2388
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2337
2389
|
for (const p of relativePaths) {
|
|
2338
2390
|
if (result.has(p)) continue;
|
|
2339
2391
|
const own = owns.get(p) ?? null;
|
|
@@ -2551,8 +2603,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2551
2603
|
repoDir: string,
|
|
2552
2604
|
ref: string,
|
|
2553
2605
|
relativePath: string,
|
|
2606
|
+
tree: ReadonlySet<string> | null = null,
|
|
2554
2607
|
): Promise<OwnEntries | null> {
|
|
2555
2608
|
if (relativePath === 'roles.yaml') return null;
|
|
2609
|
+
// At a commit (what every gate passes: `resolvedRef`), the batched read
|
|
2610
|
+
// and its memo answer; a single `git show` stays for a symbolic ref.
|
|
2611
|
+
if (isCommitSha(ref)) {
|
|
2612
|
+
return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
|
|
2613
|
+
}
|
|
2556
2614
|
const text = await this.showAtRef(repoDir, ref, relativePath);
|
|
2557
2615
|
if (text === null) return null;
|
|
2558
2616
|
if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
|
|
@@ -2571,23 +2629,60 @@ export class AccessControlService implements IAccessControl {
|
|
|
2571
2629
|
repoDir: string,
|
|
2572
2630
|
ref: string,
|
|
2573
2631
|
relativePaths: string[],
|
|
2632
|
+
tree: ReadonlySet<string> | null = null,
|
|
2574
2633
|
): Promise<Map<string, OwnEntries | null>> {
|
|
2575
2634
|
const result = new Map<string, OwnEntries | null>();
|
|
2576
2635
|
const wanted: string[] = [];
|
|
2636
|
+
const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
|
|
2577
2637
|
for (const p of relativePaths) {
|
|
2578
|
-
if (p === 'roles.yaml')
|
|
2579
|
-
|
|
2638
|
+
if (p === 'roles.yaml') {
|
|
2639
|
+
result.set(p, null);
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2642
|
+
const key = `${ref}\0${p}`;
|
|
2643
|
+
if (memo?.has(key)) {
|
|
2644
|
+
result.set(p, memo.get(key) ?? null);
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2647
|
+
// A path the commit's tree does not carry has no own entries, as a
|
|
2648
|
+
// fact about the commit: remembered, and not read. (A change request
|
|
2649
|
+
// that adds a file asks about it at the base, where it does not exist
|
|
2650
|
+
// yet; one that deletes a file asks at the head.)
|
|
2651
|
+
if (tree && !tree.has(p)) {
|
|
2652
|
+
result.set(p, null);
|
|
2653
|
+
if (memo) this.rememberOwnEntriesAtCommit(key, null);
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
if (!wanted.includes(p)) wanted.push(p);
|
|
2580
2657
|
}
|
|
2581
2658
|
if (wanted.length === 0) return result;
|
|
2582
2659
|
const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
|
|
2583
2660
|
wanted.forEach((p, i) => {
|
|
2584
2661
|
const text = texts[i];
|
|
2585
|
-
if (text === null)
|
|
2586
|
-
|
|
2662
|
+
if (text === null) {
|
|
2663
|
+
// Absent at the commit (when no tree was there to say so first), or
|
|
2664
|
+
// a blob git could not read: the batch says "missing" for both, and
|
|
2665
|
+
// only the first is a fact about the commit. Answered as no own
|
|
2666
|
+
// entries, as before, but never remembered.
|
|
2667
|
+
result.set(p, null);
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
|
|
2671
|
+
result.set(p, own);
|
|
2672
|
+
// What a blob declares for itself — including nothing — is a fact about the commit.
|
|
2673
|
+
if (memo) this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
|
|
2587
2674
|
});
|
|
2588
2675
|
return result;
|
|
2589
2676
|
}
|
|
2590
2677
|
|
|
2678
|
+
private rememberOwnEntriesAtCommit(key: string, value: OwnEntries | null): void {
|
|
2679
|
+
this.ownEntriesAtCommit.set(key, value);
|
|
2680
|
+
if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
|
|
2681
|
+
const oldest = this.ownEntriesAtCommit.keys().next().value;
|
|
2682
|
+
if (oldest !== undefined) this.ownEntriesAtCommit.delete(oldest);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2591
2686
|
/**
|
|
2592
2687
|
* Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
|
|
2593
2688
|
* for short branch names, matching the heuristic in
|
|
@@ -2649,11 +2744,12 @@ export class AccessControlService implements IAccessControl {
|
|
|
2649
2744
|
private async listAccessFilesAtRef(
|
|
2650
2745
|
repoDir: string,
|
|
2651
2746
|
ref: string,
|
|
2652
|
-
): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string
|
|
2747
|
+
): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string>; all: string[] } | 'error'> {
|
|
2653
2748
|
try {
|
|
2654
2749
|
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2655
2750
|
const out: string[] = [];
|
|
2656
2751
|
const pluginDirs: string[] = [];
|
|
2752
|
+
const all: string[] = [];
|
|
2657
2753
|
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2658
2754
|
// files): a read of one of these that comes back empty is "absent"
|
|
2659
2755
|
// only when the tree does not name it.
|
|
@@ -2661,13 +2757,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
2661
2757
|
const { pluginsDir } = this.kb.layout;
|
|
2662
2758
|
for (const p of stdout.split('\0')) {
|
|
2663
2759
|
if (!p) continue;
|
|
2760
|
+
// One past the cap is enough to know the tree is not kept.
|
|
2761
|
+
if (all.length <= this.treeIndexMaxPaths) all.push(p);
|
|
2664
2762
|
if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
|
|
2665
2763
|
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
|
|
2666
2764
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2667
2765
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2668
2766
|
}
|
|
2669
2767
|
}
|
|
2670
|
-
return { accessFiles: out, pluginDirs, named };
|
|
2768
|
+
return { accessFiles: out, pluginDirs, named, all };
|
|
2671
2769
|
} catch {
|
|
2672
2770
|
return 'error';
|
|
2673
2771
|
}
|
|
@@ -2742,6 +2840,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2742
2840
|
/** The commit `ref` points at in this clone, or null when it doesn't resolve. */
|
|
2743
2841
|
private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
|
|
2744
2842
|
if (!ref || ref.startsWith('-')) return null;
|
|
2843
|
+
// The ref files first (`readRevSha`): the names git would try, in git's
|
|
2844
|
+
// order, read without a process. "Missing" from the files is what
|
|
2845
|
+
// rev-parse answers nothing for; "unreadable" — a spelling the reader
|
|
2846
|
+
// does not resolve, a tag it cannot peel, a file it could not read — is
|
|
2847
|
+
// git's to decide, as the authority.
|
|
2848
|
+
const fromFiles = await readRevSha(repoDir, ref);
|
|
2849
|
+
if (fromFiles === 'missing') return null;
|
|
2850
|
+
if (fromFiles !== 'unreadable') return fromFiles.sha;
|
|
2745
2851
|
try {
|
|
2746
2852
|
const { stdout } = await this.gitRunner.run(repoDir, [
|
|
2747
2853
|
'rev-parse',
|
|
@@ -2762,9 +2868,22 @@ export class AccessControlService implements IAccessControl {
|
|
|
2762
2868
|
|
|
2763
2869
|
private rememberAtRef(key: string, value: AtRefModel): void {
|
|
2764
2870
|
this.atRefCache.set(key, value);
|
|
2871
|
+
this.treePathsHeld += value.tree?.size ?? 0;
|
|
2765
2872
|
if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2766
2873
|
const oldest = this.atRefCache.keys().next().value;
|
|
2767
|
-
if (oldest !== undefined)
|
|
2874
|
+
if (oldest !== undefined) {
|
|
2875
|
+
this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
|
|
2876
|
+
this.atRefCache.delete(oldest);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
// The trees are a budget of their own, across every held model: past
|
|
2880
|
+
// it, the oldest models keep their model and let their tree go — the
|
|
2881
|
+
// gate still answers, absence is just no longer remembered for them.
|
|
2882
|
+
for (const held of this.atRefCache.values()) {
|
|
2883
|
+
if (this.treePathsHeld <= this.treeIndexBudget) break;
|
|
2884
|
+
if (!held.tree) continue;
|
|
2885
|
+
this.treePathsHeld -= held.tree.size;
|
|
2886
|
+
held.tree = null;
|
|
2768
2887
|
}
|
|
2769
2888
|
}
|
|
2770
2889
|
|
|
@@ -2940,6 +3059,10 @@ export class AccessControlService implements IAccessControl {
|
|
|
2940
3059
|
deploymentOwners: this.deploymentOwners,
|
|
2941
3060
|
},
|
|
2942
3061
|
resolvedRef: commit,
|
|
3062
|
+
// The listing already named every path; held so an own-entries read
|
|
3063
|
+
// can tell absence from a lost blob, unless the tree is too large to
|
|
3064
|
+
// hold for every cached commit (then absence is simply not remembered).
|
|
3065
|
+
tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
|
|
2943
3066
|
};
|
|
2944
3067
|
}
|
|
2945
3068
|
}
|
|
@@ -128,6 +128,18 @@ export function createAccessRoutes(
|
|
|
128
128
|
return { roles, groups, plugins: plugins ?? [] };
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The groups `email` may be offered as a grantee, or name in a grant: the
|
|
133
|
+
* resolver's ONE answer to who sees which group (`visibleGroups`: every
|
|
134
|
+
* group for an admin, only their own for anyone else), the same the
|
|
135
|
+
* Library's Groups tab lists. Read on the default branch, where the active
|
|
136
|
+
* group source lives. The picker never names a group the caller has no
|
|
137
|
+
* part in, and the grant route refuses one with the same words as a group
|
|
138
|
+
* that does not exist.
|
|
139
|
+
*/
|
|
140
|
+
const visibleGroups = (email: string, groups: string[]): Promise<string[]> =>
|
|
141
|
+
accessControl.visibleGroups(kb.defaultWorkspaceId(), email, groups);
|
|
142
|
+
|
|
131
143
|
/**
|
|
132
144
|
* The plugins `email` may DISCOVER — read the folder's access.md, the same
|
|
133
145
|
* verdict the plugin index lists on. Both the suggest list and the grant
|
|
@@ -491,8 +503,9 @@ export function createAccessRoutes(
|
|
|
491
503
|
* names — resolved from the DEFAULT branch, the authoritative source),
|
|
492
504
|
* groups (the ACTIVE group source, same authority), and people (branch-local
|
|
493
505
|
* to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
|
|
494
|
-
* file reads per keystroke. Roles
|
|
495
|
-
*
|
|
506
|
+
* file reads per keystroke. Roles are small + non-sensitive so they always
|
|
507
|
+
* show; GROUPS are the caller's own unless they are an admin (see
|
|
508
|
+
* `visibleGroups`); PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
|
|
496
509
|
* can't dump the whole directory (email-harvesting guard). People are the
|
|
497
510
|
* union of the KB-canonical set (roles.yaml + access.md grants) and the
|
|
498
511
|
* `users` table (logged-in users). Results are capped. Each person carries
|
|
@@ -527,7 +540,7 @@ export function createAccessRoutes(
|
|
|
527
540
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
528
541
|
.slice(0, CAP);
|
|
529
542
|
|
|
530
|
-
const matchedGroups = groups
|
|
543
|
+
const matchedGroups = (await visibleGroups(user.email, groups))
|
|
531
544
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
532
545
|
.slice(0, CAP);
|
|
533
546
|
|
|
@@ -934,9 +947,12 @@ export function createAccessRoutes(
|
|
|
934
947
|
} else if (principal.kind === 'group') {
|
|
935
948
|
// A group grant must name a group in the ACTIVE source (IdP-synced or
|
|
936
949
|
// manual) on the default branch — same authority the suggest list
|
|
937
|
-
// uses
|
|
938
|
-
//
|
|
939
|
-
|
|
950
|
+
// uses — and one the caller may see (`visibleGroups`): a group hidden
|
|
951
|
+
// from the picker is refused here with the same words as one that
|
|
952
|
+
// does not exist, so the grant route is no oracle for group names.
|
|
953
|
+
// No role-collision refusal: bare tokens resolve GROUP-FIRST, so the
|
|
954
|
+
// written bare name IS the group.
|
|
955
|
+
const groups = await visibleGroups(user.email, (await defaultBranchPrincipals()).groups);
|
|
940
956
|
const canonical = canonicalRoleName(principal.group);
|
|
941
957
|
if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
|
|
942
958
|
throw new AccessMutationError(
|
|
@@ -358,7 +358,7 @@ describe('the HTML views section', () => {
|
|
|
358
358
|
expect(ids.indexOf('html-views')).toBe(ids.indexOf('tool-manuals') + 1);
|
|
359
359
|
});
|
|
360
360
|
|
|
361
|
-
it('describes the frame, the
|
|
361
|
+
it('describes the frame, the members core puts on window.bevel, and a fragment that scrolls the page', async () => {
|
|
362
362
|
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
|
|
363
363
|
expect(text).toContain('**No network.**');
|
|
364
364
|
expect(text).toContain('**Inline scripts and styles only.**');
|
|
@@ -374,6 +374,27 @@ describe('the HTML views section', () => {
|
|
|
374
374
|
expect(docs).toContain('/workspace/main/Docs/Alice.md');
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
+
it('documents window.bevel.tools: its four functions, the null check, the consent rule and requireTools with its card', async () => {
|
|
378
|
+
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
|
|
379
|
+
for (const member of [
|
|
380
|
+
'`window.bevel.tools.searchTools(query)`',
|
|
381
|
+
'`window.bevel.tools.toolsInfo(names)`',
|
|
382
|
+
'`window.bevel.tools.callTool(name, args)`',
|
|
383
|
+
'`window.bevel.tools.callToolChain(code)`',
|
|
384
|
+
'`window.bevel.requireTools()`',
|
|
385
|
+
]) {
|
|
386
|
+
expect(text).toContain(member);
|
|
387
|
+
}
|
|
388
|
+
expect(text).toContain('`window.bevel.tools` is `null` wherever tools are not available');
|
|
389
|
+
expect(text).toContain('Check it before every use');
|
|
390
|
+
expect(text).toContain('**Reading runs at once; writing asks first.**');
|
|
391
|
+
expect(text).toContain('"<page name> wants to write to the knowledge base"');
|
|
392
|
+
expect(text).toContain('Allow while open and Deny');
|
|
393
|
+
expect(text).toContain('rejects the call with `denied`');
|
|
394
|
+
expect(text).toContain('"<page name> uses tools that are only available in the platform"');
|
|
395
|
+
expect(text).toContain('Continue without tools and Open in the platform');
|
|
396
|
+
});
|
|
397
|
+
|
|
377
398
|
it('lets a distribution replace the section or drop it', async () => {
|
|
378
399
|
const replaced = await composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) =>
|
|
379
400
|
sections.map((s) => (s.id === 'html-views' ? { id: s.id, body: '## HTML views\n\nOurs, graph-aware.\n' } : s)),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* They used to be appended, in full, to every tool description that they
|
|
5
5
|
* covered: the content rule rode on all twelve file tools, the agent-guide
|
|
6
|
-
* reminder on all of them
|
|
6
|
+
* reminder on all of them, the write-mode and escape-sequence
|
|
7
7
|
* paragraphs on two or three apiece. A description then ran to two or three
|
|
8
8
|
* thousand characters, most of it text the agent had already read on the tool
|
|
9
9
|
* above — and clients cut long descriptions from the END, which is where the
|
|
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import type { SQL } from 'drizzle-orm';
|
|
4
4
|
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
5
5
|
import { AgentAuditService, retentionDaysFrom } from '../agent-audit.service.js';
|
|
6
|
-
import { AuditPrincipalNotFoundError, InvalidCursorError } from '../audit.contract.js';
|
|
6
|
+
import { AuditPrincipalNotFoundError, InvalidCursorError, pagePrincipalId, parsePagePrincipalId } from '../audit.contract.js';
|
|
7
7
|
import type { Database } from '../../database/connection.js';
|
|
8
8
|
import { agentConnections, oauthTokens } from '../../database/schema.js';
|
|
9
9
|
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
@@ -217,6 +217,8 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
217
217
|
{ keyId: 'k-1', connectionId: null, n: 7 },
|
|
218
218
|
{ keyId: null, connectionId: 'c-1', n: 3 },
|
|
219
219
|
],
|
|
220
|
+
// pages
|
|
221
|
+
[],
|
|
220
222
|
],
|
|
221
223
|
{ listForUser: vi.fn(async () => [key({ lastUsedAt: NOW - 2 * DAY })]) },
|
|
222
224
|
);
|
|
@@ -235,6 +237,7 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
235
237
|
[
|
|
236
238
|
[{ connection: connection({ id: 'c-bob', userId: BOB.id }), user: BOB }],
|
|
237
239
|
[],
|
|
240
|
+
[],
|
|
238
241
|
],
|
|
239
242
|
{ listForDeployment: vi.fn(async () => [{ ...key({}), user: ALICE }]) },
|
|
240
243
|
);
|
|
@@ -247,6 +250,80 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
247
250
|
});
|
|
248
251
|
});
|
|
249
252
|
|
|
253
|
+
describe('AgentAuditService — a page as a principal', () => {
|
|
254
|
+
const ALICE_ID = '11111111-1111-4111-8111-111111111111';
|
|
255
|
+
const PAGE = 'Dashboards/Roadmap.html';
|
|
256
|
+
|
|
257
|
+
it("records a page's call under its path, with neither a key nor a connection", async () => {
|
|
258
|
+
const { service, captured } = makeService([undefined]);
|
|
259
|
+
service.record({
|
|
260
|
+
userId: ALICE_ID,
|
|
261
|
+
principal: { kind: 'page', path: PAGE },
|
|
262
|
+
kind: 'capability',
|
|
263
|
+
manual: null,
|
|
264
|
+
name: 'edit_file',
|
|
265
|
+
outcome: 'refused',
|
|
266
|
+
durationMs: null,
|
|
267
|
+
});
|
|
268
|
+
await flush();
|
|
269
|
+
expect(captured.values[0]).toMatchObject({
|
|
270
|
+
userId: ALICE_ID,
|
|
271
|
+
keyId: null,
|
|
272
|
+
connectionId: null,
|
|
273
|
+
pagePath: PAGE,
|
|
274
|
+
name: 'edit_file',
|
|
275
|
+
outcome: 'refused',
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('lists each page a person opened as a row of its own, labelled by its path, never revocable', async () => {
|
|
280
|
+
const { service } = makeService([
|
|
281
|
+
[{ ...ALICE, id: ALICE_ID }],
|
|
282
|
+
[],
|
|
283
|
+
[],
|
|
284
|
+
[
|
|
285
|
+
{
|
|
286
|
+
userId: ALICE_ID,
|
|
287
|
+
pagePath: PAGE,
|
|
288
|
+
n: 4,
|
|
289
|
+
first: new Date(NOW - DAY),
|
|
290
|
+
last: new Date(NOW - 60_000),
|
|
291
|
+
user: { ...ALICE, id: ALICE_ID },
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
]);
|
|
295
|
+
const rows = await service.listPrincipals({ userId: ALICE_ID });
|
|
296
|
+
expect(rows).toHaveLength(1);
|
|
297
|
+
expect(rows[0]).toMatchObject({
|
|
298
|
+
kind: 'page',
|
|
299
|
+
id: pagePrincipalId(ALICE_ID, PAGE),
|
|
300
|
+
label: PAGE,
|
|
301
|
+
eventCount: 4,
|
|
302
|
+
createdAt: NOW - DAY,
|
|
303
|
+
lastUsedAt: NOW - 60_000,
|
|
304
|
+
revokedAt: null,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("reads a page's events by its person and path, and owns it by that person", async () => {
|
|
309
|
+
const id = pagePrincipalId(ALICE_ID, PAGE);
|
|
310
|
+
expect(parsePagePrincipalId(id)).toEqual({ userId: ALICE_ID, path: PAGE });
|
|
311
|
+
|
|
312
|
+
const events = makeService([[], [{ total: 0 }]]);
|
|
313
|
+
await events.service.listEvents({ kind: 'page', id }, { limit: 10 });
|
|
314
|
+
expect(render(events.captured.where[0]).params).toEqual([ALICE_ID, PAGE]);
|
|
315
|
+
|
|
316
|
+
const owned = makeService([[{ id: 'e-1' }]]);
|
|
317
|
+
await expect(owned.service.ownerOf({ kind: 'page', id })).resolves.toBe(ALICE_ID);
|
|
318
|
+
const never = makeService([[]]);
|
|
319
|
+
await expect(never.service.ownerOf({ kind: 'page', id })).resolves.toBeNull();
|
|
320
|
+
// An id that names no person and path is no page, without asking the database.
|
|
321
|
+
const bogus = makeService([]);
|
|
322
|
+
await expect(bogus.service.ownerOf({ kind: 'page', id: 'not-a-page' })).resolves.toBeNull();
|
|
323
|
+
expect(bogus.counts.select).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
250
327
|
describe('AgentAuditService.listEvents', () => {
|
|
251
328
|
const eventId = (i: number) => `00000000-0000-4000-8000-00000000000${i}`;
|
|
252
329
|
const eventRow = (i: number) => ({
|