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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { attemptKey, fieldLabel, makeResource, PermissionHttpError, permissionKey, policyDiff, readAttempt, readPermissions, resourceFields, resourceLabel, writePermissions } from './permissions.ts';
|
||||
import type { Interruption, Lane, PermissionResource, PermissionView, PolicyUpdate, ScopedPermission } from './permissions.ts';
|
||||
let { workspaceId, subjectId }: { workspaceId: string; subjectId: string } = $props();
|
||||
let view = $state<PermissionView | null>(null);
|
||||
let execution = $state<ScopedPermission[]>([]);
|
||||
let delegation = $state<ScopedPermission[]>([]);
|
||||
let pending = $state<PolicyUpdate | null>(null);
|
||||
let storageBlocked = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
let confirming = $state(false);
|
||||
let interruptions = $state<Interruption[]>([]);
|
||||
let lane = $state<Lane>('execution');
|
||||
let operationName = $state('');
|
||||
let resourceKind = $state<PermissionResource['kind']>('workspace');
|
||||
let inputs = $state<Record<string, string>>({});
|
||||
let recursive = $state(false);
|
||||
const selected = $derived(view?.operations.find((o) => o.operation === operationName));
|
||||
const changes = $derived({ execution: policyDiff(view?.permissions?.execution ?? [], execution), delegation: policyDiff(view?.permissions?.delegation ?? [], delegation) });
|
||||
const dirty = $derived(Object.values(changes).some((d) => d.added.length || d.removed.length));
|
||||
const editable = $derived(view?.can_manage && !busy && !pending && !storageBlocked);
|
||||
const label = (p: ScopedPermission) => view?.operations.find((o) => o.operation === p.operation)?.label ?? 'Preserved unsupported permission';
|
||||
const supported = (p: ScopedPermission) => view?.operations.some((o) => o.operation === p.operation && o.available && o.resource_kinds.includes(p.resource.kind));
|
||||
function accept(v: PermissionView) {
|
||||
view = v;
|
||||
execution = structuredClone(v.permissions?.execution ?? []);
|
||||
delegation = structuredClone(v.permissions?.delegation ?? []);
|
||||
confirming = false;
|
||||
}
|
||||
function describe(error: unknown) {
|
||||
if (error instanceof PermissionHttpError) {
|
||||
if (error.status === 403 || error.status === 401) return 'Read-only / permission unavailable. This account cannot manage this Subject.';
|
||||
if (error.status === 404) return 'Target unavailable. This Subject may have been deleted; an old screen cannot restore it.';
|
||||
if (error.status === 409) return 'Conflict: another save changed the policy. Your edits were not applied. Reload current permissions and review again.';
|
||||
if (error.status === 400) return 'The operation or resource was rejected. Check the selected target; no new permissions were saved.';
|
||||
}
|
||||
return 'The request could not be confirmed. Do not assume success or create another save.';
|
||||
}
|
||||
async function reload() {
|
||||
if (busy) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try { accept(await readPermissions(fetch, workspaceId, subjectId)); }
|
||||
catch (e) { error = describe(e); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
try { pending = readAttempt(sessionStorage, workspaceId, subjectId); }
|
||||
catch { storageBlocked = true; error = 'The saved request cannot be read. Saving is blocked; do not start another request.'; }
|
||||
if (!storageBlocked) void reload();
|
||||
});
|
||||
function chooseOperation() {
|
||||
resourceKind = selected?.resource_kinds[0] ?? 'workspace'; inputs = {}; recursive = false; confirming = false;
|
||||
}
|
||||
function add() {
|
||||
if (!editable || !selected?.available || !selected.resource_kinds.includes(resourceKind)) return;
|
||||
try {
|
||||
const p = { operation: selected.operation, resource: makeResource(resourceKind, workspaceId, subjectId, inputs, recursive) };
|
||||
const current = lane === 'execution' ? execution : delegation;
|
||||
if (!current.some((x) => permissionKey(x) === permissionKey(p))) {
|
||||
if (lane === 'execution') execution = [...execution, p]; else delegation = [...delegation, p];
|
||||
}
|
||||
confirming = false; error = ''; notice = '';
|
||||
} catch (e) { error = e instanceof Error ? e.message : 'Invalid resource'; }
|
||||
}
|
||||
function remove(which: Lane, index: number) {
|
||||
if (!editable) return;
|
||||
if (which === 'execution') execution = execution.filter((_, i) => i !== index);
|
||||
else delegation = delegation.filter((_, i) => i !== index);
|
||||
confirming = false; notice = '';
|
||||
}
|
||||
async function save(request?: PolicyUpdate) {
|
||||
if (busy || storageBlocked || !view?.can_manage) return;
|
||||
const attempt = request ?? { operation_id: crypto.randomUUID(), expected_change_id: view.permissions?.change_id ?? null, execution: $state.snapshot(execution), delegation: $state.snapshot(delegation) };
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
// 永続入力を保持できなければPOSTしない。再接続で新しいoperationを作らない。
|
||||
sessionStorage.setItem(attemptKey(workspaceId, subjectId), JSON.stringify(attempt));
|
||||
pending = attempt;
|
||||
} catch { error = 'Cannot retain the save request in this tab. Nothing was sent.'; busy = false; return; }
|
||||
let receiptAccepted = false;
|
||||
try {
|
||||
const receipt = await writePermissions(fetch, workspaceId, subjectId, attempt);
|
||||
receiptAccepted = true;
|
||||
const current = await readPermissions(fetch, workspaceId, subjectId);
|
||||
accept(current); interruptions = receipt.interruptions;
|
||||
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
|
||||
notice = current.permissions?.change_id === receipt.permissions.change_id
|
||||
? 'Saved and re-read from the current policy.'
|
||||
: 'The original save was confirmed, but a later change is now current. The current policy is shown; the receipt did not restore older permissions.';
|
||||
} catch (e) {
|
||||
const definitive = !receiptAccepted && e instanceof PermissionHttpError && [400, 401, 403, 404, 409].includes(e.status);
|
||||
if (definitive) {
|
||||
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
|
||||
// 競合後の新しい観測を元の下書きのCASへ黙って差し替えない。
|
||||
view = view ? { ...view, can_manage: false } : null;
|
||||
}
|
||||
error = receiptAccepted ? 'The save receipt was accepted, but current policy could not be re-read. Reconcile the same request; do not treat its receipt as current authority.' : describe(e);
|
||||
} finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="permissions" aria-labelledby="permissions-heading" aria-busy={busy} data-permission-state={pending ? 'unknown' : error ? 'error' : dirty ? 'unsaved' : notice ? 'saved' : view ? 'ready' : 'loading'}>
|
||||
<header><h2 id="permissions-heading">Operation permissions</h2><span class="state">{pending ? 'Outcome unknown' : dirty ? 'Unsaved changes' : notice ? 'Saved / current policy re-read' : view?.permissions ? 'Saved policy' : view ? 'Not configured · no permissions' : 'Loading…'}</span></header>
|
||||
<p>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.</p>
|
||||
{#if error}<p class="attention" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if pending}
|
||||
<div class="attention"><strong>Save outcome not yet confirmed.</strong><p>This tab retained the exact request. Reconcile it using the same operation and then re-read the current policy. Do not start another save.</p><button disabled={busy || !view?.can_manage} onclick={() => save(pending!)}>Reconcile the same save</button></div>
|
||||
{/if}
|
||||
{#if !view}
|
||||
<button disabled={busy || storageBlocked} onclick={reload}>Read current permissions</button>
|
||||
{:else}
|
||||
{#if !view.can_manage}<p role="status">Read-only. The Backend must authorize permission management; hiding controls is not authorization.</p>{/if}
|
||||
<div class="lanes">
|
||||
{#each ['execution', 'delegation'] as which}
|
||||
{@const permissions = which === 'execution' ? execution : delegation}
|
||||
<section class="lane" aria-label={which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}>
|
||||
<h3>{which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}</h3>
|
||||
<p>{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.'}</p>
|
||||
{#if permissions.length === 0}<p class="empty">No permissions in this scope.</p>{/if}
|
||||
<ul>{#each permissions as p, index}
|
||||
<li><div><strong>{label(p)}</strong><span class="resource">{resourceLabel(p.resource)}</span>
|
||||
{#if !supported(p)}<span class="legacy">Preserved, not editable here. No conversion or expansion is implied.</span><details><summary>Saved operation identifier</summary><code>{p.operation}</code></details>{/if}
|
||||
</div>{#if editable}<button aria-label={`Remove ${label(p)} from ${which === 'execution' ? 'direct operations' : 'delegation ceiling'}`} onclick={() => remove(which as Lane, index)}>Remove</button>{/if}</li>
|
||||
{/each}</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if view.can_manage}
|
||||
<form onsubmit={(event) => { event.preventDefault(); add(); }} class="permission-form">
|
||||
<h3>Add an explicit operation</h3>
|
||||
<fieldset disabled={!editable}>
|
||||
<label>Permission scope<select value={lane} onchange={(event) => { lane = event.currentTarget.value as Lane; }}><option value="execution">Direct operations</option><option value="delegation">Background Worker delegation ceiling</option></select></label>
|
||||
<label>Operation<select value={operationName} onchange={(event) => { operationName = event.currentTarget.value; chooseOperation(); }}><option value="">Choose an operation</option>{#each view.operations as o}<option value={o.operation}>{o.label}{o.available ? '' : ' — unavailable'}</option>{/each}</select></label>
|
||||
{#if selected}
|
||||
<p>{selected.description}</p>
|
||||
{#if !selected.available}<p class="attention">Target unavailable: {selected.unavailable_reason} No permission will be added.</p>
|
||||
{:else}
|
||||
<label>Resource type<select value={resourceKind} onchange={(event) => { resourceKind = event.currentTarget.value as PermissionResource['kind']; inputs = {}; recursive = false; }}>{#each selected.resource_kinds as kind}<option value={kind}>{kind.replaceAll('_', ' ')}</option>{/each}</select></label>
|
||||
{#if resourceKind === 'workspace'}<p>Collection operations in this Workspace only; this does not cover individual resources.</p>{/if}
|
||||
{#if resourceKind === 'feature'}<p>MCP is a temporary coarse activation exception. This is not per-server or per-tool authorization and does not grant ordinary Feature operations.</p>{/if}
|
||||
{#each resourceFields(resourceKind) as field}<label>{fieldLabel[field]}<input bind:value={inputs[field]} placeholder={field === 'subject_id' ? subjectId : field === 'path' ? '.' : ''} required={field !== 'subject_id' && field !== 'path'} /></label>{/each}
|
||||
{#if resourceKind === 'workdir' || resourceKind === 'workspace_config'}<label class="check"><input type="checkbox" bind:checked={recursive} />Include descendant paths</label>{/if}
|
||||
<button type="submit">Add to unsaved changes</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</fieldset>
|
||||
</form>
|
||||
{/if}
|
||||
<details class="help"><summary>Examples and enforcement boundaries</summary><p>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.</p><p>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.</p><p>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.</p></details>
|
||||
{#if dirty && !pending}
|
||||
<section class="diff" aria-label="Unsaved permission differences"><h3>Review before saving</h3>
|
||||
{#each ['execution', 'delegation'] as which}<h4>{which === 'execution' ? 'Direct operations' : 'Delegation ceiling'}</h4>
|
||||
<ul>{#each changes[which as Lane].added as p}<li>+ Add: {label(p)} — {resourceLabel(p.resource)}</li>{/each}{#each changes[which as Lane].removed as p}<li>− Remove: {label(p)} — {resourceLabel(p.resource)}</li>{/each}</ul>
|
||||
{/each}
|
||||
{#if editable}<label class="check"><input type="checkbox" bind:checked={confirming} />I reviewed the operation, resource and delegation differences.</label><button class="save" disabled={!confirming || busy} onclick={() => save()}>Save permissions</button>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<button class="reload" disabled={busy || !!pending || storageBlocked} onclick={reload}>{dirty ? 'Discard unsaved changes and reload current policy' : 'Reload current policy'}</button>
|
||||
{#if interruptions.length}<section aria-label="Cancellation evidence"><h3>Cancellation requests, not stop confirmation</h3><ul>{#each interruptions as i}<li>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.</li>{/each}</ul></section>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.permissions { max-width: 72rem; min-width: 0; }
|
||||
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
|
||||
h2 { margin: 0; font-size: 1.2rem; } h3 { font-size: 1rem; margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
|
||||
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: .8rem; }
|
||||
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
|
||||
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
|
||||
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||
li > div { min-width: 0; } .resource, .legacy { display: block; margin-top: .35rem; font-size: .85rem; } .legacy { color: var(--text-muted); }
|
||||
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
|
||||
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
|
||||
label { display: grid; gap: .35rem; font-size: .875rem; } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
|
||||
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: .85rem; } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
|
||||
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
|
||||
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
|
||||
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
|
||||
</style>
|
||||
@@ -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>): 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,
|
||||
);
|
||||
});
|
||||
@@ -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<PermissionOperationDescriptor, "availability"> & {
|
||||
available: boolean;
|
||||
unavailable_reason: string | null;
|
||||
};
|
||||
export type PermissionView = Omit<SubjectPermissionsResponse, "operations"> & {
|
||||
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<PermissionResource["kind"], string[]>;
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Invalid permission response");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
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<T>(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<PermissionView> {
|
||||
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<Mutation> {
|
||||
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<string, string> = {
|
||||
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<Storage, "getItem">,
|
||||
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<string, string>,
|
||||
recursive: boolean,
|
||||
): PermissionResource {
|
||||
const r: Record<string, unknown> = { 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<string, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -180,7 +180,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !deletionStarted}
|
||||
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
|
||||
<nav aria-label="Subjectの記録と管理"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav>
|
||||
|
||||
{#if subject}
|
||||
<section class="subject-overview" aria-label="Subject status">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
|
||||
import PageContent from './PageContent.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
<DeferredPage {data} title="Subject permissions">
|
||||
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
|
||||
</DeferredPage>
|
||||
@@ -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<PageLoad>[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,
|
||||
),
|
||||
}));
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import SubjectPermissions from '#lib/workspace/memory/SubjectPermissions.svelte';
|
||||
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: ResolvedPageData<PageData> } = $props();
|
||||
</script>
|
||||
<svelte:head><title>Permissions · {data.subject.data?.role ?? 'Subject'} · Yoi Workspace</title></svelte:head>
|
||||
<section class="permission-page">
|
||||
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← Subject details</a>
|
||||
<h1>{data.subject.data?.role ?? 'Subject'} permissions</h1>
|
||||
{#if data.subject.data}
|
||||
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
|
||||
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
|
||||
</section>
|
||||
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: 1.5rem; margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
|
||||
Reference in New Issue
Block a user