From 36008436830b2f1a405e9a48f4b4337d965b6250 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:26:11 +0900 Subject: [PATCH] feat: manage direct and delegated Subject permissions from Web Use typed catalog labels and resource inputs, preserve unsupported entries, preview lane-specific differences and retain exact save requests for unknown-outcome reconciliation. Re-read current policy instead of treating historical receipts as authority. --- web/workspace/deno.json | 2 +- .../memory/SubjectPermissions.browser.spec.ts | 264 ++++++++++++++++ .../memory/SubjectPermissions.svelte | 183 +++++++++++ .../lib/workspace/memory/permissions.test.ts | 149 +++++++++ .../src/lib/workspace/memory/permissions.ts | 295 ++++++++++++++++++ .../memory/[subjectId]/PageContent.svelte | 2 +- .../[subjectId]/permissions/+page.svelte | 9 + .../memory/[subjectId]/permissions/+page.ts | 24 ++ .../permissions/PageContent.svelte | 16 + 9 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 web/workspace/src/lib/workspace/memory/SubjectPermissions.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte create mode 100644 web/workspace/src/lib/workspace/memory/permissions.test.ts create mode 100644 web/workspace/src/lib/workspace/memory/permissions.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/PageContent.svelte diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 96f1cc0e5..2ff26ebc5 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/memory/SubjectPermissions.browser.spec.ts b/web/workspace/src/lib/workspace/memory/SubjectPermissions.browser.spec.ts new file mode 100644 index 000000000..0c58cb7c3 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/SubjectPermissions.browser.spec.ts @@ -0,0 +1,264 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import SubjectPermissions from "./SubjectPermissions.svelte"; +import type { Policy, PolicyUpdate } from "./permissions.ts"; +const ops = [ + { + operation: "ticket.create", + label: "Create Tickets", + description: "Create an open Ticket in this Workspace.", + resource_kinds: ["workspace"], + availability: { kind: "available" }, + }, + { + operation: "filesystem.read", + label: "Read files", + description: "Read only the selected Workdir path.", + resource_kinds: ["workdir"], + availability: { kind: "available" }, + }, + { + operation: "filesystem.write", + label: "Edit files", + description: "Write only the selected Workdir path.", + resource_kinds: ["workdir"], + availability: { kind: "available" }, + }, + { + operation: "worker.messaging", + label: "Message Worker", + description: "Separate operation use and directional target control.", + resource_kinds: ["workspace"], + availability: { kind: "unavailable", reason: "T-803 connection pending" }, + }, +]; +const props = { workspaceId: "w", subjectId: "s" }; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + sessionStorage.clear(); +}); +function harness(initial: Policy | null = null, canManage = true) { + let policy = initial; + const requests: PolicyUpdate[] = []; + const fetch = vi.fn(async (_path: string, init?: RequestInit) => { + if (init?.method === "POST") { + const request = JSON.parse(init.body as string) as PolicyUpdate; + requests.push(request); + policy = { + change_id: request.operation_id, + execution: request.execution, + delegation: request.delegation, + }; + return Response.json({ permissions: policy, interruptions: [] }); + } + return Response.json({ + permissions: policy, + can_manage: canManage, + operations: ops, + }); + }); + vi.stubGlobal("fetch", fetch); + return { fetch, requests, setPolicy: (next: Policy | null) => policy = next }; +} +async function choose(name = "ticket.create") { + await screen.findByRole("combobox", { name: "Operation" }); + await fireEvent.change(screen.getByRole("combobox", { name: "Operation" }), { + target: { value: name }, + }); +} +async function add(which = "execution", name = "ticket.create") { + await choose(name); + await fireEvent.change( + screen.getByRole("combobox", { name: "Permission scope" }), + { target: { value: which } }, + ); + if (name.startsWith("filesystem")) { + await fireEvent.input( + screen.getByRole("textbox", { name: "Workdir identifier" }), + { target: { value: "wd-only" } }, + ); + await fireEvent.input( + screen.getByRole("textbox", { name: "Relative path" }), + { target: { value: "src" } }, + ); + } + await fireEvent.click( + screen.getByRole("button", { name: "Add to unsaved changes" }), + ); +} +async function confirmSave() { + await fireEvent.click(screen.getByRole("checkbox", { name: /I reviewed/ })); + await fireEvent.click( + screen.getByRole("button", { name: "Save permissions" }), + ); +} +test("new Subject selects direct and narrow delegation independently, previews exact diff and saves through existing endpoint", async () => { + const h = harness(); + render(SubjectPermissions, props); + await add(); + await add("delegation", "filesystem.write"); + expect(h.requests).toHaveLength(0); + expect( + screen.getByRole("region", { name: "Unsaved permission differences" }) + .textContent, + ).toContain("Edit files"); + expect( + (screen.getByRole("button", { + name: "Save permissions", + }) as HTMLButtonElement).disabled, + ).toBe(true); + await confirmSave(); + await screen.findByText("Saved and re-read from the current policy."); + expect(h.requests).toHaveLength(1); + expect(h.requests[0].expected_change_id).toBe(null); + expect(h.requests[0].execution[0].operation).toBe("ticket.create"); + expect(h.requests[0].delegation[0]).toEqual({ + operation: "filesystem.write", + resource: { + kind: "workdir", + working_directory_id: "wd-only", + path: "src", + recursive: false, + }, + }); + expect(h.fetch.mock.calls[1][0]).toBe( + "/api/w/w/subjektiv/subjects/s/permissions", + ); +}); +test("unsupported legacy target is retained verbatim while an unrelated operation is added", async () => { + const legacy = { + operation: "worker.notify", + resource: { + kind: "worker" as const, + runtime_id: "r", + worker_id: "only-target", + }, + }; + const h = harness({ change_id: "old", execution: [], delegation: [legacy] }); + render(SubjectPermissions, props); + await add(); + await confirmSave(); + await screen.findByText("Saved and re-read from the current policy."); + expect(h.requests[0].delegation).toEqual([legacy]); + expect(screen.getByText(/Preserved, not editable/)).toBeTruthy(); +}); +test("unimplemented Worker operation has reason, no Worker-ID fields and cannot be added", async () => { + harness(); + render(SubjectPermissions, props); + await choose("worker.messaging"); + expect(screen.getByText(/Target unavailable: T-803/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Add to unsaved changes" })) + .toBeNull(); + expect(screen.queryByRole("textbox", { name: /Worker identifier/ })) + .toBeNull(); +}); +test("read-only projection retains current permission visibility without save or edit actions", async () => { + harness({ + change_id: "p", + execution: [{ + operation: "ticket.create", + resource: { kind: "workspace", workspace_id: "w" }, + }], + delegation: [], + }, false); + render(SubjectPermissions, props); + await screen.findByText(/Read-only. The Backend/); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.queryByRole("button", { name: /^Remove/ })).toBeNull(); + expect( + within(screen.getByRole("region", { name: "Direct operations" })).getByText( + "Create Tickets", + ), + ).toBeTruthy(); +}); +test("conflict retains unsaved diff and blocks editing until explicit reload, never rebases CAS silently", async () => { + const h = harness(); + h.fetch.mockImplementation(async (_path, init) => + init?.method === "POST" + ? new Response(null, { status: 409 }) + : Response.json({ permissions: null, can_manage: true, operations: ops }) + ); + render(SubjectPermissions, props); + await add(); + await confirmSave(); + await screen.findByText(/Conflict: another save/); + expect(screen.getByRole("region", { name: "Unsaved permission differences" })) + .toBeTruthy(); + expect(screen.queryByRole("button", { name: "Save permissions" })).toBeNull(); + expect(sessionStorage.length).toBe(0); +}); +test("lost response survives remount and exact replay confirms receipt without reviving later policy", async () => { + const h = harness(); + let saved: PolicyUpdate | null = null; + const later: Policy = { + change_id: "later-shrink", + execution: [], + delegation: [], + }; + let lost = false; + h.fetch.mockImplementation(async (_path, init) => { + if (init?.method === "POST") { + const request = JSON.parse(init.body as string) as PolicyUpdate; + if (!lost) { + saved = request; + lost = true; + throw new Error("lost after commit"); + } + expect(request).toEqual(saved); + return Response.json({ + permissions: { + change_id: request.operation_id, + execution: request.execution, + delegation: request.delegation, + }, + interruptions: [], + }); + } + return Response.json({ + permissions: lost ? later : null, + can_manage: true, + operations: ops, + }); + }); + const first = render(SubjectPermissions, props); + await add(); + await confirmSave(); + await screen.findByText("Save outcome not yet confirmed."); + first.unmount(); + render(SubjectPermissions, props); + await waitFor(() => + expect( + (screen.getByRole("button", { + name: "Reconcile the same save", + }) as HTMLButtonElement).disabled, + ).toBe(false) + ); + await fireEvent.click( + screen.getByRole("button", { name: "Reconcile the same save" }), + ); + await screen.findByText(/original save was confirmed, but a later change/); + expect( + within(screen.getByRole("region", { name: "Direct operations" })) + .queryByText("Create Tickets"), + ).toBeNull(); + expect(sessionStorage.length).toBe(0); +}); +test("deleted Subject is refused with no success indication", async () => { + const h = harness(); + h.fetch.mockImplementation(async () => new Response(null, { status: 404 })); + render(SubjectPermissions, props); + await screen.findByText( + /Target unavailable. This Subject may have been deleted/, + ); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(h.requests).toHaveLength(0); +}); diff --git a/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte new file mode 100644 index 000000000..e566c97fd --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte @@ -0,0 +1,183 @@ + + +
+

Operation permissions

{pending ? 'Outcome unknown' : dirty ? 'Unsaved changes' : notice ? 'Saved / current policy re-read' : view?.permissions ? 'Saved policy' : view ? 'Not configured · no permissions' : 'Loading…'}
+

Direct operations and the background Worker delegation ceiling are independent. Profile and Feature selection never grant permissions. Workdir/provider grants, target existence and assignment conditions still apply.

+ {#if error}{/if} + {#if notice}

{notice}

{/if} + {#if pending} +
Save outcome not yet confirmed.

This tab retained the exact request. Reconcile it using the same operation and then re-read the current policy. Do not start another save.

+ {/if} + {#if !view} + + {:else} + {#if !view.can_manage}

Read-only. The Backend must authorize permission management; hiding controls is not authorization.

{/if} +
+ {#each ['execution', 'delegation'] as which} + {@const permissions = which === 'execution' ? execution : delegation} +
+

{which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}

+

{which === 'execution' ? 'Operations the Subject body may use, if its tools and current connections support them.' : 'Maximum operations and resources that may be explicitly passed to background Workers. This does not give the body work tools or start a Worker.'}

+ {#if permissions.length === 0}

No permissions in this scope.

{/if} +
    {#each permissions as p, index} +
  • {label(p)}{resourceLabel(p.resource)} + {#if !supported(p)}Preserved, not editable here. No conversion or expansion is implied.
    Saved operation identifier{p.operation}
    {/if} +
    {#if editable}{/if}
  • + {/each}
+
+ {/each} +
+ {#if view.can_manage} +
{ event.preventDefault(); add(); }} class="permission-form"> +

Add an explicit operation

+
+ + + {#if selected} +

{selected.description}

+ {#if !selected.available}

Target unavailable: {selected.unavailable_reason} No permission will be added.

+ {:else} + + {#if resourceKind === 'workspace'}

Collection operations in this Workspace only; this does not cover individual resources.

{/if} + {#if resourceKind === 'feature'}

MCP is a temporary coarse activation exception. This is not per-server or per-tool authorization and does not grant ordinary Feature operations.

{/if} + {#each resourceFields(resourceKind) as field}{/each} + {#if resourceKind === 'workdir' || resourceKind === 'workspace_config'}{/if} + + {/if} + {/if} +
+
+ {/if} +
Examples and enforcement boundaries

For read-only work, add a read operation and its exact resource to the delegation ceiling. For limited editing, separately add the write operation for only the intended Workdir and relative path. Nothing else is implied; no wildcard or fixed all-powerful role is added.

Worker operations require both permission to use the operation and a directional Backend control grant for the actual target. Idle/CAS and lifecycle rules remain separate. This editor never asks for per-Worker notification/stop permissions or copies creation grants into the Subject policy. The T-803 connection is not yet available. Subject Ticket assignment migration (T-778) is not enabled by this screen.

Saving fences later operations using the current policy. Running work may receive cancellation requests; acceptance is not a confirmed stop. Already completed effects are not undone. Existing child grants are invalidated rather than silently refreshed.

+ {#if dirty && !pending} +

Review before saving

+ {#each ['execution', 'delegation'] as which}

{which === 'execution' ? 'Direct operations' : 'Delegation ceiling'}

+
    {#each changes[which as Lane].added as p}
  • + Add: {label(p)} — {resourceLabel(p.resource)}
  • {/each}{#each changes[which as Lane].removed as p}
  • − Remove: {label(p)} — {resourceLabel(p.resource)}
  • {/each}
+ {/each} + {#if editable}{/if} +
+ {/if} + + {#if interruptions.length}

Cancellation requests, not stop confirmation

    {#each interruptions as i}
  • Worker {i.worker_id}: cancellation {i.cancellation_accepted ? 'accepted' : 'not confirmed'}; Backend command cleanup {i.backend_command_sessions_closed ? 'confirmed' : 'not confirmed'}. Runtime stop remains unconfirmed.
  • {/each}
{/if} + {/if} +
+ + diff --git a/web/workspace/src/lib/workspace/memory/permissions.test.ts b/web/workspace/src/lib/workspace/memory/permissions.test.ts new file mode 100644 index 000000000..f50038624 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/permissions.test.ts @@ -0,0 +1,149 @@ +import { + attemptKey, + makeResource, + parseView, + permissionKey, + policyDiff, + readAttempt, + readPermissions, + writePermissions, +} from "./permissions.ts"; +import type { ScopedPermission } from "./permissions.ts"; +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function assert(condition: unknown): asserts condition { + if (!condition) throw new Error("Assertion failed"); +} +const p: ScopedPermission = { + operation: "filesystem.read", + resource: { + kind: "workdir", + working_directory_id: "wd", + path: "src", + recursive: true, + }, +}; +Deno.test("permission differences preserve exact legacy resource and separate lanes", () => { + const legacy: ScopedPermission = { + operation: "worker.notify", + resource: { + kind: "worker", + runtime_id: "runtime", + worker_id: "only-worker", + }, + }; + const direct = [legacy, p]; + assert(policyDiff([legacy], direct).added.length === 1); + assert(policyDiff([legacy], direct).removed.length === 0); + assert( + permissionKey(p) === + permissionKey({ + operation: p.operation, + resource: { + recursive: true, + path: "src", + working_directory_id: "wd", + kind: "workdir", + }, + }), + ); + assert(policyDiff([legacy], []).removed[0] === legacy); +}); +Deno.test("typed resource input binds workspace, preserves narrow paths and rejects missing targets", () => { + assert( + JSON.stringify( + makeResource("workdir", "w", "s", { + working_directory_id: "wd", + path: "src", + }, false), + ) === + JSON.stringify({ + kind: "workdir", + working_directory_id: "wd", + path: "src", + recursive: false, + }), + ); + assert( + JSON.stringify(makeResource("subject", "w", "s", {}, false)) === + JSON.stringify({ kind: "subject", workspace_id: "w", subject_id: "s" }), + ); + let rejected = false; + try { + makeResource("ticket", "w", "s", {}, false); + } catch { + rejected = true; + } + assert(rejected); +}); +Deno.test("catalog parsing refuses unknown resource types and malformed management permission", () => { + for ( + const value of [ + { permissions: null, can_manage: "yes", operations: [] }, + { + permissions: null, + can_manage: true, + operations: [{ + operation: "unknown", + label: "Unknown", + description: "", + resource_kinds: ["everything"], + availability: { kind: "available" }, + }], + }, + ] + ) { + let rejected = false; + try { + parseView(value); + } catch { + rejected = true; + } + assert(rejected); + } +}); +Deno.test("save retry keeps exact complete input scoped to Workspace and Subject", async () => { + const request = { + operation_id: "stable-operation", + expected_change_id: "old", + execution: [p], + delegation: [], + }; + const memory = new Map([[attemptKey("w", "s"), JSON.stringify(request)]]); + const storage = { getItem: (key: string) => memory.get(key) ?? null }; + assert( + JSON.stringify(readAttempt(storage, "w", "s")) === JSON.stringify(request), + ); + assert(readAttempt(storage, "other", "s") === null); + assert(readAttempt(storage, "w", "other") === null); + const calls: { url: string; body: string }[] = []; + const fake = ((input: string, init?: RequestInit) => { + calls.push({ url: input, body: init?.body as string }); + return Promise.resolve( + Response.json({ + permissions: { change_id: "new", execution: [p], delegation: [] }, + interruptions: [], + }), + ); + }) as typeof fetch; + await writePermissions(fake, "w", "s", request); + await writePermissions(fake, "w", "s", readAttempt(storage, "w", "s")!); + assert(calls[0].url === "/api/w/w/subjektiv/subjects/s/permissions"); + assert(calls[0].body === calls[1].body); +}); +Deno.test("read permission projection uses the existing scoped endpoint and no fallback grant", async () => { + let path = ""; + const fake = ((input: string) => { + path = input; + return Promise.resolve( + Response.json({ permissions: null, can_manage: false, operations: [] }), + ); + }) as typeof fetch; + const view = await readPermissions(fake, "space", "subject"); + assert(path === "/api/w/space/subjektiv/subjects/subject/permissions"); + assert( + view.permissions === null && !view.can_manage && + view.operations.length === 0, + ); +}); diff --git a/web/workspace/src/lib/workspace/memory/permissions.ts b/web/workspace/src/lib/workspace/memory/permissions.ts new file mode 100644 index 000000000..9708beaf0 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/permissions.ts @@ -0,0 +1,295 @@ +import type { + PermissionOperationDescriptor, + PermissionResource, + ScopedPermission, + SubjectPermissionInterruption, + SubjectPermissions, + SubjectPermissionsMutationResponse, + SubjectPermissionsResponse, + SubjectPermissionsUpdate, +} from "#lib/generated/memory-api.ts"; +import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; + +export type { PermissionResource, ScopedPermission }; +export type Policy = SubjectPermissions; +export type PolicyUpdate = SubjectPermissionsUpdate; +export type Operation = Omit & { + available: boolean; + unavailable_reason: string | null; +}; +export type PermissionView = Omit & { + operations: Operation[]; +}; +export type Interruption = SubjectPermissionInterruption; +export type Mutation = SubjectPermissionsMutationResponse; +export type Lane = "execution" | "delegation"; + +const MAX_BYTES = 1_048_576; +const fields = { + workspace: ["workspace_id"], + ticket: ["workspace_id", "ticket_id"], + objective: ["workspace_id", "objective_id"], + merge_request: ["workspace_id", "merge_request_id"], + subject: ["workspace_id", "subject_id"], + drive_entry: ["workspace_id", "node_id"], + drive_request: ["workspace_id", "request_id"], + repository: ["workspace_id", "repository_key"], + workdir_attachment: ["workspace_id", "connection_id"], + workspace_config: ["workspace_id", "path", "recursive"], + network: ["url"], + sub_worker: ["runtime_id", "worker_id", "name"], + feature: [], + workdir: ["working_directory_id", "path", "recursive"], + worker: ["runtime_id", "worker_id"], + job: ["workspace_id", "job_id"], +} satisfies Record; +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid permission response"); + } + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== "string" || value.length > 16_384) { + throw new Error("Invalid permission text"); + } + return value; +} +function bool(value: unknown): boolean { + if (typeof value !== "boolean") throw new Error("Invalid permission flag"); + return value; +} +function list(value: unknown, parse: (item: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > 256) { + throw new Error("Invalid permission list"); + } + return value.map(parse); +} +export function parseResource(value: unknown): PermissionResource { + const r = object(value); + const kind = text(r.kind) as PermissionResource["kind"]; + if (!Object.hasOwn(fields, kind)) throw new Error("Unknown resource kind"); + const keys: string[] = fields[kind]; + if ( + Object.keys(r).some((key) => key !== "kind" && !keys.includes(key)) || + keys.some((key) => !(key in r)) + ) throw new Error("Invalid resource fields"); + for (const key of keys) key === "recursive" ? bool(r[key]) : text(r[key]); + return r as PermissionResource; +} +export function parsePermission(value: unknown): ScopedPermission { + const p = object(value); + return { operation: text(p.operation), resource: parseResource(p.resource) }; +} +export function parsePolicy(value: unknown): Policy { + const p = object(value); + return { + change_id: text(p.change_id), + execution: list(p.execution, parsePermission), + delegation: list(p.delegation, parsePermission), + }; +} +export function parseView(value: unknown): PermissionView { + const v = object(value); + return { + permissions: v.permissions === null ? null : parsePolicy(v.permissions), + can_manage: bool(v.can_manage), + operations: list(v.operations, (value) => { + const o = object(value); + const availability = object(o.availability); + if ( + availability.kind !== "available" && availability.kind !== "unavailable" + ) throw new Error("Unknown operation availability"); + return { + operation: text(o.operation), + label: text(o.label), + description: text(o.description), + resource_kinds: list(o.resource_kinds, (value) => { + const kind = text(value) as PermissionResource["kind"]; + if (!Object.hasOwn(fields, kind)) { + throw new Error("Unknown resource kind"); + } + return kind; + }), + available: availability.kind === "available", + unavailable_reason: availability.kind === "unavailable" + ? text(availability.reason) + : null, + }; + }), + }; +} +export class PermissionHttpError extends Error { + constructor(public status: number) { + super(`Permission request rejected (HTTP ${status}).`); + } +} +const path = (workspaceId: string, subjectId: string) => + workspaceApiPath( + workspaceId, + `/subjektiv/subjects/${encodeURIComponent(subjectId)}/permissions`, + ); +export async function readPermissions( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, +): Promise { + const response = await fetchFn(path(workspaceId, subjectId)); + if (!response.ok) { + await response.body?.cancel(); + throw new PermissionHttpError(response.status); + } + return parseView(await readBoundedJson(response, MAX_BYTES)); +} +export async function writePermissions( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, + request: PolicyUpdate, +): Promise { + const response = await fetchFn(path(workspaceId, subjectId), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new PermissionHttpError(response.status); + } + const v = object(await readBoundedJson(response, MAX_BYTES)); + return { + permissions: parsePolicy(v.permissions), + interruptions: list(v.interruptions, (value) => { + const i = object(value); + return { + runtime_id: text(i.runtime_id), + worker_id: text(i.worker_id), + cancellation_accepted: bool(i.cancellation_accepted), + backend_command_sessions_closed: bool( + i.backend_command_sessions_closed, + ), + diagnostics: list(i.diagnostics, text), + }; + }), + }; +} +export function permissionKey(permission: ScopedPermission): string { + return JSON.stringify([ + permission.operation, + Object.entries(permission.resource).sort(([a], [b]) => a.localeCompare(b)), + ]); +} +export function policyDiff( + before: ScopedPermission[], + after: ScopedPermission[], +) { + return { + added: after.filter((p) => + !before.some((b) => permissionKey(b) === permissionKey(p)) + ), + removed: before.filter((p) => + !after.some((a) => permissionKey(a) === permissionKey(p)) + ), + }; +} +export function resourceLabel(resource: PermissionResource): string { + const labels: Record = { + workspace: "Current workspace", + subject: "Subject", + ticket: "Ticket", + objective: "Objective", + merge_request: "Merge request", + workdir: "Workdir", + workspace_config: "Workspace configuration", + network: "URL", + drive_entry: "Drive entry", + drive_request: "Drive request", + repository: "Repository", + workdir_attachment: "Workdir connection", + feature: "MCP activation / legacy feature", + worker: "Legacy Worker target", + sub_worker: "Internal SubWorker", + job: "Job", + }; + const identifiers = Object.entries(resource).filter(([key]) => + !["kind", "workspace_id", "recursive"].includes(key) + ).map(([_, value]) => String(value)); + return `${labels[resource.kind]}${ + identifiers.length ? ` · ${identifiers.join(" / ")}` : "" + }${ + "recursive" in resource && resource.recursive + ? " (including descendants)" + : "" + }`; +} +// 保存値の正本ではなく、同じ操作の完全一致再送に必要な入力だけをtab内に保持する。 +export function attemptKey(workspaceId: string, subjectId: string): string { + return `yoi.subject-permissions:${JSON.stringify([workspaceId, subjectId])}`; +} +export function readAttempt( + storage: Pick, + workspaceId: string, + subjectId: string, +): PolicyUpdate | null { + const raw = storage.getItem(attemptKey(workspaceId, subjectId)); + if (raw === null) return null; + if (raw.length > MAX_BYTES) { + throw new Error( + "Saved permission request is unreadable. Do not start another save.", + ); + } + const v = object(JSON.parse(raw)); + return { + operation_id: text(v.operation_id), + expected_change_id: v.expected_change_id === null + ? null + : text(v.expected_change_id), + execution: list(v.execution, parsePermission), + delegation: list(v.delegation, parsePermission), + }; +} +export function makeResource( + kind: PermissionResource["kind"], + workspaceId: string, + subjectId: string, + inputs: Record, + recursive: boolean, +): PermissionResource { + const r: Record = { kind }; + for (const key of fields[kind]) { + r[key] = key === "workspace_id" + ? workspaceId + : key === "recursive" + ? recursive + : key === "subject_id" + ? (inputs[key]?.trim() || subjectId) + : inputs[key]?.trim() ?? ""; + if ( + typeof r[key] === "string" && !(r[key] as string).length && key !== "path" + ) throw new Error("Enter the required target resource."); + } + if (kind === "workdir" && !r.path) r.path = "."; + return parseResource(r); +} +export function resourceFields(kind: PermissionResource["kind"]): string[] { + return fields[kind].filter((key) => + !["workspace_id", "recursive"].includes(key) + ); +} +export const fieldLabel: Record = { + ticket_id: "Ticket identifier", + objective_id: "Objective identifier", + merge_request_id: "Merge request identifier", + subject_id: "Subject identifier (empty means this Subject)", + node_id: "Drive entry number", + request_id: "Drive request identifier", + repository_key: "Repository key", + connection_id: "Workdir connection identifier", + path: "Relative path", + url: "Exact HTTP(S) URL", + working_directory_id: "Workdir identifier", + job_id: "Job identifier", + runtime_id: "Runtime identifier", + worker_id: "Worker identifier", + name: "Internal SubWorker name", +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte index 3b3f58bcf..1a9c0f8a1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte @@ -180,7 +180,7 @@ {/if} {#if !deletionStarted} - + {#if subject}
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.svelte new file mode 100644 index 000000000..4e4e20f10 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.svelte @@ -0,0 +1,9 @@ + + + {#snippet children(resolved)}{/snippet} + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.ts new file mode 100644 index 000000000..2c376097b --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/+page.ts @@ -0,0 +1,24 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivSubjectResponse, +} from "#lib/workspace/memory/api.ts"; +import { deferPage } from "#lib/workspace/navigation/deferred.ts"; +import type { PageLoad } from "./$types"; + +export const load = deferPage(async ( + { fetch, params }: Parameters[0], +) => ({ + workspaceId: params.workspaceId, + subjectId: params.subjectId, + subject: await loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/subjektiv/subjects/${encodeURIComponent(params.subjectId)}`, + ), + undefined, + (value) => parseSubjektivSubjectResponse(value, params.subjectId), + MEMORY_API_LOAD_POLICY, + ), +})); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/PageContent.svelte new file mode 100644 index 000000000..e5f1e251c --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/permissions/PageContent.svelte @@ -0,0 +1,16 @@ + +Permissions · {data.subject.data?.role ?? 'Subject'} · Yoi Workspace +
+ ← Subject details +

{data.subject.data?.role ?? 'Subject'} permissions

+ {#if data.subject.data} + {#key JSON.stringify([data.workspaceId, data.subjectId])}{/key} + {:else}

Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.

{/if} +
+