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