From 38a953cd917b9e06cb78827b6ec4caeb26899e52 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:25:09 +0900 Subject: [PATCH 1/4] feat: expose typed Subject permission management catalog Validate management additions in the existing CAS/replay transaction while retaining exact legacy entries in their original lane. Expose read-only account capability and test real saved-policy authorization, conflicts, replay and deletion fences. --- crates/server-api/src/authorization.rs | 3 + .../server-api/src/authorization/catalog.rs | 933 ++++++++++++++++++ .../src/authorization/catalog_tests.rs | 119 +++ crates/server-api/src/lib.rs | 12 + crates/subjektiv/src/permissions.rs | 25 + crates/workspace-server/src/server.rs | 28 +- .../tests/subject_authorization_tests.rs | 140 ++- .../subject_permission_management_tests.rs | 579 +++++++++++ openapi/server-api.json | 102 +- .../src/lib/generated/companion-api.ts | 2 +- web/workspace/src/lib/generated/memory-api.ts | 112 +++ .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 15 files changed, 1997 insertions(+), 66 deletions(-) create mode 100644 crates/server-api/src/authorization/catalog.rs create mode 100644 crates/server-api/src/authorization/catalog_tests.rs create mode 100644 crates/workspace-server/src/server/tests/subject_permission_management_tests.rs diff --git a/crates/server-api/src/authorization.rs b/crates/server-api/src/authorization.rs index bfc418c1e..8102255ad 100644 --- a/crates/server-api/src/authorization.rs +++ b/crates/server-api/src/authorization.rs @@ -51,6 +51,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +mod catalog; +pub use catalog::*; + pub const MAX_SCOPED_PERMISSIONS: usize = 256; #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] diff --git a/crates/server-api/src/authorization/catalog.rs b/crates/server-api/src/authorization/catalog.rs new file mode 100644 index 000000000..4ffe1c5d3 --- /dev/null +++ b/crates/server-api/src/authorization/catalog.rs @@ -0,0 +1,933 @@ +//! Management metadata over the existing policy, not an authorization source. +//! +//! Only the management replacement path validates against this catalog. Historical +//! deserialization and execution grants retain their exact spelling and scope. New +//! operations require an explicit catalog entry; dotted prefixes grant nothing. +use super::*; + +#[cfg(test)] +#[path = "catalog_tests.rs"] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum PermissionResourceKind { + Job, + Ticket, + Objective, + MergeRequest, + Subject, + DriveEntry, + DriveRequest, + Repository, + WorkdirAttachment, + WorkspaceConfig, + Network, + SubWorker, + Feature, + Workspace, + Workdir, + Worker, +} + +impl PermissionResource { + pub fn kind(&self) -> PermissionResourceKind { + use PermissionResourceKind as K; + match self { + Self::Job { .. } => K::Job, + Self::Ticket { .. } => K::Ticket, + Self::Objective { .. } => K::Objective, + Self::MergeRequest { .. } => K::MergeRequest, + Self::Subject { .. } => K::Subject, + Self::DriveEntry { .. } => K::DriveEntry, + Self::DriveRequest { .. } => K::DriveRequest, + Self::Repository { .. } => K::Repository, + Self::WorkdirAttachment { .. } => K::WorkdirAttachment, + Self::WorkspaceConfig { .. } => K::WorkspaceConfig, + Self::Network { .. } => K::Network, + Self::SubWorker { .. } => K::SubWorker, + Self::Feature => K::Feature, + Self::Workspace { .. } => K::Workspace, + Self::Workdir { .. } => K::Workdir, + Self::Worker { .. } => K::Worker, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PermissionOperationAvailability { + Available, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PermissionOperationDescriptor { + pub operation: String, + pub label: String, + pub description: String, + /// Accepted management input kinds, not a claim that a target exists or is usable. + pub resource_kinds: Vec, + pub availability: PermissionOperationAvailability, +} + +/// Available means the operation/resource contract is implemented, not that the +/// current Profile, provider, target, assignment or Job permits its execution. +/// Body execution and delegation ceilings use the same descriptors, independently. +pub fn subject_permission_operations() -> Vec { + use PermissionResourceKind as K; + let mut catalog = Vec::new(); + let mut add = |kind, entries: &[(&str, &str, &str)]| { + catalog.extend(entries.iter().map(|(operation, label, description)| { + PermissionOperationDescriptor { + operation: (*operation).into(), + label: (*label).into(), + description: (*description).into(), + resource_kinds: vec![kind], + availability: PermissionOperationAvailability::Available, + } + })); + }; + add( + K::Workdir, + &[ + ( + "filesystem.read", + "Read files", + "Read files and enumerate directories within the selected path and recursive scope; provider scope still applies.", + ), + ( + "filesystem.write", + "Edit files", + "Create or edit files within the selected path and recursive scope; does not permit commands.", + ), + ( + "bash.execute", + "Run commands", + "Requires the whole Workdir: path '.' and recursive true. A shell is not confined to a subdirectory. Explicit provider command authority is also required.", + ), + ( + "workdir.read", + "Inspect Workdir", + "Read selected Workdir metadata; does not grant file access.", + ), + ( + "workdir.attach", + "Attach Workdir", + "Attach the selected Workdir subject to existing provider and attachment constraints.", + ), + ( + "workdir.delete", + "Delete Workdir", + "Delete the selected Workdir only if existing occupancy and retention conditions permit it.", + ), + ], + ); + add( + K::Network, + &[ + ( + "web.fetch", + "Fetch web page", + "Fetch this exact HTTP(S) URL. Each redirect needs its own permission; private/local hosts remain denied.", + ), + ( + "web.search", + "Search web", + "Contact this exact search provider endpoint; credentials and network restrictions still apply.", + ), + ], + ); + add( + K::Workspace, + &[ + ( + "ticket.create", + "Create Tickets", + "Create open Tickets in this Workspace; no read or edit rights to existing Tickets are implied.", + ), + ( + "ticket.query", + "Search Tickets", + "Query the Workspace Ticket collection.", + ), + ( + "ticket.list", + "List Tickets", + "List the Workspace Ticket collection.", + ), + ( + "ticket.doctor", + "Diagnose Tickets", + "Inspect Workspace Ticket diagnostics.", + ), + ( + "ticket.intake.default_body", + "Read intake template", + "Read the default intake transition body.", + ), + ( + "ticket.tag.list", + "List classification tags", + "Read Workspace classification tags.", + ), + ( + "ticket.tag.create", + "Create classification tags", + "Create Workspace classification tags, not workflow authority.", + ), + ( + "ticket.tag.rename", + "Rename classification tags", + "Rename Workspace tags with existing replay and compare checks.", + ), + ( + "ticket.tag.delete", + "Delete classification tags", + "Delete Workspace tags and their memberships with existing compare checks.", + ), + ( + "ticket.relation.query", + "Search Ticket relations", + "Query Workspace Ticket relation metadata.", + ), + ( + "ticket.orchestration_plan.query", + "Read orchestration plans", + "Query retained orchestration plan metadata; does not start work.", + ), + ( + "objective.query", + "Search Objectives", + "Query the Workspace Objective collection.", + ), + ( + "objective.create", + "Create Objectives", + "Create an Objective in this Workspace.", + ), + ( + "repository.list", + "List repositories", + "List repository metadata, not checkout or credential access.", + ), + ( + "workdir.list", + "List Workdirs", + "List Workdir metadata; does not grant attachment or file access.", + ), + ( + "workdir.attachment.list", + "List attachments", + "List Workdir attachment metadata.", + ), + ( + "worker.list", + "List Workers", + "List known Workers. Discovery does not grant control.", + ), + ( + "worker.discover", + "Discover Workspace Workers", + "Search bounded Worker metadata. Discovery does not grant control or observation.", + ), + ( + "worker.observe.list", + "List observable Workers", + "List observation subjects; does not grant access to individual sessions.", + ), + ( + "worker.create", + "Create background Workers", + "Use existing Profile-resolved Worker creation with explicit execution grants. Control and reply without duplicate target permissions await T-803.", + ), + ( + "drive.capabilities", + "Read Drive capabilities", + "Read provider-supported Drive operations.", + ), + ( + "drive.root", + "Find Drive root", + "Read the Workspace Drive root reference.", + ), + ( + "drive.search", + "Search Drive", + "Search the Workspace Drive; provider visibility still applies.", + ), + ], + ); + add( + K::Ticket, + &[ + ( + "ticket.read", + "Read Ticket", + "Read the selected Ticket and its thread.", + ), + ( + "ticket.edit_item", + "Edit Ticket", + "Edit the selected Ticket content; actor and concurrency checks still apply.", + ), + ( + "ticket.edit_post", + "Edit Ticket post", + "Edit an eligible post using its exact observed body.", + ), + ( + "ticket.event.add", + "Add Ticket event", + "Append an allowed typed event to the selected Ticket.", + ), + ( + "ticket.case.update", + "Judge Ticket case", + "Close, reopen or correct closure with explicit judgment and current compare keys.", + ), + ( + "ticket.dependency.check", + "Check dependencies", + "Inspect selected Ticket dependencies; does not start them.", + ), + ( + "ticket.intake_summary.add", + "Add intake summary", + "Append an intake summary to the selected Ticket.", + ), + ( + "ticket.tags.set", + "Classify Ticket", + "Replace selected Ticket classification tags using the observed full set.", + ), + ( + "ticket.relation.read", + "Read Ticket relations", + "Read selected Ticket relations.", + ), + ( + "ticket.relation.record", + "Record Ticket relation", + "Record an explicit relation from this Ticket.", + ), + ( + "ticket.relation.remove", + "Remove Ticket relation", + "Remove an exact relation from this Ticket.", + ), + ( + "ticket.orchestration_plan.record", + "Record orchestration plan", + "Record plan metadata for this Ticket; does not grant execution authority.", + ), + ( + "ticket.mark_ready", + "Update intake readiness", + "Record an intake readiness change; does not grant execution authority.", + ), + ( + "ticket.queue", + "Request Ticket queueing", + "Use the existing authorized Ticket queue route; does not bypass actor restrictions.", + ), + ( + "ticket.assignment.assign", + "Delegate Ticket", + "Delegate to an authorized Orchestrator principal; does not start a Worker. Subject assignment awaits T-778.", + ), + ( + "ticket.assignment.clear", + "Release Ticket delegation", + "Release the observed current Orchestrator delegation; does not stop Workers.", + ), + ( + "ticket.assignment.update", + "Report assignment handling", + "Report handling only for the authenticated authorized current delegation with event CAS.", + ), + ( + "ticket.assignment.answer", + "Answer assignment", + "Append fenced continuing input to the current assignment; not a new assignment.", + ), + ( + "ticket.assignment.query", + "Read current assignment", + "Read selected Ticket assignment tracking.", + ), + ( + "ticket.assignment.history", + "Read assignment history", + "Read retained assignment tracking events.", + ), + ( + "ticket.assignment.diagnostics", + "Read assignment diagnostics", + "Read retained delivery and start evidence, not execution authority.", + ), + ( + "ticket.implementation_result.write", + "Report implementation result", + "Save a bounded result, handoff or blocker; not approval or closure.", + ), + ( + "ticket.implementation_result.read", + "Read implementation result", + "Read a saved implementation result; not a new request.", + ), + ( + "merge_request.open", + "Open Merge Request", + "Open or reuse a repository-scoped Merge Request for this Ticket.", + ), + ], + ); + add( + K::Objective, + &[ + ( + "objective.read", + "Read Objective", + "Read selected Objective and linked Ticket context.", + ), + ( + "objective.edit", + "Edit Objective", + "Edit selected Objective content.", + ), + ( + "objective.set_state", + "Set Objective state", + "Change selected Objective state.", + ), + ( + "objective.link_ticket", + "Link Ticket", + "Link a Ticket to this Objective; required Ticket read permission is separate.", + ), + ( + "objective.unlink_ticket", + "Unlink Ticket", + "Unlink a Ticket from this Objective.", + ), + ], + ); + add( + K::MergeRequest, + &[ + ( + "merge_request.read", + "Read Merge Request", + "Read selected Merge Request and review evidence.", + ), + ( + "merge_request.readiness", + "Check MR readiness", + "Check recording-workflow readiness; does not adopt changes.", + ), + ( + "merge_request.review", + "Review Merge Request", + "Submit a review only with independently bound review capability and current source.", + ), + ( + "merge_request.complete", + "Record MR integration", + "Record an already-applied exact-source integration with existing approval and source checks.", + ), + ], + ); + add( + K::Repository, + &[ + ( + "repository.read", + "Read repository metadata", + "Read selected repository metadata without exposing secrets.", + ), + ( + "workdir.create", + "Create Workdir", + "Materialize selected repository using existing provider grants and explicit selectors.", + ), + ], + ); + add( + K::WorkdirAttachment, + &[ + ( + "workdir.attachment.read", + "Read attachment", + "Read metadata for this exact attachment connection.", + ), + ( + "workdir.detach", + "Detach Workdir", + "Detach this exact observed connection; stale aliases do not select a replacement.", + ), + ], + ); + add( + K::WorkspaceConfig, + &[ + ( + "workspace_config.attach", + "Attach configuration", + "Attach the selected configuration path and recursive scope.", + ), + ( + "workspace_config.observe", + "Observe configuration", + "Observe selected configuration metadata within path scope.", + ), + ( + "workspace_config.read", + "Read configuration", + "Read selected configuration within path scope; secret rules still apply.", + ), + ( + "workspace_config.commit", + "Commit configuration", + "Commit selected configuration within path scope and provider compare checks.", + ), + ], + ); + add( + K::DriveEntry, + &[ + ( + "drive.metadata", + "Read Drive metadata", + "Read metadata for this exact entry.", + ), + ( + "drive.list", + "List Drive folder", + "List children of this exact folder; does not include descendants implicitly.", + ), + ( + "drive.read_text", + "Read Drive text", + "Read text of this exact entry.", + ), + ( + "drive.read_chunk", + "Read Drive chunk", + "Read a bounded chunk of this exact entry.", + ), + ( + "drive.create_folder", + "Create Drive folder", + "Create a folder under this exact parent.", + ), + ( + "drive.create_text", + "Create Drive text", + "Create a text entry under this exact parent.", + ), + ( + "drive.update_text", + "Update Drive text", + "Update this exact entry with provider concurrency checks.", + ), + ( + "drive.delete", + "Delete Drive entry", + "Delete this exact entry if provider conditions permit.", + ), + ( + "drive.relocate", + "Move Drive entry", + "Move an entry; source and destination each require explicit permission.", + ), + ( + "drive.upload", + "Upload Drive file", + "Upload under this exact parent, subject to provider limits.", + ), + ], + ); + add( + K::DriveRequest, + &[( + "drive.request_status", + "Read Drive request", + "Read the status of this exact provider request; no new execution authority.", + )], + ); + add( + K::Feature, + &[( + "mcp", + "Enable MCP (temporary coarse permission)", + "Retained MCP activation permission before stdio discovery. Not per-server, per-tool or per-call authorization; no equivalent gate is needed by other Features.", + )], + ); + add( + K::Subject, + &[ + ( + "session.record", + "Record Subject session", + "Attribute and record sessions for this Subject; current body ownership is still required.", + ), + ( + "session.list", + "List Subject sessions", + "List sessions attributed to this Subject.", + ), + ( + "session.search", + "Search Subject sessions", + "Search committed sessions attributed to this Subject.", + ), + ( + "session.read", + "Read Subject session", + "Read committed entries from this Subject's sessions.", + ), + ( + "memory.query", + "Search Subject Memory", + "Query this Subject's Memory, not Workspace Memory.", + ), + ( + "memory.read", + "Read Subject Memory", + "Read this Subject's selected Memory document.", + ), + ( + "memory.changes.list", + "List Memory changes", + "Read this Subject's retained Memory changes.", + ), + ( + "memory.resident.read", + "Read resident Memory", + "Read this Subject's resident Memory data.", + ), + ( + "memory.resident.supply", + "Supply resident Memory", + "Supply protected resident Memory to a model; read permission is separate.", + ), + ( + "memory.staging.write", + "Stage Memory candidate", + "Write a candidate to this Subject's staging area.", + ), + ( + "memory.staging.list", + "List Memory candidates", + "List this Subject's staging candidates.", + ), + ( + "memory.staging.read", + "Read Memory candidate", + "Read this Subject's staging candidate.", + ), + ( + "memory.candidate.decide", + "Decide Memory candidate", + "Decide a staged candidate using existing Job/domain constraints.", + ), + ( + "memory.proposal.validate", + "Validate Memory proposal", + "Validate a proposal for this Subject.", + ), + ( + "memory.receipt.read", + "Read Memory receipt", + "Read retained Memory operation evidence, not new authority.", + ), + ( + "memory.surface.prepare", + "Prepare Memory surface", + "Prepare this Subject's surface update under domain checks.", + ), + ( + "memory.surface.supply", + "Supply Memory surface", + "Supply protected surface input to a model.", + ), + ( + "memory.surface.publish", + "Publish Memory surface", + "Publish this Subject's prepared surface under writer and Job checks.", + ), + ( + "memory.surface.fail", + "Record surface failure", + "Record failure of this Subject's prepared surface.", + ), + ( + "memory.extract.read", + "Read extraction input", + "Read protected session input for Memory extraction.", + ), + ( + "memory.extract.supply", + "Supply extraction input", + "Supply protected extraction input to a model.", + ), + ( + "memory.extract.commit", + "Commit extraction", + "Commit extracted candidates under existing source/writer checks.", + ), + ( + "memory.consolidation.request", + "Request consolidation", + "Request a bounded consolidation Job; Job grants and origin constraints remain separate.", + ), + ( + "memory.consolidation.supply", + "Supply consolidation input", + "Supply frozen consolidation input only within its current Job capability.", + ), + ( + "journal.startup.read", + "Read startup Journal", + "Read this Subject's startup Journal context.", + ), + ( + "journal.startup.supply", + "Supply startup Journal", + "Supply protected startup Journal context to a model.", + ), + ( + "journal.compaction.read", + "Read compaction Journal", + "Read this Subject's compaction Journal context.", + ), + ( + "journal.compaction.supply", + "Supply compaction Journal", + "Supply protected compaction context to a model.", + ), + ( + "journal.recording.request", + "Request Journal recording", + "Request a bounded recording Job; does not grant arbitrary Job operations.", + ), + ( + "journal.job.input.read", + "Read Journal Job input", + "Read frozen input only with the bound current Journal Job capability.", + ), + ( + "journal.job.input.supply", + "Supply Journal Job input", + "Supply frozen input only with current Job, origin and policy authority.", + ), + ( + "journal.state.read", + "Read Journal state", + "Read this Subject's Journal state.", + ), + ( + "journal.text.read", + "Read Journal text", + "Read this Subject's Journal text.", + ), + ( + "journal.operation.query", + "Query Journal operations", + "Query retained Journal operations.", + ), + ( + "journal.operation.list", + "List Journal operations", + "List retained Journal operations.", + ), + ( + "journal.operation.coverage.read", + "Read Journal coverage", + "Read operation coverage evidence.", + ), + ( + "journal.entry.create", + "Create Journal entry", + "Create an entry within the bound Journal Job.", + ), + ( + "journal.entry.fix", + "Fix Journal entry", + "Fix an entry within the bound Journal Job.", + ), + ( + "journal.entry.rewrite", + "Rewrite Journal entry", + "Rewrite an entry within the bound Journal Job.", + ), + ( + "journal.position.update", + "Update Journal position", + "Update position within the current Journal writer/attempt.", + ), + ( + "journal.unchanged.confirm", + "Confirm unchanged Journal", + "Confirm no change within the current Journal writer/attempt.", + ), + ( + "journal.checkpoint", + "Checkpoint Journal", + "Checkpoint the current Journal recording Job.", + ), + ( + "journal.finish", + "Finish Journal recording", + "Finish the bound Journal Job; not a general work-completion judgment.", + ), + ], + ); + add( + K::Workspace, + &[ + ( + "memory.document.read", + "Read Workspace Memory document", + "Read a legacy Workspace Memory document; not Subject Memory.", + ), + ( + "memory.document.update", + "Update Workspace Memory document", + "Update a legacy Workspace Memory document with existing storage constraints.", + ), + ( + "memory.audit.append", + "Append Workspace Memory audit", + "Append a Workspace Memory audit event.", + ), + ( + "memory.staging.close", + "Close Workspace Memory candidate", + "Close a legacy Workspace staging candidate.", + ), + ], + ); + // The same exact operation may target either independent Memory domain. This + // adds accepted kinds, never cross-kind containment or a second descriptor. + for descriptor in &mut catalog { + if matches!( + descriptor.operation.as_str(), + "memory.query" + | "memory.resident.read" + | "memory.resident.supply" + | "memory.staging.write" + | "memory.staging.list" + | "memory.staging.read" + | "memory.extract.read" + | "memory.extract.supply" + | "memory.extract.commit" + | "memory.consolidation.request" + ) { + descriptor.resource_kinds.push(K::Workspace); + descriptor.description.push_str(" The Workspace kind selects the separate legacy Workspace Memory domain, never all Subjects."); + } + } + catalog.push(PermissionOperationDescriptor { + operation: "notification.diagnostics.read".into(), label: "Read notification diagnostics".into(), + description: "Read bounded saved delivery evidence for the exact source resource; Runtime acceptance does not prove processing.".into(), + resource_kinds: vec![K::Workspace, K::Ticket, K::MergeRequest, K::Job], + availability: PermissionOperationAvailability::Available, + }); + // T-803 owns operation-only control and name migration. No Worker resource + // kinds are offered here: adding a per-target setting would duplicate Backend + // control grants and dropping an old target would widen authority. + for (operation, label) in [ + ("worker.messaging", "Message Workers"), + ("worker.send_input", "Submit to Worker"), + ("worker.cancel", "Cancel Worker turn"), + ("worker.stop", "Stop Worker"), + ("worker.restore", "Restore Worker"), + ("worker.remove", "Remove Worker"), + ("worker.observe", "Observe Worker session"), + ] { + catalog.push(PermissionOperationDescriptor { + operation: operation.into(), label: label.into(), + description: "Operation use permission and directional Backend target control grants are separate. Acceptance does not prove processing or success; lifecycle state conditions still apply.".into(), + resource_kinds: vec![], + availability: PermissionOperationAvailability::Unavailable { reason: "T-803 connection pending: current control still requires target-specific Worker permissions and uses worker.notify/worker.result. Existing restricted entries are preserved without conversion.".into() }, + }); + } + for (operation, label) in [ + ("subworker.spawn", "Create Internal SubWorker"), + ("subworker.send_input", "Submit to Internal SubWorker"), + ("subworker.stop", "Stop Internal SubWorker"), + ("subworker.observe", "Observe Internal SubWorker"), + ] { + catalog.push(PermissionOperationDescriptor { + operation: operation.into(), label: label.into(), + description: "Parent-owned Internal SubWorkers are distinct from independently controlled Runtime Workers. Existing exact parent/name scopes are retained.".into(), + resource_kinds: vec![], + availability: PermissionOperationAvailability::Unavailable { reason: "A safe parent-bound management selector is not yet available. Existing scopes may be retained or removed, not widened by replacing parent identifiers.".into() }, + }); + } + catalog +} + +/// Validate additions only, after receipt lookup and current-policy CAS in the +/// management store transaction. Unsupported entries may be kept exactly in their +/// original lane or removed, never copied to another lane or broadened. +pub fn validate_managed_subject_permissions( + current: Option<&SubjectPermissions>, + update: &SubjectPermissionsUpdate, +) -> Result<(), String> { + let catalog = subject_permission_operations(); + for (next, previous) in [ + ( + &update.execution, + current.map(|p| p.execution.as_slice()).unwrap_or_default(), + ), + ( + &update.delegation, + current.map(|p| p.delegation.as_slice()).unwrap_or_default(), + ), + ] { + for permission in next { + if previous.contains(permission) { + continue; + } + permission.validate()?; + let descriptor = catalog + .iter() + .find(|d| d.operation == permission.operation) + .ok_or_else(|| format!("unknown management operation: {}", permission.operation))?; + if !matches!( + descriptor.availability, + PermissionOperationAvailability::Available + ) { + return Err(format!( + "operation is unavailable for management: {}", + permission.operation + )); + } + if !descriptor + .resource_kinds + .contains(&permission.resource.kind()) + { + return Err(format!( + "invalid resource kind for operation: {}", + permission.operation + )); + } + if permission.operation == "bash.execute" + && !matches!(&permission.resource, + PermissionResource::Workdir { path, recursive: true, .. } if path == ".") + { + return Err( + "bash.execute requires the whole Workdir (path '.' and recursive true)".into(), + ); + } + } + } + Ok(()) +} diff --git a/crates/server-api/src/authorization/catalog_tests.rs b/crates/server-api/src/authorization/catalog_tests.rs new file mode 100644 index 000000000..109ded979 --- /dev/null +++ b/crates/server-api/src/authorization/catalog_tests.rs @@ -0,0 +1,119 @@ +//! Management validation is exercised from JSON; stored legacy policy remains readable. +use super::*; +use serde_json::json; + +fn permission(operation: &str, resource: serde_json::Value) -> ScopedPermission { + serde_json::from_value(json!({"operation":operation,"resource":resource})).unwrap() +} +fn update( + execution: Vec, + delegation: Vec, +) -> SubjectPermissionsUpdate { + SubjectPermissionsUpdate { + operation_id: "save".into(), + expected_change_id: Some("observed".into()), + execution, + delegation, + } +} + +#[test] +fn managed_additions_reject_unknown_mismatched_and_unavailable_entries() { + let workspace = json!({"kind":"workspace","workspace_id":"w"}); + for grant in [ + permission("ticket.future", workspace.clone()), + permission("ticket.read", workspace.clone()), + permission( + "ticket.create", + json!({"kind":"ticket","workspace_id":"w","ticket_id":"T-1"}), + ), + permission("worker.messaging", workspace.clone()), + permission( + "worker.stop", + json!({"kind":"worker","runtime_id":"r","worker_id":"worker"}), + ), + permission("ticket", json!({"kind":"feature"})), + permission( + "bash.execute", + json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}), + ), + ] { + assert!( + validate_managed_subject_permissions(None, &update(vec![grant.clone()], vec![])) + .is_err(), + "{grant:?}" + ); + assert!( + validate_managed_subject_permissions(None, &update(vec![], vec![grant.clone()])) + .is_err(), + "{grant:?}" + ); + } + assert!(validate_managed_subject_permissions(None, &update(vec![permission("ticket.create", workspace)], vec![permission("filesystem.write", json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}))])).is_ok()); +} + +#[test] +fn unsupported_legacy_entries_are_exact_lane_preserved_not_converted_or_extended() { + let legacy: SubjectPermissions = serde_json::from_value(json!({ + "change_id":"observed", + "execution":[ + {"operation":"ticket","resource":{"kind":"feature"}}, + {"operation":"worker.notify","resource":{"kind":"worker","runtime_id":"r","worker_id":"one"}}, + {"operation":"old.unknown","resource":{"kind":"workspace","workspace_id":"w"}} + ], + "delegation":[{"operation":"worker.result","resource":{"kind":"worker","runtime_id":"r","worker_id":"origin"}}] + })).unwrap(); + let mut next = update(legacy.execution.clone(), legacy.delegation.clone()); + next.execution.push(permission( + "ticket.create", + json!({"kind":"workspace","workspace_id":"w"}), + )); + assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_ok()); + assert_eq!( + serde_json::to_value(&legacy).unwrap()["execution"][1]["operation"], + "worker.notify" + ); + next.delegation.push(legacy.execution[1].clone()); + assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err()); + next.delegation.pop(); + next.execution[1].resource = PermissionResource::Worker { + runtime_id: "r".into(), + worker_id: "two".into(), + }; + assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err()); + assert!(validate_managed_subject_permissions(Some(&legacy), &update(vec![], vec![])).is_ok()); +} + +#[test] +fn catalog_wire_has_no_worker_target_editors_and_no_implicit_feature_grants() { + let wire = serde_json::to_value(subject_permission_operations()).unwrap(); + let entries = wire.as_array().unwrap(); + let mut names = std::collections::BTreeSet::new(); + for entry in entries { + assert!( + names.insert(entry["operation"].as_str().unwrap()), + "duplicate descriptor: {entry}" + ); + assert!(!entry["label"].as_str().unwrap().is_empty()); + assert!(!entry["description"].as_str().unwrap().is_empty()); + assert!( + !entry["resource_kinds"] + .as_array() + .unwrap() + .contains(&json!("worker")) + ); + if entry["availability"]["kind"] == "available" { + assert!(!entry["resource_kinds"].as_array().unwrap().is_empty()); + } + } + assert!(!names.contains("ticket")); + assert!(!names.contains("worker.notify")); + assert!(!names.contains("worker.result")); + let mcp = entries.iter().find(|d| d["operation"] == "mcp").unwrap(); + assert_eq!(mcp["resource_kinds"], json!(["feature"])); + let messaging = entries + .iter() + .find(|d| d["operation"] == "worker.messaging") + .unwrap(); + assert_eq!(messaging["availability"]["kind"], "unavailable"); +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 17eb9dd12..000c94079 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -34,6 +34,8 @@ pub use workspace_config::*; #[serde(deny_unknown_fields)] pub struct SubjectPermissionsResponse { pub permissions: Option, + pub can_manage: bool, + pub operations: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -11060,6 +11062,16 @@ pub fn memory_api_typescript() -> String { MemoryStagingRecord::decl(&config), MemoryStagingEntry::decl(&config), MemoryStagingListResponse::decl(&config), + authorization::PermissionResource::decl(&config), + authorization::ScopedPermission::decl(&config), + authorization::SubjectPermissions::decl(&config), + authorization::SubjectPermissionsUpdate::decl(&config), + authorization::PermissionResourceKind::decl(&config), + authorization::PermissionOperationAvailability::decl(&config), + authorization::PermissionOperationDescriptor::decl(&config), + SubjectPermissionsResponse::decl(&config), + SubjectPermissionsMutationResponse::decl(&config), + SubjectPermissionInterruption::decl(&config), ]; // These browser DTOs include nested types owned by `memory` and the Worker // launch API. Keep their exported names explicit so the generated Web diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs index 5af302b5d..bd878eda7 100644 --- a/crates/subjektiv/src/permissions.rs +++ b/crates/subjektiv/src/permissions.rs @@ -36,6 +36,27 @@ impl SubjektivStore { &self, subject_id: &str, update: &SubjectPermissionsUpdate, + ) -> Result { + self.replace_subject_permissions(subject_id, update, false) + } + + /// Management replacement rejects new unsupported operation/resource pairs while + /// retaining exact legacy entries in their original execution/delegation lane. + /// Validation shares the existing CAS/receipt transaction so a stale editor cannot + /// retain a removed legacy grant. Exact replay remains historical evidence. + pub fn set_managed_subject_permissions( + &self, + subject_id: &str, + update: &SubjectPermissionsUpdate, + ) -> Result { + self.replace_subject_permissions(subject_id, update, true) + } + + fn replace_subject_permissions( + &self, + subject_id: &str, + update: &SubjectPermissionsUpdate, + managed: bool, ) -> Result { validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; validate_authorization_identifier(&update.operation_id) @@ -70,6 +91,10 @@ impl SubjektivStore { if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() { return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into())); } + if managed { + server_api::authorization::validate_managed_subject_permissions(current.as_ref(), update) + .map_err(SubjektivError::InvalidRecord)?; + } let source = SubjectPermissions { change_id: issued_id("permissions"), execution: update.execution.clone(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bb01ce571..df157fb74 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -9708,20 +9708,28 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api()?.clone(); let actor = subject_authorization::administration_actor(&context) .map_err(ApiError::into_repository_api_error)?; - require_workspace_owner( - &api, - &workspace_id, - actor, - "Subject permission administration", - ) - .await - .map_err(ApiError::into_repository_api_error)?; + validate_workspace_scope(&api, &workspace_id) + .map_err(ApiError::into_repository_api_error)?; + let workspace = api + .store + .get_workspace(&workspace_id) + .await + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)? + .ok_or_else(|| { + ApiError::from(Error::WorkspaceIdMismatch).into_repository_api_error() + })?; + let can_manage = workspace.owner_account_id == actor.account_id; api.store .db_executor() .run(move || -> ApiResult<_> { Ok(open_subjektiv_store(&api)? .subject_permissions(&subject_id) - .map(|permissions| server_api::SubjectPermissionsResponse { permissions }) + .map(|permissions| server_api::SubjectPermissionsResponse { + permissions, + can_manage, + operations: server_api::authorization::subject_permission_operations(), + }) .map_err(subjektiv_store_error)?) }) .await @@ -9762,7 +9770,7 @@ impl server_api::ServerApi for ServerApiContractService { let was_current = before.as_ref().map(|policy| &policy.change_id) == request.expected_change_id.as_ref(); let permissions = store - .set_subject_permissions(&mutation_subject, &request) + .set_managed_subject_permissions(&mutation_subject, &request) .map_err(subjektiv_store_error)?; let changed = was_current && before.as_ref().map(|source| &source.change_id) diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs index 06253056d..fa291c807 100644 --- a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -7,6 +7,8 @@ use super::*; #[path = "subject_job_model_supply_tests.rs"] mod subject_job_model_supply; +#[path = "subject_permission_management_tests.rs"] +mod subject_permission_management; use agen::tool::{Tool, ToolExecutionContext, ToolOutput}; use server_api::authorization::{ PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, @@ -42,6 +44,14 @@ fn create_permission() -> ScopedPermission { }, } } +fn query_permission() -> ScopedPermission { + ScopedPermission { + operation: "ticket.query".into(), + resource: PermissionResource::Workspace { + workspace_id: TEST_WORKSPACE_ID.into(), + }, + } +} fn authorization_path() -> String { format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check") } @@ -179,6 +189,27 @@ impl Fixture { self.subject ) } + // Seed only intentionally unsupported saved input, never a management save. + // This models pre-catalog policies without teaching the HTTP validator to issue them. + fn seed_saved_policy( + &self, + execution: Vec, + delegation: Vec, + ) -> SubjectPermissions { + let store = open_subjektiv_store(&self.api).unwrap(); + let previous = store.subject_permissions(&self.subject).unwrap(); + store + .set_subject_permissions( + &self.subject, + &SubjectPermissionsUpdate { + operation_id: Uuid::now_v7().to_string(), + expected_change_id: previous.map(|policy| policy.change_id), + execution, + delegation, + }, + ) + .unwrap() + } async fn assign( &self, execution: Vec, @@ -412,6 +443,7 @@ async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() { let (status, body) = f.check(&f.source, &permission).await; assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}"); } + f.seed_saved_policy(vec![activation("ticket")], vec![]); f.assign(vec![activation("ticket"), create_permission()], vec![]) .await; for permission in [activation("ticket"), create_permission()] { @@ -597,7 +629,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso }, }) .collect(); - f.assign(workspace_grants.clone(), vec![]).await; + f.seed_saved_policy(workspace_grants.clone(), vec![]); for permission in &requested { let (status, body) = f.check(&f.source, permission).await; assert_eq!( @@ -612,8 +644,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso .iter() .map(|permission| permission.operation.split('.').next().unwrap()) .collect(); - f.assign(feature_names.into_iter().map(activation).collect(), vec![]) - .await; + f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]); for permission in &requested { assert_eq!( f.check(&f.source, permission).await.0, @@ -621,7 +652,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso "{permission:?}" ); } - f.assign(requested.clone(), vec![]).await; + f.seed_saved_policy(requested.clone(), vec![]); for permission in &requested { let (status, body) = f.check(&f.source, permission).await; assert_eq!( @@ -647,7 +678,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() { let f = Fixture::new().await; let permissions = concrete_resource_permissions(&f); - f.assign(permissions.clone(), vec![]).await; + f.seed_saved_policy(permissions.clone(), vec![]); for permission in permissions .iter() .filter(|permission| permission.resource.workspace_id().is_some()) @@ -796,8 +827,8 @@ async fn signed_host_ungranted_operation_does_not_disclose_ticket_or_subject_exi #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() { let f = Fixture::new().await; - f.assign(vec![activation("ticket")], vec![]).await; - let body = serde_json::to_value(activation("ticket")).unwrap(); + f.assign(vec![create_permission()], vec![]).await; + let body = serde_json::to_value(create_permission()).unwrap(); let (status, error) = response( &f.api, human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())), @@ -895,7 +926,7 @@ async fn authorization_rejects_missing_source_unknown_identity_fields_and_tamper } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() { +async fn only_owner_account_can_assign_shrink_and_revoke_subject_permissions() { let f = Fixture::new().await; let (status, body) = response( &f.api, @@ -906,8 +937,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission assert!(body["permissions"].is_null()); let original = f .assign( - vec![activation("ticket"), create_permission()], - vec![activation("ticket")], + vec![create_permission(), query_permission()], + vec![create_permission()], ) .await; let (status, body) = response( @@ -959,9 +990,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission .await; assert_eq!( status, - StatusCode::FORBIDDEN, - "foreign account {method}: {body}" + if method == "GET" { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }, + "non-owner account {method}: {body}" ); + if method == "GET" { + assert_eq!(body["can_manage"], false); + assert_eq!( + body["permissions"], + serde_json::to_value(&original).unwrap() + ); + } } assert_eq!( open_subjektiv_store(&f.api) @@ -970,7 +1012,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission .unwrap(), Some(original.clone()) ); - let shrunk = f.assign(vec![activation("ticket")], vec![]).await; + let shrunk = f.assign(vec![query_permission()], vec![]).await; assert_ne!(shrunk.change_id, original.change_id); assert_eq!( f.check(&f.source, &create_permission()).await.0, @@ -989,7 +1031,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission let revoked = f.assign(vec![], vec![]).await; assert!(revoked.execution.is_empty() && revoked.delegation.is_empty()); assert_eq!( - f.check(&f.source, &activation("ticket")).await.0, + f.check(&f.source, &query_permission()).await.0, StatusCode::FORBIDDEN ); } @@ -1140,6 +1182,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_ ("old-feature-only", vec![activation("ticket")]), ("wrong-resource", vec![wrong_workspace]), ] { + f.seed_saved_policy(permissions.clone(), vec![]); f.assign(permissions, vec![]).await; client.requests.lock().unwrap().clear(); let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await; @@ -1182,7 +1225,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_ 1, "genuine SQLite Ticket backend result: {created}" ); - f.assign(vec![activation("ticket")], vec![]).await; + f.assign(vec![], vec![]).await; let result = tool .execute( r#"{"title":"revoked after install"}"#, @@ -1522,7 +1565,7 @@ fn creation_record_counts(f: &Fixture) -> [i64; 3] { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() { let f = Fixture::new().await; - let permissions = vec![activation("ticket"), create_permission()]; + let permissions = vec![query_permission(), create_permission()]; f.assign(vec![], permissions.clone()).await; let mut child_request = f.child_request("redelegating-editor", permissions.clone()); child_request["execution_grant"]["redelegation"] = json!(permissions); @@ -1639,7 +1682,7 @@ async fn generic_create_routes_preserve_human_and_non_subject_worker_creation() async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates() { let f = Fixture::new().await; - let permissions = vec![activation("ticket"), create_permission()]; + let permissions = vec![query_permission(), create_permission()]; f.assign(vec![], permissions.clone()).await; let mut missing_id = f.child_request("control-grant-recovery", permissions.clone()); missing_id @@ -1836,7 +1879,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o let (status, body) = f .spawn( &child, - f.child_request("undelegatable-grandchild", vec![activation("ticket")]), + f.child_request("undelegatable-grandchild", vec![query_permission()]), ) .await; assert_eq!( @@ -1849,12 +1892,12 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() { let f = Fixture::new().await; - f.assign(vec![], vec![activation("ticket")]).await; - let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]); + f.assign(vec![], vec![query_permission()]).await; + let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]); singleton["singleton_key"] = json!("model-chosen-singleton"); - let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]); + let mut body_attachment = f.child_request("subject-escalation", vec![query_permission()]); body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}}); - let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]); + let mut spoof = f.child_request("origin-escalation", vec![query_permission()]); spoof["execution_grant"]["subject_id"] = json!(f.subject); for request in [ singleton, @@ -1876,12 +1919,9 @@ async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy() { let f = Fixture::new().await; - f.assign(vec![], vec![activation("ticket")]).await; - let child = f.child("revocable-child", vec![activation("ticket")]).await; - assert_eq!( - f.check(&child, &activation("ticket")).await.0, - StatusCode::OK - ); + f.assign(vec![], vec![query_permission()]).await; + let child = f.child("revocable-child", vec![query_permission()]).await; + assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK); let before = open_subjektiv_store(&f.api) .unwrap() .worker_execution_grant(&child.runtime_id, &child.worker_id) @@ -1889,7 +1929,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_ .unwrap(); f.assign(vec![], vec![]).await; assert_eq!( - f.check(&child, &activation("ticket")).await.0, + f.check(&child, &query_permission()).await.0, StatusCode::FORBIDDEN ); let observation = f @@ -1907,7 +1947,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_ Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await; assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!( - f.check(&child, &activation("ticket")).await.0, + f.check(&child, &query_permission()).await.0, StatusCode::FORBIDDEN ); assert_eq!( @@ -1919,33 +1959,33 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_ ); // Regranting identical permissions changes the assignment identity. Old // grants remain revoked rather than refreshing during restore/reconnect. - f.assign(vec![], vec![activation("ticket")]).await; + f.assign(vec![], vec![query_permission()]).await; assert_eq!( - f.check(&child, &activation("ticket")).await.0, + f.check(&child, &query_permission()).await.0, StatusCode::FORBIDDEN ); let current = f - .child("current-before-origin-release", vec![activation("ticket")]) + .child("current-before-origin-release", vec![query_permission()]) .await; assert_eq!( - f.check(¤t, &activation("ticket")).await.0, + f.check(¤t, &query_permission()).await.0, StatusCode::OK ); f.release_source_lease(); assert_eq!( - f.check(¤t, &activation("ticket")).await.0, + f.check(¤t, &query_permission()).await.0, StatusCode::CONFLICT, "the captured source lease is no longer current" ); assert_eq!( - f.check(&f.source, &activation("ticket")).await.0, + f.check(&f.source, &query_permission()).await.0, StatusCode::UNAUTHORIZED, "released body fails signed-source authentication instead of falling through to legacy compatibility" ); let (status, _) = f .spawn( &f.source, - f.child_request("released-origin-spawn", vec![activation("ticket")]), + f.child_request("released-origin-spawn", vec![query_permission()]), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -1954,7 +1994,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_ #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() { let f = Fixture::new().await; - f.assign(vec![activation("ticket")], vec![]).await; + f.assign(vec![], vec![]).await; let client = f.client(&f.source); let (mut host, installed) = ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await; @@ -2021,7 +2061,7 @@ async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_bac let backend = browser_ticket_backend(&f.api).unwrap(); let tickets = backend.list(Default::default()).unwrap(); assert_eq!(tickets.len(), 1); - f.assign(vec![activation("ticket")], vec![]).await; + f.assign(vec![], vec![]).await; assert!( invoke .execute(&args, ToolExecutionContext::direct()) @@ -2041,7 +2081,7 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_ { let f = Fixture::new().await; let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null, - "execution":[activation("ticket")],"delegation":[]}); + "execution":[create_permission()],"delegation":[]}); let (status, body) = response( &f.api, human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())), @@ -2095,13 +2135,13 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_ #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() { let f = Fixture::new().await; - f.assign(vec![], vec![activation("ticket"), create_permission()]) + f.assign(vec![], vec![query_permission(), create_permission()]) .await; let mut request = f.child_request( "redelegating-parent", - vec![activation("ticket"), create_permission()], + vec![query_permission(), create_permission()], ); - request["execution_grant"]["redelegation"] = json!([activation("ticket")]); + request["execution_grant"]["redelegation"] = json!([query_permission()]); let (status, body) = f.spawn(&f.source, request).await; assert_eq!(status, StatusCode::OK, "{body}"); let parent = RuntimeWorkerRef::new( @@ -2111,7 +2151,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling( let (status, body) = f .spawn( &parent, - f.child_request("allowed-grandchild", vec![activation("ticket")]), + f.child_request("allowed-grandchild", vec![query_permission()]), ) .await; assert_eq!(status, StatusCode::OK, "{body}"); @@ -2129,7 +2169,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling( "keep immediate lineage instead of flattening to the body" ); assert_eq!( - f.check(&grandchild, &activation("ticket")).await.0, + f.check(&grandchild, &query_permission()).await.0, StatusCode::OK ); assert_eq!( @@ -2151,7 +2191,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling( assert_eq!(f.runtime.spawn_requests().len(), calls); f.assign(vec![], vec![]).await; assert_eq!( - f.check(&grandchild, &activation("ticket")).await.0, + f.check(&grandchild, &query_permission()).await.0, StatusCode::FORBIDDEN ); } @@ -2159,8 +2199,8 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling( #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() { let f = Fixture::new().await; - f.assign(vec![], vec![activation("ticket")]).await; - let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]); + f.assign(vec![], vec![query_permission()]).await; + let mut request = f.child_request("profile-bound-once", vec![query_permission()]); request["profile"] = json!("project:authority-editor"); let (status, body) = f.spawn(&f.source, request.clone()).await; assert_eq!(status, StatusCode::OK, "{body}"); @@ -2196,7 +2236,7 @@ async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_oper #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() { let f = Fixture::new().await; - f.assign(vec![activation("ticket")], vec![]).await; + f.assign(vec![], vec![]).await; let client = f.client(&f.source); let (_host, installed, service) = ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await; @@ -2272,7 +2312,7 @@ async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source( "Service authorized" ); assert_eq!(ticket_posts(&client), 1); - f.assign(vec![activation("ticket")], vec![]).await; + f.assign(vec![], vec![]).await; assert!( tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service"))) .await diff --git a/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs b/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs new file mode 100644 index 000000000..6b5d0a6f4 --- /dev/null +++ b/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs @@ -0,0 +1,579 @@ +//! Management HTTP integration: real router/account authentication, catalog +//! validation, Subject SQLite policy/receipts and deletion; no running service or +//! model. The connected Ticket case also uses the signed Worker Host, real Tool +//! and Ticket backend via the parent suite's isolated Runtime fixture. +use super::*; + +struct ManagementFixture { + api: WorkspaceApi, + owner: String, + subject: String, + _dir: tempfile::TempDir, +} + +impl ManagementFixture { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let owner = seed_test_workspace_owner_token(api.store.as_ref(), "permission-manager").await; + let subject = open_subjektiv_store(&api) + .unwrap() + .create_subject(crate::subjektiv::SubjectRole::new("permission-management").unwrap()) + .unwrap() + .id; + Self { + api, + owner, + subject, + _dir: dir, + } + } + + fn path(&self) -> String { + format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", + self.subject + ) + } + + async fn read(&self) -> Value { + let (status, body) = response( + &self.api, + human_request(&self.owner, "GET", &self.path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + body + } + + async fn save(&self, update: Value) -> Value { + let (status, body) = response( + &self.api, + human_request(&self.owner, "POST", &self.path(), Some(update)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + body + } + + fn assert_current(&self, expected: &Value) { + let saved = open_subjektiv_store(&self.api) + .unwrap() + .subject_permissions(&self.subject) + .unwrap(); + assert_eq!(serde_json::to_value(saved).unwrap(), *expected); + } +} + +fn update(screen: &Value, operation: &str, execution: Value, delegation: Value) -> Value { + json!({"operation_id":operation,"expected_change_id":screen["permissions"]["change_id"], + "execution":execution,"delegation":delegation}) +} + +#[tokio::test] +async fn management_read_exposes_capability_and_catalog_without_enabling_pending_worker_operations() +{ + let f = ManagementFixture::new().await; + let screen = f.read().await; + assert!(screen["permissions"].is_null()); + assert_eq!(screen["can_manage"], true); + let operations = screen["operations"].as_array().expect("management catalog"); + let create = operations + .iter() + .find(|entry| entry["operation"] == "ticket.create") + .unwrap(); + assert_eq!(create["availability"]["kind"], "available"); + assert!( + create["resource_kinds"] + .as_array() + .unwrap() + .contains(&json!("workspace")) + ); + assert!(!create["label"].as_str().unwrap().is_empty()); + assert!(!create["description"].as_str().unwrap().is_empty()); + let messaging = operations + .iter() + .find(|entry| entry["operation"] == "worker.messaging") + .unwrap(); + assert_eq!(messaging["availability"]["kind"], "unavailable"); + assert!( + !messaging["availability"]["reason"] + .as_str() + .unwrap() + .is_empty() + ); + for name in [ + "worker.messaging", + "worker.send_input", + "worker.cancel", + "worker.stop", + "worker.restore", + "worker.remove", + "worker.observe", + ] { + let operation = operations + .iter() + .find(|entry| entry["operation"] == name) + .unwrap(); + assert_eq!( + operation["availability"]["kind"], "unavailable", + "pending Worker contracts: {operation}" + ); + assert_eq!( + operation["resource_kinds"], + json!([]), + "do not offer duplicate target control grants" + ); + } +} + +#[tokio::test] +async fn two_management_screens_conflict_without_overwriting_saved_execution_or_delegation() { + let f = ManagementFixture::new().await; + let first = f.read().await; + let second = f.read().await; + let saved = f + .save(update( + &first, + "screen-one", + json!([create_permission()]), + json!([query_permission()]), + )) + .await; + let stale = update( + &second, + "screen-two", + json!([]), + json!([create_permission()]), + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.path(), Some(stale)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert!( + body["message"].is_string(), + "conflict must be visible: {body}" + ); + assert_eq!(f.read().await["permissions"], saved["permissions"]); + f.assert_current(&saved["permissions"]); +} + +#[tokio::test] +async fn discarded_save_response_recovers_by_exact_replay_and_authoritative_reread() { + let f = ManagementFixture::new().await; + let input = update( + &f.read().await, + "lost-save-response", + json!([create_permission()]), + json!([query_permission()]), + ); + // Commit through the real HTTP route, then discard the entire response. This + // models response loss, not process death or rollback of the committed save. + drop( + build_router(f.api.clone()) + .oneshot(human_request( + &f.owner, + "POST", + &f.path(), + Some(input.clone()), + )) + .await + .unwrap(), + ); + let reread = f.read().await; + assert_eq!(reread["permissions"]["execution"], input["execution"]); + assert_eq!(reread["permissions"]["delegation"], input["delegation"]); + let replay = f.save(input.clone()).await; + assert_eq!(replay["permissions"], reread["permissions"]); + assert_eq!(replay["interruptions"], json!([])); + assert_eq!(f.read().await["permissions"], reread["permissions"]); + f.assert_current(&reread["permissions"]); + let mut changed = input; + changed["execution"] = json!([]); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.path(), Some(changed)), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "operation identity cannot be reused: {body}" + ); + f.assert_current(&reread["permissions"]); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn historical_save_receipt_after_shrink_and_revoke_never_restores_current_authority() { + let f = Fixture::new().await; + let (status, screen) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK); + let original_input = update( + &screen, + "before-shrink", + json!([create_permission()]), + json!([create_permission()]), + ); + let (status, original) = response( + &f.api, + human_request( + &f.owner, + "POST", + &f.policy_path(), + Some(original_input.clone()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{original}"); + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::OK + ); + for delegation in [vec![create_permission()], vec![]] { + let current = f.assign(vec![], delegation).await; + let (status, replay) = response( + &f.api, + human_request( + &f.owner, + "POST", + &f.policy_path(), + Some(original_input.clone()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "historical receipt: {replay}"); + assert_eq!(replay["permissions"], original["permissions"]); + assert_eq!(replay["interruptions"], json!([])); + let (status, read) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(read["permissions"], serde_json::to_value(¤t).unwrap()); + assert_ne!(read["permissions"], replay["permissions"]); + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap(), + Some(current) + ); + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::FORBIDDEN + ); + } +} + +#[tokio::test] +async fn subject_deletion_rejects_stale_screen_save_and_exact_receipt_replay_without_revival() { + let f = ManagementFixture::new().await; + let screen = f.read().await; + let initial_input = update( + &screen, + "save-before-delete", + json!([create_permission()]), + json!([query_permission()]), + ); + let saved = f.save(initial_input.clone()).await; + let stale_screen = json!({"permissions":saved["permissions"]}); + let late_input = update( + &stale_screen, + "late-screen-save", + json!([query_permission()]), + json!([]), + ); + let (status, body) = response( + &f.api, + human_request( + &f.owner, + "DELETE", + &format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}", + f.subject + ), + Some(json!({"operation_id":"delete-after-save"})), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["completed"], true); + for input in [late_input, initial_input] { + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.path(), Some(input)), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "deleted Subject cannot be revived: {body}" + ); + assert!( + open_subjektiv_store(&f.api) + .unwrap() + .subject(&f.subject) + .unwrap() + .is_none() + ); + } + let (status, body) = response(&f.api, human_request(&f.owner, "GET", &f.path(), None)).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .is_err() + ); +} + +#[tokio::test] +async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() { + let f = ManagementFixture::new().await; + let account = seed_test_api_token(f.api.store.as_ref(), "permission-reader"); + let initial = f.read().await; + let input = update( + &initial, + "owner-only-save", + json!([create_permission()]), + json!([]), + ); + let (status, read) = response(&f.api, human_request(&account, "GET", &f.path(), None)).await; + assert_eq!(status, StatusCode::OK, "{read}"); + assert_eq!(read["can_manage"], false); + assert_eq!(read["permissions"], initial["permissions"]); + let (status, body) = response( + &f.api, + human_request(&account, "POST", &f.path(), Some(input.clone())), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + f.assert_current(&Value::Null); + let saved = f.save(input.clone()).await; + let (status, body) = response( + &f.api, + human_request(&account, "POST", &f.path(), Some(input)), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "exact owner receipt replay must still authorize the caller: {body}" + ); + f.assert_current(&saved["permissions"]); +} + +#[tokio::test] +async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_rejected_without_policy_write() + { + let f = ManagementFixture::new().await; + let screen = f.read().await; + let mut unknown = create_permission(); + unknown.operation = "ticket.future_operation".into(); + let mut mismatch = create_permission(); + mismatch.resource = PermissionResource::Ticket { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: "not-a-collection".into(), + }; + let mut unavailable = query_permission(); + unavailable.operation = "worker.messaging".into(); + for (name, permission) in [ + ("unknown", unknown), + ("legacy", activation("ticket")), + ("resource-mismatch", mismatch), + ("unavailable", unavailable), + ] { + for lane in ["execution", "delegation"] { + let mut input = update( + &screen, + &format!("reject-{name}-{lane}"), + json!([]), + json!([]), + ); + input[lane] = json!([permission]); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.path(), Some(input)), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}"); + assert!(body["message"].is_string(), "{body}"); + f.assert_current(&Value::Null); + assert!(f.read().await["permissions"].is_null()); + } + } +} + +#[tokio::test] +async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_removed() { + let f = ManagementFixture::new().await; + let execution = vec![activation("ticket")]; + let mut unknown = query_permission(); + unknown.operation = "legacy.custom_operation".into(); + let delegation = vec![unknown]; + open_subjektiv_store(&f.api) + .unwrap() + .set_subject_permissions( + &f.subject, + &SubjectPermissionsUpdate { + operation_id: "seed-pre-catalog-policy".into(), + expected_change_id: None, + execution: execution.clone(), + delegation: delegation.clone(), + }, + ) + .unwrap(); + let screen = f.read().await; + let saved = f + .save(update( + &screen, + "retain-existing", + json!([execution[0], create_permission()]), + json!(delegation), + )) + .await; + assert_eq!( + saved["permissions"]["execution"], + json!([execution[0], create_permission()]) + ); + assert_eq!(saved["permissions"]["delegation"], json!(delegation)); + let screen = f.read().await; + for (name, execution, delegation) in [ + ( + "copy-legacy-lane", + json!([activation("ticket"), create_permission()]), + json!([activation("ticket")]), + ), + ( + "copy-unknown-lane", + json!([delegation[0], create_permission()]), + json!(delegation), + ), + ] { + let (status, body) = response( + &f.api, + human_request( + &f.owner, + "POST", + &f.path(), + Some(update(&screen, name, execution, delegation)), + ), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "retention cannot expand into another lane: {body}" + ); + f.assert_current(&saved["permissions"]); + } + let removed = f + .save(update( + &screen, + "remove-unsupported", + json!([create_permission()]), + json!([]), + )) + .await; + assert_eq!( + removed["permissions"]["execution"], + json!([create_permission()]) + ); + assert_eq!(removed["permissions"]["delegation"], json!([])); + f.assert_current(&removed["permissions"]); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn management_saved_policy_controls_real_ticket_tool_and_raw_http_before_backend_writes() { + let f = Fixture::new().await; + let client = f.client(&f.source); + let (installed, tool) = ticket_tool_host( + &f._dir.path().join("management-ticket-tool"), + client.clone(), + ) + .await; + assert!(installed); + let tool = tool.unwrap(); + assert!( + client.requests.lock().unwrap().is_empty(), + "local registration is not authorization" + ); + let backend = browser_ticket_backend(&f.api).unwrap(); + for state in ["unset", "out-of-scope", "allowed", "revoked"] { + match state { + "unset" => {} + "out-of-scope" => { + // Intentionally migrated, unsupported kind: management may retain + // this exact saved entry but it must never authorize collection creation. + let mut wrong_kind = create_permission(); + wrong_kind.resource = PermissionResource::Ticket { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: "legacy-ticket-scope".into(), + }; + f.seed_saved_policy(vec![wrong_kind.clone()], vec![]); + f.assign(vec![wrong_kind], vec![]).await; + } + "allowed" => { + f.assign(vec![create_permission()], vec![]).await; + } + "revoked" => { + f.assign(vec![], vec![]).await; + } + _ => unreachable!(), + } + let before = backend.list(Default::default()).unwrap().len(); + let posts = ticket_posts(&client); + let result = tool + .execute( + &json!({"title":format!("Managed policy {state}")}).to_string(), + ToolExecutionContext::direct(), + ) + .await; + if state == "allowed" { + let output = value(result.unwrap()); + assert_eq!(ticket_posts(&client), posts + 1); + let records = backend.list(Default::default()).unwrap(); + assert_eq!(records.len(), before + 1, "real SQLite write: {output}"); + assert!( + records + .iter() + .any(|record| record.title == "Managed policy allowed") + ); + } else { + assert!(result.is_err(), "{state}: Tool must deny"); + assert_eq!( + ticket_posts(&client), + posts, + "{state}: no downstream create dispatch" + ); + let (status, body) = response( + &f.api, + runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets"), + serde_json::to_vec(&NewTicket::new(format!("Raw denied {state}"))).unwrap(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{state}: direct Backend path also rechecks: {body}" + ); + assert_eq!( + backend.list(Default::default()).unwrap().len(), + before, + "{state}: denied writes = 0" + ); + } + } +} diff --git a/openapi/server-api.json b/openapi/server-api.json index ad01a6caf..f82940b4c 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -7023,6 +7023,72 @@ ], "type": "object" }, + "PermissionOperationAvailability": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "available", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unavailable", + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "kind", + "reason" + ], + "type": "object" + } + ] + }, + "PermissionOperationDescriptor": { + "additionalProperties": false, + "properties": { + "availability": { + "$ref": "#/components/schemas/PermissionOperationAvailability" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "resource_kinds": { + "description": "Accepted management input kinds, not a claim that a target exists or is usable.", + "items": { + "$ref": "#/components/schemas/PermissionResourceKind" + }, + "type": "array" + } + }, + "required": [ + "operation", + "label", + "description", + "resource_kinds", + "availability" + ], + "type": "object" + }, "PermissionResource": { "oneOf": [ { @@ -7359,6 +7425,27 @@ } ] }, + "PermissionResourceKind": { + "enum": [ + "job", + "ticket", + "objective", + "merge_request", + "subject", + "drive_entry", + "drive_request", + "repository", + "workdir_attachment", + "workspace_config", + "network", + "sub_worker", + "feature", + "workspace", + "workdir", + "worker" + ], + "type": "string" + }, "PluginAddArtifactRequest": { "additionalProperties": false, "properties": { @@ -11257,6 +11344,15 @@ "SubjectPermissionsResponse": { "additionalProperties": false, "properties": { + "can_manage": { + "type": "boolean" + }, + "operations": { + "items": { + "$ref": "#/components/schemas/PermissionOperationDescriptor" + }, + "type": "array" + }, "permissions": { "anyOf": [ { @@ -11268,6 +11364,10 @@ ] } }, + "required": [ + "can_manage", + "operations" + ], "type": "object" }, "SubjectPermissionsUpdate": { @@ -19062,7 +19162,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f" + "x-yoi-source-digest": "sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index e99726a94..d966456a2 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index ab6547ec4..2a2efa87e 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -111,6 +111,118 @@ export type MemoryStagingListResponse = { diagnostics: Array; }; +export type PermissionResource = + | { "kind": "job"; workspace_id: string; job_id: string } + | { "kind": "ticket"; workspace_id: string; ticket_id: string } + | { "kind": "objective"; workspace_id: string; objective_id: string } + | { "kind": "merge_request"; workspace_id: string; merge_request_id: string } + | { "kind": "subject"; workspace_id: string; subject_id: string } + | { "kind": "drive_entry"; workspace_id: string; node_id: string } + | { "kind": "drive_request"; workspace_id: string; request_id: string } + | { "kind": "repository"; workspace_id: string; repository_key: string } + | { + "kind": "workdir_attachment"; + workspace_id: string; + connection_id: string; + } + | { + "kind": "workspace_config"; + workspace_id: string; + path: string; + recursive: boolean; + } + | { "kind": "network"; url: string } + | { + "kind": "sub_worker"; + runtime_id: string; + worker_id: string; + name: string; + } + | { "kind": "feature" } + | { "kind": "workspace"; workspace_id: string } + | { + "kind": "workdir"; + working_directory_id: string; + path: string; + recursive: boolean; + } + | { "kind": "worker"; runtime_id: string; worker_id: string }; + +export type ScopedPermission = { + operation: string; + resource: PermissionResource; +}; + +export type SubjectPermissions = { + change_id: string; + execution: Array; + delegation: Array; +}; + +export type SubjectPermissionsUpdate = { + operation_id: string; + expected_change_id: string | null; + execution: Array; + delegation: Array; +}; + +export type PermissionResourceKind = + | "job" + | "ticket" + | "objective" + | "merge_request" + | "subject" + | "drive_entry" + | "drive_request" + | "repository" + | "workdir_attachment" + | "workspace_config" + | "network" + | "sub_worker" + | "feature" + | "workspace" + | "workdir" + | "worker"; + +export type PermissionOperationAvailability = { "kind": "available" } | { + "kind": "unavailable"; + reason: string; +}; + +export type PermissionOperationDescriptor = { + operation: string; + label: string; + description: string; + /** + * Accepted management input kinds, not a claim that a target exists or is usable. + */ + resource_kinds: Array; + availability: PermissionOperationAvailability; +}; + +export type SubjectPermissionsResponse = { + permissions: SubjectPermissions | null; + can_manage: boolean; + operations: Array; +}; + +export type SubjectPermissionsMutationResponse = { + permissions: SubjectPermissions; + /** + * New operations are already fenced. These are cancellation requests and + * Backend command cleanup evidence, not proof that a Runtime turn stopped. + */ + interruptions: Array; +}; + +export type SubjectPermissionInterruption = { + runtime_id: string; + worker_id: string; + cancellation_accepted: boolean; + backend_command_sessions_closed: boolean; + diagnostics: Array; +}; + export type SubjektivSubjectState = "active" | "retired"; export type SubjektivSubjectCreateRequest = { diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index f71281eb9..46dd0df11 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index efa2e84e4..5ae76037b 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b6953e4d0..9d98d4f65 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ba17a053c..6a01b9a12 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From 36008436830b2f1a405e9a48f4b4337d965b6250 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:26:11 +0900 Subject: [PATCH 2/4] 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} +
+ From 2141672310428b1f64e526cad30d7f9bf4221185 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:33:58 +0900 Subject: [PATCH 3/4] test: exercise Subject permission management in Chromium --- tools/web-ux/README.md | 2 + .../memory_document_fixture_server.ts | 60 ++- .../subject_permissions_before.ts | 101 ++++ .../subject_permissions_fixture.ts | 187 +++++++ .../subject_permissions_review.ts | 477 ++++++++++++++++++ .../scenarios/subject-permissions-dark.json | 138 +++++ .../scenarios/subject-permissions-light.json | 138 +++++ .../tests/subject_permissions_fixture_test.ts | 129 +++++ .../memory/SubjectPermissions.svelte | 10 +- .../permissions/PageContent.svelte | 2 +- .../memory/memory-page.browser.spec.ts | 8 + 11 files changed, 1241 insertions(+), 11 deletions(-) create mode 100644 tools/web-ux/browser-tests/subject_permissions_before.ts create mode 100644 tools/web-ux/browser-tests/subject_permissions_fixture.ts create mode 100644 tools/web-ux/browser-tests/subject_permissions_review.ts create mode 100644 tools/web-ux/scenarios/subject-permissions-dark.json create mode 100644 tools/web-ux/scenarios/subject-permissions-light.json create mode 100644 tools/web-ux/tests/subject_permissions_fixture_test.ts diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index ed34cb958..080e45b18 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -11,3 +11,5 @@ - 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。 - PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。 - 変更時は`deno.json`のcheck・testと必要なbrowser testを選ぶ。scenario変更では実際の画像と`review-context.json`を確認する。browser testの成功を目視の代わりにしない。 + +Subject権限の操作確認には[`subject_permissions_review.ts`](browser-tests/subject_permissions_review.ts)、変更前の撮影には[`subject_permissions_before.ts`](browser-tests/subject_permissions_before.ts)を使う。実Webのビルドを合成HTTP fixtureで動かすため、採取結果は実Backendの認証・保存・認可・Worker制御の証拠ではない。画像と診断の目視確認も別途必要である。 diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 069b413e3..c27cc1d75 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -1,5 +1,9 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { journalBrowserFixture } from "./journal_browser_fixture.ts"; +import { + createSubjectPermissionsFixture, + permissionFixtureSubjects, +} from "./subject_permissions_fixture.ts"; const port = Number(Deno.args[0]); const buildRoot = Deno.args[1]; @@ -11,6 +15,15 @@ if ( throw new Error("usage: server.ts [section-count: 3..100]"); } +// Opt-in keeps the existing Memory scenarios and their pagination unchanged. +const permissionsMode = Deno.args[3] === "permissions"; +if (permissionsMode) { + // Permission evidence must serve a parent-supplied actual production build. + // Do not make /health succeed for a missing build or substitute fixture HTML. + await Deno.stat(join(buildRoot, "index.html")); + await Deno.stat(join(buildRoot, "_app/immutable")); +} +const permissionsFixture = createSubjectPermissionsFixture(); const workspaceId = "memory-review"; const representativeSubjectId = "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; @@ -81,6 +94,11 @@ const subjects = [ subject(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"), subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"), ]; +if (permissionsMode) { + subjects.push( + ...permissionFixtureSubjects.map(([id, role]) => subject(id, role, "active", "fingerprint-0")), + ); +} const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1"); function connectedWorker(displayName: string) { @@ -132,7 +150,13 @@ function memoriesFor(subjectId: string) { if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; if (subjectId === staleSubjectId) { return [ - memorySummary("stale-memory-1", "change-earlier", "lesson", "resolved", "A resolved stale-surface lesson"), + memorySummary( + "stale-memory-1", + "change-earlier", + "lesson", + "resolved", + "A resolved stale-surface lesson", + ), ]; } if (subjectId === failedSubjectId) { @@ -227,7 +251,12 @@ function surfaceFor(subjectId: string) { function memoryDetail(memoryId: string, requestedChangeId: string | null) { const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current"; const changeId = requestedChangeId ?? currentChangeId; - const states: Record = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" }; + const states: Record = { + "change-original": "retracted", + "change-earlier": "resolved", + "change-current": "active", + "change-parent": "active", + }; return { memory_id: memoryId, change_id: changeId, @@ -361,6 +390,10 @@ async function staticResponse(pathname: string): Promise { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + if (permissionsMode) { + const response = await permissionsFixture(request); + if (response) return response; + } if (url.pathname === "/fixture/subject-create-requests") { return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests }); } @@ -393,6 +426,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { manage_secrets: false, manage_runtimes: false, delete_workspace: false, + delete_subjects: false, }, extension_points: { store: "fixture", @@ -409,7 +443,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } if (url.pathname === `/api/w/${workspaceId}/workers`) { - return json({ workspace_id: workspaceId, limit: 200, items: [], source: "fixture", diagnostics: [] }); + return json({ + workspace_id: workspaceId, + limit: 200, + items: [], + source: "fixture", + diagnostics: [], + }); } if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) { return json({ @@ -521,7 +561,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const paginated = subjectId === representativeSubjectId; const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor; const items = continued - ? [memorySummary("memory-page-two", "change-original", "lesson", "active", "Memory on the next page")] + ? [ + memorySummary( + "memory-page-two", + "change-original", + "lesson", + "active", + "Memory on the next page", + ), + ] : memoriesFor(subjectId); return json({ items, @@ -536,7 +584,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } if (parts.length === 3) { const requested = url.searchParams.get("change_id"); - const knownChanges = memoryId === derivedMemoryId ? ["change-parent"] : ["change-original", "change-earlier", "change-current"]; + const knownChanges = memoryId === derivedMemoryId + ? ["change-parent"] + : ["change-original", "change-earlier", "change-current"]; if (requested !== null && !knownChanges.includes(requested)) return json({}, 404); return json(memoryDetail(memoryId, requested)); } diff --git a/tools/web-ux/browser-tests/subject_permissions_before.ts b/tools/web-ux/browser-tests/subject_permissions_before.ts new file mode 100644 index 000000000..5ba18304b --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_before.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env -S deno run -A +/** Capture the actual built Subject detail before UI changes; never builds Web. */ +import { dirname, fromFileUrl, join, resolve } from "@std/path"; +import { capture } from "../src/capture.ts"; +import type { Scenario } from "../src/types.ts"; +import { freePort } from "./memory_document_harness.ts"; + +const repositoryRoot = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const outputRoot = join(repositoryRoot, "target/web-ux/t-802"); +const buildRoot = resolve(Deno.args[0] ?? join(repositoryRoot, "web/workspace/build")); +const runPrefix = Deno.args[1] ?? `before-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; +if (!/^[a-zA-Z0-9._-]+$/.test(runPrefix)) throw new Error("run id must be a safe filename"); +// Preflight is mandatory. A missing build must not be replaced by fixture HTML. +for (const filename of ["index.html", "_app/immutable"]) { + await Deno.stat(join(buildRoot, filename)).catch(() => { + throw new Error( + `Actual Web build missing: ${ + join(buildRoot, filename) + }. Parent must build/supply it; this harness does not build Web or bypass preflight.`, + ); + }); +} +await Deno.mkdir(outputRoot, { recursive: true }); +const index = await Deno.readFile(join(buildRoot, "index.html")); +const indexSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", index))) + .map((b) => b.toString(16).padStart(2, "0")).join(""); +await Deno.writeTextFile( + join(outputRoot, `${runPrefix}-build.json`), + JSON.stringify( + { + evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend", + buildRoot, + indexSha256, + capturedAt: new Date().toISOString(), + visualVerdict: "UNREVIEWED — parent must ViewImage before pass", + }, + null, + 2, + ), +); +const port = await freePort(); +for (const theme of ["light", "dark"] as const) { + const scenario: Scenario = { + schemaVersion: 1, + id: `subject-permissions-before-${theme}`, + title: "T-802 BEFORE — actual Subject detail / synthetic HTTP, NOT real Backend", + baseUrl: `http://127.0.0.1:${port}`, + colorScheme: theme, + reducedMotion: "reduce", + personas: [{ + id: "synthetic-owner", + label: "Synthetic HTTP owner, NOT real Backend authentication", + auth: { kind: "anonymous" }, + }], + viewports: [ + { label: "desktop", width: 1440, height: 900 }, + { label: "narrow", width: 768, height: 900 }, + { label: "mobile", width: 390, height: 844 }, + ], + routes: [{ + id: "subject-detail", + label: "Existing Subject detail before permission UI", + path: + "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + goal: + "Find Subject identity, current state and ordinary routes before permission management changes.", + dataState: + "Active Subject with current Worker and representative long identifiers. All HTTP is synthetic; no real Backend evidence.", + ready: { kind: "selector", selector: ".subject-overview" }, + capturePoints: [{ id: "initial", label: "BEFORE Subject detail" }], + }], + processes: [{ + id: "t-802-owned-synthetic-http", + command: Deno.execPath(), + cwd: join(repositoryRoot, "tools/web-ux"), + args: [ + "run", + "-A", + join(repositoryRoot, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"), + String(port), + buildRoot, + "3", + "permissions", + ], + readyUrl: `http://127.0.0.1:${port}/health`, + }], + }; + const scenarioPath = join(outputRoot, `${runPrefix}-${theme}-scenario.json`); + await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2)); + const bundle = await capture({ + scenarioPath, + outputDirectory: outputRoot, + runId: `${runPrefix}-${theme}`, + }); + console.log(JSON.stringify({ runId: bundle.runId, status: bundle.status })); + if (bundle.status !== "completed") { + throw new Error( + `BEFORE capture was not clean: ${bundle.runId}; inspect review-context.json. No visual pass is implied.`, + ); + } +} diff --git a/tools/web-ux/browser-tests/subject_permissions_fixture.ts b/tools/web-ux/browser-tests/subject_permissions_fixture.ts new file mode 100644 index 000000000..9efd6de6a --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_fixture.ts @@ -0,0 +1,187 @@ +/** + * Synthetic HTTP state for T-802 browser UX only. NOT the real Backend, + * authentication, authorization, operation catalog, or cancellation implementation. + * Keep real Backend integration evidence separate from these browser fixtures. + */ +export const permissionFixtureSubjects = [ + ["permissions-empty", "New Subject — permissions not configured"], + ["permissions-edit", "Release coordination"], + ["permissions-legacy", "Subject with preserved legacy permissions"], + ["permissions-reader", "Read-only Subject permissions"], + ["permissions-unavailable", "Unavailable Subject permissions"], +] as const; + +export type FixturePermission = { + operation: string; + resource: Record & { kind: string }; +}; +export type FixturePolicy = { + change_id: string; + execution: FixturePermission[]; + delegation: FixturePermission[]; +}; +type FixtureUpdate = Omit & { + operation_id: string; + expected_change_id: string | null; +}; + +const workspaceId = "memory-review"; +const resource = { kind: "ticket", workspace_id: workspaceId, ticket_id: "T-802" }; +export const fixtureLegacyPermission: FixturePermission = { + operation: "worker.notify", + resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" }, +}; +// Deliberately a small synthetic catalog, not a copy/second authority for production. +export const fixturePermissionOperations: { + operation: string; + label: string; + description: string; + resource_kinds: string[]; + availability: { kind: "available" } | { kind: "unavailable"; reason: string }; +}[] = [ + { + operation: "ticket.read", + label: "Read ticket", + description: "Read the selected ticket. Does not allow editing or closing it.", + resource_kinds: ["ticket"], + availability: { kind: "available" }, + }, + { + operation: "ticket.comment", + label: "Comment on ticket", + description: "Append a comment to the selected ticket; no completion or assignment authority.", + resource_kinds: ["ticket"], + availability: { kind: "available" }, + }, + { + operation: "worker.messaging", + label: "Message a Worker (T-803)", + description: + "Operation use is separate from Backend directional target control grants and execution conditions.", + resource_kinds: [], + availability: { + kind: "unavailable", + reason: "T-803 is not implemented in this synthetic browser fixture.", + }, + }, +]; + +function json(value: unknown, status = 200): Response { + return Response.json(value, { + status, + headers: { "x-yoi-evidence": "synthetic-http-not-real-backend" }, + }); +} +function error(message: string, status: number): Response { + return json({ error: "Synthetic fixture response", message }, status); +} +function isUpdate(value: unknown): value is FixtureUpdate { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.operation_id === "string" && v.operation_id.length > 0 && + (v.expected_change_id === null || typeof v.expected_change_id === "string") && + Array.isArray(v.execution) && Array.isArray(v.delegation) && + [...v.execution, ...v.delegation].every((p) => + p && typeof p.operation === "string" && p.resource && typeof p.resource.kind === "string" + ); +} + +export function createSubjectPermissionsFixture() { + const policies = new Map(); + const receipts = new Map(); + const requests: { subject_id: string; request: FixtureUpdate; outcome: string }[] = []; + let issuedChangeCount = 0; + for (const [id] of permissionFixtureSubjects) { + policies.set( + id, + id === "permissions-empty" ? null : { + change_id: `fixture-initial-${id}`, + execution: [{ operation: "ticket.read", resource: { ...resource } }], + delegation: id === "permissions-legacy" ? [structuredClone(fixtureLegacyPermission)] : [], + }, + ); + } + + return async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname === "/api/auth/whoami") { + // Exercise the real Web's admission gate with a synthetic HTTP session projection. + // No cookies/tokens or real Backend session are created or bypassed. + return json({ + actor: { + user_id: "synthetic-owner", + account_id: "owner", + handle: "fixture-owner", + display_name: "Synthetic browser owner", + auth_method: "browser_session", + }, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + authentication_reference: "synthetic-http-not-real-authentication", + }); + } + if (url.pathname === "/fixture/permissions/evidence") { + if (request.method !== "GET") return error("GET required", 405); + return json({ authority: "synthetic-http-not-real-backend", requests }); + } + const match = url.pathname.match( + /^\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/permissions$/, + ); + if (!match) return null; + const [, workspace, id] = match.map(decodeURIComponent); + if (workspace !== workspaceId || !policies.has(id)) return error("Subject unavailable", 404); + if (id === "permissions-unavailable") return error("Permission service unavailable", 503); + const current = policies.get(id)!; + if (request.method === "GET") { + return json({ + permissions: current, + can_manage: id !== "permissions-reader", + operations: fixturePermissionOperations, + }); + } + if (request.method !== "POST") return error("POST required", 405); + if (id === "permissions-reader") return error("Actor cannot manage permissions", 403); + const payload: unknown = await request.json().catch(() => null); + if (!isUpdate(payload)) return error("Invalid synthetic policy update", 400); + const input = JSON.stringify(payload); + const receiptKey = JSON.stringify([id, payload.operation_id]); + const previous = receipts.get(receiptKey); + if (previous) { + requests.push({ subject_id: id, request: payload, outcome: "replay" }); + if (previous.input !== input) { + return error("Operation identity reused with changed input", 409); + } + return json({ permissions: previous.permissions, interruptions: [] }); + } + if (payload.expected_change_id !== (current?.change_id ?? null)) { + requests.push({ subject_id: id, request: payload, outcome: "conflict" }); + return error("Policy changed in another browser; reload and review", 409); + } + // Reject only the combinations used by this fixture. This is NOT Backend validation evidence. + for (const p of [...payload.execution, ...payload.delegation]) { + const legacy = JSON.stringify(p) === JSON.stringify(fixtureLegacyPermission) && + id === "permissions-legacy"; + const supported = fixturePermissionOperations.some((o) => + o.availability.kind === "available" && o.operation === p.operation && + o.resource_kinds.includes(p.resource.kind) + ); + if (!legacy && !supported) { + return error("Unknown operation or invalid resource combination", 400); + } + if ( + p.resource.kind === "ticket" && + (p.resource.workspace_id !== workspaceId || !p.resource.ticket_id) + ) { + return error("Invalid ticket target", 400); + } + } + const saved: FixturePolicy = { + change_id: `fixture-change-${++issuedChangeCount}`, + execution: structuredClone(payload.execution), + delegation: structuredClone(payload.delegation), + }; + policies.set(id, saved); + receipts.set(receiptKey, { input, permissions: structuredClone(saved) }); + requests.push({ subject_id: id, request: payload, outcome: "saved" }); + return json({ permissions: saved, interruptions: [] }); + }; +} diff --git a/tools/web-ux/browser-tests/subject_permissions_review.ts b/tools/web-ux/browser-tests/subject_permissions_review.ts new file mode 100644 index 000000000..74d678869 --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_review.ts @@ -0,0 +1,477 @@ +#!/usr/bin/env -S deno run -A +/** Actual built Web + synthetic HTTP. No real Backend auth/Worker success claim. */ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join, resolve } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { sha256File } from "../src/artifacts.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts"; + +const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build")); +const runId = Deno.args[1] ?? `after-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; +if (!/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("run id must be a safe filename"); +// Fail fast; the parent owns building Web. Never replace it with a synthetic shell. +for (const name of ["index.html", "_app/immutable"]) { + await Deno.stat(join(buildRoot, name)).catch(() => { + throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`); + }); +} +const output = join(root, "target/web-ux/t-802", runId); +await Deno.mkdir(dirname(output), { recursive: true }); +await Deno.mkdir(output); // A repeated run ID must not overwrite prior evidence. +const sourceCommit = await new Deno.Command("git", { + args: ["rev-parse", "HEAD"], + cwd: root, + stdout: "piped", +}).output(); +const sourceStatus = await new Deno.Command("git", { + args: ["status", "--porcelain"], + cwd: root, + stdout: "piped", +}).output(); +const manifest: { + evidence: string; + buildRoot: string; + buildIndexSha256: string; + visualVerdict: string; + status: string; + captures: unknown[]; + failures: string[]; + source: { commitSha: string | null; dirty: boolean | null }; + browser: { name: string; version: string } | null; + expectedFaults: string[]; +} = { + evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend", + buildRoot, + buildIndexSha256: await sha256File(join(buildRoot, "index.html")), + visualVerdict: "UNREVIEWED — parent must ViewImage and inspect diagnostics before pass", + status: "running", + captures: [], + failures: [], + source: { + commitSha: sourceCommit.success ? new TextDecoder().decode(sourceCommit.stdout).trim() : null, + dirty: sourceStatus.success + ? new TextDecoder().decode(sourceStatus.stdout).trim().length > 0 + : null, + }, + browser: null, + expectedFaults: [ + "One intentionally aborted POST response after synthetic persistence per width/theme", + "HTTP 409 for stale two-page policy save", + "HTTP 503 for permissions-unavailable GET", + ], +}; +const browser = await chromium.launch({ headless: true }); +manifest.browser = { name: "chromium", version: browser.version() }; +type BrowserDiagnostic = { + kind: string; + message: string; + url?: string; + method?: string; + resourceType?: string; + pageUrl?: string; +}; +const diagnostics = new WeakMap(); +const documents = new WeakMap(); +const errorCounts = new WeakMap(); +function observe(page: Page) { + const errors: BrowserDiagnostic[] = []; + diagnostics.set(page, errors); + const add = (e: typeof errors[number]) => { + errorCounts.set(page, (errorCounts.get(page) ?? 0) + 1); + if (errors.length < 100) errors.push(e); + }; + page.on("pageerror", (e) => add({ kind: "page", message: e.stack || e.message || String(e) })); + page.on("console", (e) => { + if (e.type() === "error" || e.type() === "warning") add({ kind: "console", message: e.text() }); + }); + page.on( + "requestfailed", + (r) => + add({ + kind: "request", + message: r.failure()?.errorText ?? "request failed", + url: r.url(), + method: r.method(), + resourceType: r.resourceType(), + pageUrl: page.url(), + }), + ); + page.on("response", (r) => { + if (r.request().isNavigationRequest()) documents.set(page, r.status()); + if (r.status() >= 400) { + add({ + kind: "http", + message: `HTTP ${r.status()}`, + url: r.url(), + method: r.request().method(), + resourceType: r.request().resourceType(), + pageUrl: page.url(), + }); + } + }); +} +async function capturePoint(page: Page, id: string, expectedErrors: string[] = []) { + // Show the decision being captured, without changing product CSS or layout. + if (id.includes("preview")) await page.locator(".diff").scrollIntoViewIfNeeded(); + else if (/saved|re-read|unknown|recovered|conflict/.test(id)) { + await page.locator(".permissions > header").scrollIntoViewIfNeeded(); + } + // Always retain the image first, even when an overflow assertion will fail. + const image = join(output, `${id}.png`); + const snapshot = join(output, `${id}.aria.txt`); + await page.screenshot({ path: image }); + await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot()); + const mobileRemoveButtons = page.viewportSize()?.width === 390 + ? await page.locator(".lane button").evaluateAll((buttons) => + buttons.map((button) => { + const style = button.ownerDocument.defaultView!.getComputedStyle(button); + const bounds = button.getBoundingClientRect(); + const lane = button.closest(".lane")!.getBoundingClientRect(); + const range = button.ownerDocument.createRange(); + range.selectNodeContents(button); + const textBounds = range.getBoundingClientRect(); + return { + label: button.getAttribute("aria-label"), + text: button.textContent?.trim(), + whiteSpace: style.whiteSpace, + flexShrink: style.flexShrink, + textLineBoxes: Array.from(range.getClientRects()).filter((rect) => rect.width > 0).length, + textFitsButton: textBounds.left >= + bounds.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) - 1 && + textBounds.right <= + bounds.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) + + 1 && + textBounds.top >= bounds.top && textBounds.bottom <= bounds.bottom, + buttonFitsLane: bounds.left >= lane.left - 1 && bounds.right <= lane.right + 1, + noInternalClipping: button.scrollWidth <= button.clientWidth && + button.scrollHeight <= button.clientHeight, + width: bounds.width, + textWidth: textBounds.width, + }; + }) + ) + : []; + manifest.captures.push({ + id, + route: new URL(page.url()).pathname, + viewport: page.viewportSize(), + theme: await page.evaluate(() => + (globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" + ), + documentStatus: documents.get(page) ?? null, + visibleAlerts: await page.getByRole("alert").allTextContents(), + permissionState: await page.locator("[data-permission-state]").getAttribute( + "data-permission-state", + ), + image, + imageSha256: await sha256File(image), + accessibilitySnapshot: snapshot, + expectedErrors, + // Per-page errors are cumulative so reload does not erase recovery evidence. + errors: [...diagnostics.get(page) ?? []], + errorSummary: { + observed: errorCounts.get(page) ?? 0, + retained: diagnostics.get(page)?.length ?? 0, + truncated: (errorCounts.get(page) ?? 0) > 100, + limit: 100, + }, + mobileRemoveButtons, + reviewed: false, + }); + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); + for (const button of mobileRemoveButtons) { + assertEquals(button.text, "Remove", `${id}: expected Remove control`); + assertEquals(button.whiteSpace, "nowrap", `${id}: ${button.label} can wrap`); + assertEquals(button.flexShrink, "0", `${id}: ${button.label} can shrink`); + assertEquals(button.textLineBoxes, 1, `${id}: ${button.label} text breaks across lines`); + assert( + button.textFitsButton && button.buttonFitsLane && button.noInternalClipping, + `${id}: ${button.label} clips or exceeds its lane`, + ); + } + await assertNoPageWideOverflow(page); +} +async function open(page: Page, base: string, id: string, state = "ready") { + await page.goto(`${base}/w/memory-review/memory/${id}/permissions`); + await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor(); +} +async function add( + page: Page, + lane: "execution" | "delegation", + operation: string, + target: string, + resourceCaptureId?: string, +) { + await page.getByRole("combobox", { name: "Permission scope", exact: true }).selectOption(lane); + await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(operation); + // Exercise the actual name/description/resource input UI, not internal JSON entry. + await page.getByText( + operation === "ticket.read" + ? "Read the selected ticket. Does not allow editing or closing it." + : "Append a comment to the selected ticket; no completion or assignment authority.", + { exact: true }, + ).waitFor(); + await page.getByLabel("Ticket identifier", { exact: true }).fill(target); + if (resourceCaptureId) { + await page.getByLabel("Ticket identifier", { exact: true }).focus(); + await page.keyboard.press("Tab"); + assertEquals( + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).evaluate(( + element, + ) => element === element.ownerDocument.activeElement), + true, + ); + await capturePoint(page, resourceCaptureId); + } + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).click(); + await page.locator("[data-permission-state=unsaved]").waitFor(); +} +async function save(page: Page, state = "saved") { + await page.getByLabel("I reviewed the operation, resource and delegation differences.").check(); + await page.getByRole("button", { name: "Save permissions", exact: true }).click(); + await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor(); +} +try { + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const prefix = `${theme}-${width}`; + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const owned = await startOwnedProcesses( + [{ + id: `t-802-${prefix}-synthetic-http`, + command: Deno.execPath(), + cwd: join(root, "tools/web-ux"), + args: [ + "run", + "-A", + join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"), + String(port), + buildRoot, + "3", + "permissions", + ], + readyUrl: `${base}/health`, + }], + join(output, "scenario.json"), + join(output, `logs-${prefix}`), + [], + ); + const context = await browser.newContext({ + viewport: { width, height: width === 390 ? 844 : 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + observe(page); + // Enter through the ordinary Subject detail navigation. + await page.goto(`${base}/w/memory-review/memory/permissions-empty`); + await page.locator(".subject-overview").waitFor(); + await page.locator('main a[href$="/permissions"]').click(); + await page.locator("[data-permission-state=ready]").waitFor(); + await page.getByText("Not configured · no permissions", { exact: true }).waitFor(); + await capturePoint(page, `${prefix}-empty`); + await add( + page, + "execution", + "ticket.read", + "T-802", + `${prefix}-operation-description-resource-input`, + ); + await add(page, "delegation", "ticket.comment", "T-802"); + // Use named lane scopes so one grant cannot silently appear in both lanes. + assertEquals( + await page.locator('.lane[aria-label="Direct operations"]').getByText("Read ticket", { + exact: true, + }).count(), + 1, + ); + assertEquals( + await page.locator('.lane[aria-label="Direct operations"]').getByText( + "Comment on ticket", + { exact: true }, + ).count(), + 0, + ); + assertEquals( + await page.locator('.lane[aria-label="Background Worker delegation ceiling"]').getByText( + "Comment on ticket", + { exact: true }, + ).count(), + 1, + ); + await capturePoint(page, `${prefix}-preview-separate-lanes`); + await save(page); + await capturePoint(page, `${prefix}-saved`); + await page.reload(); + await page.locator("[data-permission-state=ready]").waitFor(); + await capturePoint(page, `${prefix}-re-read`); + + // Real browser transport loss AFTER the synthetic HTTP fixture accepts the POST. + await add(page, "execution", "ticket.comment", "T-803"); + let lost = false; + await page.route("**/subjektiv/subjects/permissions-empty/permissions", async (route) => { + if (route.request().method() === "POST" && !lost) { + const accepted = await route.fetch(); + assertEquals(accepted.status(), 200); + lost = true; + await route.abort("failed"); + } else await route.continue(); + }); + await save(page, "unknown"); + assert(lost); + // Fault injection is one-shot; restore ordinary browser networking before evidence capture. + await page.unroute("**/subjektiv/subjects/permissions-empty/permissions"); + await capturePoint(page, `${prefix}-unknown`, [ + "Intentional POST response loss after synthetic save", + ]); + await page.reload(); + await page.locator("[data-permission-state=unknown][aria-busy=false]").waitFor(); + await page.getByRole("button", { name: "Reconcile the same save", exact: true }).click(); + await page.locator("[data-permission-state=saved]").waitFor(); + const evidence = await (await fetch(`${base}/fixture/permissions/evidence`)).json(); + const saves = evidence.requests.filter((r: { subject_id: string }) => + r.subject_id === "permissions-empty" + ); + assertEquals(saves.length, 3); + assertEquals(saves[1].request, saves[2].request); + assertEquals(saves[2].outcome, "replay"); + await capturePoint(page, `${prefix}-recovered-same-operation`, [ + "Intentional earlier POST response loss", + ]); + + // Two browser pages observe the same synthetic change identity before either save. + const other = await context.newPage(); + observe(other); + await open(page, base, "permissions-edit"); + await open(other, base, "permissions-edit"); + await add(page, "delegation", "ticket.comment", "T-802"); + await other.getByRole("button", { + name: "Remove Read ticket from direct operations", + exact: true, + }).click(); + await save(page); + await save(other, "error"); + await other.getByRole("alert").filter({ hasText: "Conflict:" }).waitFor(); + await capturePoint(other, `${prefix}-two-page-conflict`, [ + "Intentional stale policy POST returns HTTP 409", + ]); + await other.close(); + await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption( + "worker.messaging", + ); + await page.getByText("Target unavailable:", { exact: false }).waitFor(); + assertEquals( + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).count(), + 0, + ); + await capturePoint(page, `${prefix}-unavailable-operation`); + await open(page, base, "permissions-reader"); + assertEquals(await page.locator("form.permission-form").count(), 0); + assertEquals( + await page.getByRole("button", { name: "Save permissions", exact: true }).count(), + 0, + ); + await capturePoint(page, `${prefix}-read-only`); + await open(page, base, "permissions-unavailable", "error"); + await capturePoint(page, `${prefix}-service-unavailable`, [ + "Intentional permission GET returns HTTP 503", + ]); + await open(page, base, "permissions-legacy"); + await page.getByText( + "Preserved, not editable here. No conversion or expansion is implied.", + { exact: true }, + ).waitFor(); + await add(page, "execution", "ticket.comment", "T-802"); + await save(page); + const legacy = await (await fetch( + `${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`, + )).json(); + assertEquals(legacy.permissions.delegation, [{ + operation: "worker.notify", + resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" }, + }]); + await capturePoint(page, `${prefix}-legacy-preserved`, [ + "Intentional earlier service unavailable GET", + ]); + // Revoke a supported direct permission; this fixture proves UI intent, not real cancellation. + await page.getByRole("button", { + name: "Remove Read ticket from direct operations", + exact: true, + }).click(); + await save(page); + await capturePoint(page, `${prefix}-revoked-saved`); + const finalAudit = await (await fetch(`${base}/fixture/permissions/evidence`)).json(); + const finalLegacy = await (await fetch( + `${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`, + )).json(); + assertEquals(finalAudit.requests.length, 7); + assertEquals( + finalAudit.requests.filter((r: { outcome: string }) => r.outcome === "conflict").length, + 1, + ); + assertEquals(finalLegacy.permissions.execution, [{ + operation: "ticket.comment", + resource: { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" }, + }]); + assertEquals(finalLegacy.permissions.delegation, legacy.permissions.delegation); + await Deno.writeTextFile( + join(output, `${prefix}-synthetic-http-evidence.json`), + JSON.stringify( + { + authority: "synthetic-http-not-real-backend", + replayAssertions: { + originalAndRetryInputsEqual: true, + retryOutcome: saves[2].outcome, + }, + legacyDelegationPreserved: legacy.permissions.delegation, + requests: finalAudit.requests, + currentLegacyPolicyAfterShrink: finalLegacy, + }, + null, + 2, + ), + ); + } catch (e) { + manifest.failures.push( + `${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`, + ); + for (const [index, failedPage] of context.pages().entries()) { + const image = join(output, `${prefix}-failure-${index}.png`); + const snapshot = join(output, `${prefix}-failure-${index}.aria.txt`); + await failedPage.screenshot({ path: image, timeout: 5_000 }).catch(() => {}); + await Deno.writeTextFile(snapshot, await failedPage.locator("body").ariaSnapshot()); + manifest.captures.push({ + id: `${prefix}-failure-${index}`, + route: failedPage.url(), + documentStatus: documents.get(failedPage) ?? null, + image, + accessibilitySnapshot: snapshot, + errors: diagnostics.get(failedPage) ?? [], + reviewed: false, + }); + } + throw e; + } finally { + await context.close(); + await stopOwnedProcesses(owned); + } + } + } + assertEquals( + await sha256File(join(buildRoot, "index.html")), + manifest.buildIndexSha256, + "Web build changed during the browser matrix", + ); + manifest.status = "browser-assertions-passed-visual-unreviewed"; +} catch (e) { + manifest.status = "failed"; + throw e; +} finally { + await browser.close(); + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); + console.log(join(output, "review-context.json")); +} diff --git a/tools/web-ux/scenarios/subject-permissions-dark.json b/tools/web-ux/scenarios/subject-permissions-dark.json new file mode 100644 index 000000000..df9570f29 --- /dev/null +++ b/tools/web-ux/scenarios/subject-permissions-dark.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "id": "subject-permissions-dark", + "title": "T-802 actual Web / synthetic HTTP NOT real Backend — dark", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection, NOT real Backend authentication or authorization", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "permissions-empty", + "label": "Unset permissions", + "path": "/w/memory-review/memory/permissions-empty/permissions", + "goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unset permissions" + } + ] + }, + { + "id": "permissions-edit", + "label": "Configured permissions", + "path": "/w/memory-review/memory/permissions-edit/permissions", + "goal": "Read exact operation and target before editing.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Configured permissions" + } + ] + }, + { + "id": "permissions-legacy", + "label": "Preserved unsupported permission", + "path": "/w/memory-review/memory/permissions-legacy/permissions", + "goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Preserved unsupported permission" + } + ] + }, + { + "id": "permissions-reader", + "label": "Read-only permissions", + "path": "/w/memory-review/memory/permissions-reader/permissions", + "goal": "Read policy without unauthorized editing controls.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only permissions" + } + ] + }, + { + "id": "permissions-unavailable", + "label": "Unavailable permission service", + "path": "/w/memory-review/memory/permissions-unavailable/permissions", + "goal": "Recover without success claims.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=error][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unavailable permission service" + } + ] + } + ], + "processes": [ + { + "id": "subject-permissions-synthetic-http", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "-A", + "browser-tests/memory_document_fixture_server.ts", + "15289", + "${WEB_UX_BUILD_ROOT}", + "3", + "permissions" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/scenarios/subject-permissions-light.json b/tools/web-ux/scenarios/subject-permissions-light.json new file mode 100644 index 000000000..56146c71a --- /dev/null +++ b/tools/web-ux/scenarios/subject-permissions-light.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "id": "subject-permissions-light", + "title": "T-802 actual Web / synthetic HTTP NOT real Backend — light", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection, NOT real Backend authentication or authorization", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "permissions-empty", + "label": "Unset permissions", + "path": "/w/memory-review/memory/permissions-empty/permissions", + "goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unset permissions" + } + ] + }, + { + "id": "permissions-edit", + "label": "Configured permissions", + "path": "/w/memory-review/memory/permissions-edit/permissions", + "goal": "Read exact operation and target before editing.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Configured permissions" + } + ] + }, + { + "id": "permissions-legacy", + "label": "Preserved unsupported permission", + "path": "/w/memory-review/memory/permissions-legacy/permissions", + "goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Preserved unsupported permission" + } + ] + }, + { + "id": "permissions-reader", + "label": "Read-only permissions", + "path": "/w/memory-review/memory/permissions-reader/permissions", + "goal": "Read policy without unauthorized editing controls.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only permissions" + } + ] + }, + { + "id": "permissions-unavailable", + "label": "Unavailable permission service", + "path": "/w/memory-review/memory/permissions-unavailable/permissions", + "goal": "Recover without success claims.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=error][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unavailable permission service" + } + ] + } + ], + "processes": [ + { + "id": "subject-permissions-synthetic-http", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "-A", + "browser-tests/memory_document_fixture_server.ts", + "15289", + "${WEB_UX_BUILD_ROOT}", + "3", + "permissions" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/tests/subject_permissions_fixture_test.ts b/tools/web-ux/tests/subject_permissions_fixture_test.ts new file mode 100644 index 000000000..3f8ee851f --- /dev/null +++ b/tools/web-ux/tests/subject_permissions_fixture_test.ts @@ -0,0 +1,129 @@ +import { assert, assertEquals } from "@std/assert"; +import { + createSubjectPermissionsFixture, + fixtureLegacyPermission, + type FixturePolicy, +} from "../browser-tests/subject_permissions_fixture.ts"; + +const root = "http://127.0.0.1:12345/api/w/memory-review/subjektiv/subjects/"; +const target = { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" }; +const direct = { operation: "ticket.read", resource: target }; +const delegated = { operation: "ticket.comment", resource: target }; +function request(id: string, body?: unknown): Request { + return new Request( + `${root}${id}/permissions`, + body === undefined ? {} : { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); +} + +Deno.test("synthetic browser admission projection is explicitly labeled, with no real credentials", async () => { + const handle = createSubjectPermissionsFixture(); + const response = await handle(new Request("http://127.0.0.1:12345/api/auth/whoami")); + assert(response); + assertEquals(response.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend"); + assertEquals(response.headers.get("set-cookie"), null); + const view = await response.json(); + assertEquals(view.authentication_reference, "synthetic-http-not-real-authentication"); + assertEquals(view.session.kind, "authenticated"); + assertEquals(view.actor.auth_method, "browser_session"); +}); + +Deno.test("synthetic UX fixture: unset, separate lanes, exact replay and two-editor CAS", async () => { + const handle = createSubjectPermissionsFixture(); + const initial = await handle(request("permissions-empty")); + assert(initial); + assertEquals(initial.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend"); + assertEquals((await initial.json()).permissions, null); + const update = { + operation_id: "browser-save-1", + expected_change_id: null, + execution: [direct], + delegation: [delegated], + }; + const save = await handle(request("permissions-empty", update)); + assert(save); + assertEquals(save.status, 200); + const saved = await save.json(); + assertEquals(saved.permissions.execution, [direct]); + assertEquals(saved.permissions.delegation, [delegated]); + const replay = await handle(request("permissions-empty", update)); + assert(replay); + assertEquals(await replay.json(), saved); + const collision = await handle(request("permissions-empty", { ...update, execution: [] })); + assertEquals(collision?.status, 409); + const stale = await handle( + request("permissions-empty", { ...update, operation_id: "second-tab" }), + ); + assertEquals(stale?.status, 409); + const readback = await handle(request("permissions-empty")); + assertEquals((await readback!.json()).permissions, saved.permissions); +}); + +Deno.test("synthetic UX fixture: read-only, unavailable, wrong workspace and unknown targets", async () => { + const handle = createSubjectPermissionsFixture(); + const view = await handle(request("permissions-reader")); + assertEquals((await view!.json()).can_manage, false); + assertEquals((await handle(request("permissions-reader", {})))?.status, 403); + assertEquals((await handle(request("permissions-unavailable")))?.status, 503); + assertEquals((await handle(request("missing")))?.status, 404); + assertEquals( + (await handle( + new Request(`${root.replace("memory-review", "other")}permissions-edit/permissions`), + ))?.status, + 404, + ); + assertEquals(await handle(new Request("http://127.0.0.1:12345/not-owned")), null); +}); + +Deno.test("synthetic UX fixture preserves old Worker target without making T-803 usable", async () => { + const handle = createSubjectPermissionsFixture(); + const view = await handle(request("permissions-legacy")); + const initial = await view!.json(); + assertEquals(initial.permissions.delegation, [fixtureLegacyPermission]); + const workerOperation = initial.operations.find((o: { operation: string }) => + o.operation === "worker.messaging" + ); + assertEquals(workerOperation.availability.kind, "unavailable"); + assertEquals(workerOperation.resource_kinds, []); + assertEquals(Object.hasOwn(workerOperation, "available"), false); + assertEquals(Object.hasOwn(workerOperation, "unavailable_reason"), false); + assertEquals( + initial.operations.find((o: { operation: string }) => o.operation === "ticket.read") + .availability, + { kind: "available" }, + ); + const update = { + operation_id: "preserve-legacy", + expected_change_id: initial.permissions.change_id, + execution: [direct, delegated], + delegation: initial.permissions.delegation, + }; + const saved = await handle(request("permissions-legacy", update)); + assertEquals(saved?.status, 200); + const value = await saved!.json(); + assertEquals(value.permissions.delegation, [fixtureLegacyPermission]); + const policy = value.permissions as FixturePolicy; + for ( + const invalid of [ + { operation: "future.all", resource: target }, + { operation: "ticket.read", resource: { kind: "workspace", workspace_id: "memory-review" } }, + { + operation: "worker.messaging", + resource: { kind: "workspace", workspace_id: "memory-review" }, + }, + ] + ) { + const rejected = await handle(request("permissions-legacy", { + ...update, + operation_id: `reject-${invalid.operation}`, + expected_change_id: policy.change_id, + execution: [invalid], + })); + assertEquals(rejected?.status, 400); + } + assertEquals((await (await handle(request("permissions-legacy")))!.json()).permissions, policy); +}); diff --git a/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte index e566c97fd..a35e3ccae 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte +++ b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte @@ -167,16 +167,16 @@ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 6d71ec0f2..7e8ed9b45 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -44,6 +44,7 @@ function subject() { function subjectIndexData(items = [subject()]) { return { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", cursor: null, subjects: result({ @@ -57,6 +58,7 @@ function subjectIndexData(items = [subject()]) { test("renders the subject index as the Memory product entry", () => { render(SubjectIndexPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", cursor: "subject-current", subjects: result({ @@ -239,6 +241,7 @@ test("warns about an unknown create outcome without replaying the POST", async ( test("renders a ready resident surface and committed Memory lifecycle states", async () => { render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", cursor: "memory-current", @@ -333,6 +336,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save vi.stubGlobal("fetch", fetchMock); render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", subject: result(subject()), @@ -386,6 +390,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save function subjectPageData(record = subject(), workspaceId = "workspace-1") { return { + workspace: { permissions: { delete_subjects: false } }, workspaceId, subjectId: record.id, subject: result(record), @@ -663,6 +668,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid // the same empty committed list as a new Subject; candidates are not a page input. render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", cursor: null, @@ -746,6 +752,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)( test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", subject: result(subject()), @@ -831,6 +838,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => { render(MemoryDetailPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", memoryId: "memory-1", From dd312b71eedd74513ae8bf4011ecd0b512562186 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:34:30 +0900 Subject: [PATCH 4/4] docs: localize browser evidence boundary comments --- .../memory_document_fixture_server.ts | 6 ++--- .../subject_permissions_before.ts | 4 +-- .../subject_permissions_fixture.ts | 13 +++++----- .../subject_permissions_review.ts | 26 +++++++++---------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index c27cc1d75..7bf6b63ab 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -15,11 +15,11 @@ if ( throw new Error("usage: server.ts [section-count: 3..100]"); } -// Opt-in keeps the existing Memory scenarios and their pagination unchanged. +// 既存Memory scenarioとpaginationを変えないよう、明示選択時だけ有効にする。 const permissionsMode = Deno.args[3] === "permissions"; if (permissionsMode) { - // Permission evidence must serve a parent-supplied actual production build. - // Do not make /health succeed for a missing build or substitute fixture HTML. + // 権限の証拠は親から渡された実製品buildを使って採取する。 + // build欠落時に/healthを成功させたり、fixture HTMLで代替したりしない。 await Deno.stat(join(buildRoot, "index.html")); await Deno.stat(join(buildRoot, "_app/immutable")); } diff --git a/tools/web-ux/browser-tests/subject_permissions_before.ts b/tools/web-ux/browser-tests/subject_permissions_before.ts index 5ba18304b..2d8b3307e 100644 --- a/tools/web-ux/browser-tests/subject_permissions_before.ts +++ b/tools/web-ux/browser-tests/subject_permissions_before.ts @@ -1,5 +1,5 @@ #!/usr/bin/env -S deno run -A -/** Capture the actual built Subject detail before UI changes; never builds Web. */ +/** 変更前の実Web buildでSubject詳細を撮影する。Web自体はbuildしない。 */ import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { capture } from "../src/capture.ts"; import type { Scenario } from "../src/types.ts"; @@ -10,7 +10,7 @@ const outputRoot = join(repositoryRoot, "target/web-ux/t-802"); const buildRoot = resolve(Deno.args[0] ?? join(repositoryRoot, "web/workspace/build")); const runPrefix = Deno.args[1] ?? `before-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; if (!/^[a-zA-Z0-9._-]+$/.test(runPrefix)) throw new Error("run id must be a safe filename"); -// Preflight is mandatory. A missing build must not be replaced by fixture HTML. +// Preflightは必須。buildの欠落をfixture HTMLで代替しない。 for (const filename of ["index.html", "_app/immutable"]) { await Deno.stat(join(buildRoot, filename)).catch(() => { throw new Error( diff --git a/tools/web-ux/browser-tests/subject_permissions_fixture.ts b/tools/web-ux/browser-tests/subject_permissions_fixture.ts index 9efd6de6a..5195e52e7 100644 --- a/tools/web-ux/browser-tests/subject_permissions_fixture.ts +++ b/tools/web-ux/browser-tests/subject_permissions_fixture.ts @@ -1,7 +1,6 @@ /** - * Synthetic HTTP state for T-802 browser UX only. NOT the real Backend, - * authentication, authorization, operation catalog, or cancellation implementation. - * Keep real Backend integration evidence separate from these browser fixtures. + * Browser UXだけの合成HTTP状態。実Backend、認証、認可、操作catalog、取消の実装ではない。 + * 実Backendの結合証拠は、このbrowser fixtureと分けて保持する。 */ export const permissionFixtureSubjects = [ ["permissions-empty", "New Subject — permissions not configured"], @@ -31,7 +30,7 @@ export const fixtureLegacyPermission: FixturePermission = { operation: "worker.notify", resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" }, }; -// Deliberately a small synthetic catalog, not a copy/second authority for production. +// 小さな合成catalogに限定する。製品catalogの複製や第二正本ではない。 export const fixturePermissionOperations: { operation: string; label: string; @@ -105,8 +104,8 @@ export function createSubjectPermissionsFixture() { return async (request: Request): Promise => { const url = new URL(request.url); if (url.pathname === "/api/auth/whoami") { - // Exercise the real Web's admission gate with a synthetic HTTP session projection. - // No cookies/tokens or real Backend session are created or bypassed. + // 合成HTTP session projectionで実Webの画面入場判定を通す。 + // 実Backendのsessionやcookie/tokenは作成・迂回しない。 return json({ actor: { user_id: "synthetic-owner", @@ -156,7 +155,7 @@ export function createSubjectPermissionsFixture() { requests.push({ subject_id: id, request: payload, outcome: "conflict" }); return error("Policy changed in another browser; reload and review", 409); } - // Reject only the combinations used by this fixture. This is NOT Backend validation evidence. + // fixtureで使う組合せだけを拒否する。Backend検証の証拠にはしない。 for (const p of [...payload.execution, ...payload.delegation]) { const legacy = JSON.stringify(p) === JSON.stringify(fixtureLegacyPermission) && id === "permissions-legacy"; diff --git a/tools/web-ux/browser-tests/subject_permissions_review.ts b/tools/web-ux/browser-tests/subject_permissions_review.ts index 74d678869..5af94bd8f 100644 --- a/tools/web-ux/browser-tests/subject_permissions_review.ts +++ b/tools/web-ux/browser-tests/subject_permissions_review.ts @@ -1,5 +1,5 @@ #!/usr/bin/env -S deno run -A -/** Actual built Web + synthetic HTTP. No real Backend auth/Worker success claim. */ +/** 実Web buildを合成HTTPで操作する。実Backend認証やWorker動作の証拠ではない。 */ import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { chromium, type Page } from "playwright"; @@ -11,7 +11,7 @@ const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build")); const runId = Deno.args[1] ?? `after-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; if (!/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("run id must be a safe filename"); -// Fail fast; the parent owns building Web. Never replace it with a synthetic shell. +// Web buildは親の責務。欠落時は即失敗させ、合成shellで代替しない。 for (const name of ["index.html", "_app/immutable"]) { await Deno.stat(join(buildRoot, name)).catch(() => { throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`); @@ -19,7 +19,7 @@ for (const name of ["index.html", "_app/immutable"]) { } const output = join(root, "target/web-ux/t-802", runId); await Deno.mkdir(dirname(output), { recursive: true }); -await Deno.mkdir(output); // A repeated run ID must not overwrite prior evidence. +await Deno.mkdir(output); // 同じrun IDで既存の証拠を上書きしない。 const sourceCommit = await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root, @@ -113,12 +113,12 @@ function observe(page: Page) { }); } async function capturePoint(page: Page, id: string, expectedErrors: string[] = []) { - // Show the decision being captured, without changing product CSS or layout. + // 製品CSSやlayoutを変えず、確認対象の判断が見える位置へ移動する。 if (id.includes("preview")) await page.locator(".diff").scrollIntoViewIfNeeded(); else if (/saved|re-read|unknown|recovered|conflict/.test(id)) { await page.locator(".permissions > header").scrollIntoViewIfNeeded(); } - // Always retain the image first, even when an overflow assertion will fail. + // overflow検査が失敗する場合も、先に画像を保存して根拠を残す。 const image = join(output, `${id}.png`); const snapshot = join(output, `${id}.aria.txt`); await page.screenshot({ path: image }); @@ -169,7 +169,7 @@ async function capturePoint(page: Page, id: string, expectedErrors: string[] = [ imageSha256: await sha256File(image), accessibilitySnapshot: snapshot, expectedErrors, - // Per-page errors are cumulative so reload does not erase recovery evidence. + // 再読込みで復旧の証拠を消さないよう、pageごとにerrorを累積する。 errors: [...diagnostics.get(page) ?? []], errorSummary: { observed: errorCounts.get(page) ?? 0, @@ -206,7 +206,7 @@ async function add( ) { await page.getByRole("combobox", { name: "Permission scope", exact: true }).selectOption(lane); await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(operation); - // Exercise the actual name/description/resource input UI, not internal JSON entry. + // 内部JSON入力ではなく、実UIの名称・説明・資源入力を操作する。 await page.getByText( operation === "ticket.read" ? "Read the selected ticket. Does not allow editing or closing it." @@ -267,7 +267,7 @@ try { try { const page = await context.newPage(); observe(page); - // Enter through the ordinary Subject detail navigation. + // 通常のSubject詳細から管理導線へ進む。 await page.goto(`${base}/w/memory-review/memory/permissions-empty`); await page.locator(".subject-overview").waitFor(); await page.locator('main a[href$="/permissions"]').click(); @@ -282,7 +282,7 @@ try { `${prefix}-operation-description-resource-input`, ); await add(page, "delegation", "ticket.comment", "T-802"); - // Use named lane scopes so one grant cannot silently appear in both lanes. + // 名前付きlaneで検査し、一つのgrantが両方へ黙って追加されないことを確認する。 assertEquals( await page.locator('.lane[aria-label="Direct operations"]').getByText("Read ticket", { exact: true, @@ -310,7 +310,7 @@ try { await page.locator("[data-permission-state=ready]").waitFor(); await capturePoint(page, `${prefix}-re-read`); - // Real browser transport loss AFTER the synthetic HTTP fixture accepts the POST. + // 合成HTTP fixtureがPOSTを受け付けた後、実browserで応答を消失させる。 await add(page, "execution", "ticket.comment", "T-803"); let lost = false; await page.route("**/subjektiv/subjects/permissions-empty/permissions", async (route) => { @@ -323,7 +323,7 @@ try { }); await save(page, "unknown"); assert(lost); - // Fault injection is one-shot; restore ordinary browser networking before evidence capture. + // 障害注入は一度だけとし、撮影前に通常のbrowser通信へ戻す。 await page.unroute("**/subjektiv/subjects/permissions-empty/permissions"); await capturePoint(page, `${prefix}-unknown`, [ "Intentional POST response loss after synthetic save", @@ -343,7 +343,7 @@ try { "Intentional earlier POST response loss", ]); - // Two browser pages observe the same synthetic change identity before either save. + // 二つのbrowser画面で同じ合成change identityを観測してから保存する。 const other = await context.newPage(); observe(other); await open(page, base, "permissions-edit"); @@ -397,7 +397,7 @@ try { await capturePoint(page, `${prefix}-legacy-preserved`, [ "Intentional earlier service unavailable GET", ]); - // Revoke a supported direct permission; this fixture proves UI intent, not real cancellation. + // 直接操作の失効を確認する。ここで証明するのはUIの入力であり、実取消ではない。 await page.getByRole("button", { name: "Remove Read ticket from direct operations", exact: true,