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.
This commit is contained in:
@@ -51,6 +51,9 @@
|
|||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
mod catalog;
|
||||||
|
pub use catalog::*;
|
||||||
|
|
||||||
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
|
||||||
|
|||||||
@@ -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<PermissionResourceKind>,
|
||||||
|
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<PermissionOperationDescriptor> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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<ScopedPermission>,
|
||||||
|
delegation: Vec<ScopedPermission>,
|
||||||
|
) -> 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");
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ pub use workspace_config::*;
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SubjectPermissionsResponse {
|
pub struct SubjectPermissionsResponse {
|
||||||
pub permissions: Option<authorization::SubjectPermissions>,
|
pub permissions: Option<authorization::SubjectPermissions>,
|
||||||
|
pub can_manage: bool,
|
||||||
|
pub operations: Vec<authorization::PermissionOperationDescriptor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -11060,6 +11062,16 @@ pub fn memory_api_typescript() -> String {
|
|||||||
MemoryStagingRecord::decl(&config),
|
MemoryStagingRecord::decl(&config),
|
||||||
MemoryStagingEntry::decl(&config),
|
MemoryStagingEntry::decl(&config),
|
||||||
MemoryStagingListResponse::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
|
// These browser DTOs include nested types owned by `memory` and the Worker
|
||||||
// launch API. Keep their exported names explicit so the generated Web
|
// launch API. Keep their exported names explicit so the generated Web
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ impl SubjektivStore {
|
|||||||
&self,
|
&self,
|
||||||
subject_id: &str,
|
subject_id: &str,
|
||||||
update: &SubjectPermissionsUpdate,
|
update: &SubjectPermissionsUpdate,
|
||||||
|
) -> Result<SubjectPermissions> {
|
||||||
|
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<SubjectPermissions> {
|
||||||
|
self.replace_subject_permissions(subject_id, update, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_subject_permissions(
|
||||||
|
&self,
|
||||||
|
subject_id: &str,
|
||||||
|
update: &SubjectPermissionsUpdate,
|
||||||
|
managed: bool,
|
||||||
) -> Result<SubjectPermissions> {
|
) -> Result<SubjectPermissions> {
|
||||||
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
|
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
|
||||||
validate_authorization_identifier(&update.operation_id)
|
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() {
|
if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() {
|
||||||
return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into()));
|
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 {
|
let source = SubjectPermissions {
|
||||||
change_id: issued_id("permissions"),
|
change_id: issued_id("permissions"),
|
||||||
execution: update.execution.clone(),
|
execution: update.execution.clone(),
|
||||||
|
|||||||
@@ -9708,20 +9708,28 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
let api = self.workspace_api()?.clone();
|
let api = self.workspace_api()?.clone();
|
||||||
let actor = subject_authorization::administration_actor(&context)
|
let actor = subject_authorization::administration_actor(&context)
|
||||||
.map_err(ApiError::into_repository_api_error)?;
|
.map_err(ApiError::into_repository_api_error)?;
|
||||||
require_workspace_owner(
|
validate_workspace_scope(&api, &workspace_id)
|
||||||
&api,
|
.map_err(ApiError::into_repository_api_error)?;
|
||||||
&workspace_id,
|
let workspace = api
|
||||||
actor,
|
.store
|
||||||
"Subject permission administration",
|
.get_workspace(&workspace_id)
|
||||||
)
|
.await
|
||||||
.await
|
.map_err(ApiError::from)
|
||||||
.map_err(ApiError::into_repository_api_error)?;
|
.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
|
api.store
|
||||||
.db_executor()
|
.db_executor()
|
||||||
.run(move || -> ApiResult<_> {
|
.run(move || -> ApiResult<_> {
|
||||||
Ok(open_subjektiv_store(&api)?
|
Ok(open_subjektiv_store(&api)?
|
||||||
.subject_permissions(&subject_id)
|
.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)?)
|
.map_err(subjektiv_store_error)?)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -9762,7 +9770,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
let was_current = before.as_ref().map(|policy| &policy.change_id)
|
let was_current = before.as_ref().map(|policy| &policy.change_id)
|
||||||
== request.expected_change_id.as_ref();
|
== request.expected_change_id.as_ref();
|
||||||
let permissions = store
|
let permissions = store
|
||||||
.set_subject_permissions(&mutation_subject, &request)
|
.set_managed_subject_permissions(&mutation_subject, &request)
|
||||||
.map_err(subjektiv_store_error)?;
|
.map_err(subjektiv_store_error)?;
|
||||||
let changed = was_current
|
let changed = was_current
|
||||||
&& before.as_ref().map(|source| &source.change_id)
|
&& before.as_ref().map(|source| &source.change_id)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
#[path = "subject_job_model_supply_tests.rs"]
|
#[path = "subject_job_model_supply_tests.rs"]
|
||||||
mod subject_job_model_supply;
|
mod subject_job_model_supply;
|
||||||
|
#[path = "subject_permission_management_tests.rs"]
|
||||||
|
mod subject_permission_management;
|
||||||
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
|
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
|
||||||
use server_api::authorization::{
|
use server_api::authorization::{
|
||||||
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
|
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 {
|
fn authorization_path() -> String {
|
||||||
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
|
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
|
||||||
}
|
}
|
||||||
@@ -179,6 +189,27 @@ impl Fixture {
|
|||||||
self.subject
|
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<ScopedPermission>,
|
||||||
|
delegation: Vec<ScopedPermission>,
|
||||||
|
) -> 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(
|
async fn assign(
|
||||||
&self,
|
&self,
|
||||||
execution: Vec<ScopedPermission>,
|
execution: Vec<ScopedPermission>,
|
||||||
@@ -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;
|
let (status, body) = f.check(&f.source, &permission).await;
|
||||||
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
|
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
|
||||||
}
|
}
|
||||||
|
f.seed_saved_policy(vec![activation("ticket")], vec![]);
|
||||||
f.assign(vec![activation("ticket"), create_permission()], vec![])
|
f.assign(vec![activation("ticket"), create_permission()], vec![])
|
||||||
.await;
|
.await;
|
||||||
for permission in [activation("ticket"), create_permission()] {
|
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();
|
.collect();
|
||||||
f.assign(workspace_grants.clone(), vec![]).await;
|
f.seed_saved_policy(workspace_grants.clone(), vec![]);
|
||||||
for permission in &requested {
|
for permission in &requested {
|
||||||
let (status, body) = f.check(&f.source, permission).await;
|
let (status, body) = f.check(&f.source, permission).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -612,8 +644,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|permission| permission.operation.split('.').next().unwrap())
|
.map(|permission| permission.operation.split('.').next().unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
f.assign(feature_names.into_iter().map(activation).collect(), vec![])
|
f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]);
|
||||||
.await;
|
|
||||||
for permission in &requested {
|
for permission in &requested {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&f.source, permission).await.0,
|
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:?}"
|
"{permission:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
f.assign(requested.clone(), vec![]).await;
|
f.seed_saved_policy(requested.clone(), vec![]);
|
||||||
for permission in &requested {
|
for permission in &requested {
|
||||||
let (status, body) = f.check(&f.source, permission).await;
|
let (status, body) = f.check(&f.source, permission).await;
|
||||||
assert_eq!(
|
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() {
|
async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
let permissions = concrete_resource_permissions(&f);
|
let permissions = concrete_resource_permissions(&f);
|
||||||
f.assign(permissions.clone(), vec![]).await;
|
f.seed_saved_policy(permissions.clone(), vec![]);
|
||||||
for permission in permissions
|
for permission in permissions
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|permission| permission.resource.workspace_id().is_some())
|
.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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
|
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![create_permission()], vec![]).await;
|
||||||
let body = serde_json::to_value(activation("ticket")).unwrap();
|
let body = serde_json::to_value(create_permission()).unwrap();
|
||||||
let (status, error) = response(
|
let (status, error) = response(
|
||||||
&f.api,
|
&f.api,
|
||||||
human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())),
|
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)]
|
#[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 f = Fixture::new().await;
|
||||||
let (status, body) = response(
|
let (status, body) = response(
|
||||||
&f.api,
|
&f.api,
|
||||||
@@ -906,8 +937,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
|||||||
assert!(body["permissions"].is_null());
|
assert!(body["permissions"].is_null());
|
||||||
let original = f
|
let original = f
|
||||||
.assign(
|
.assign(
|
||||||
vec![activation("ticket"), create_permission()],
|
vec![create_permission(), query_permission()],
|
||||||
vec![activation("ticket")],
|
vec![create_permission()],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (status, body) = response(
|
let (status, body) = response(
|
||||||
@@ -959,9 +990,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status,
|
status,
|
||||||
StatusCode::FORBIDDEN,
|
if method == "GET" {
|
||||||
"foreign account {method}: {body}"
|
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!(
|
assert_eq!(
|
||||||
open_subjektiv_store(&f.api)
|
open_subjektiv_store(&f.api)
|
||||||
@@ -970,7 +1012,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(original.clone())
|
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_ne!(shrunk.change_id, original.change_id);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&f.source, &create_permission()).await.0,
|
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;
|
let revoked = f.assign(vec![], vec![]).await;
|
||||||
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
|
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&f.source, &activation("ticket")).await.0,
|
f.check(&f.source, &query_permission()).await.0,
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1140,6 +1182,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
|
|||||||
("old-feature-only", vec![activation("ticket")]),
|
("old-feature-only", vec![activation("ticket")]),
|
||||||
("wrong-resource", vec![wrong_workspace]),
|
("wrong-resource", vec![wrong_workspace]),
|
||||||
] {
|
] {
|
||||||
|
f.seed_saved_policy(permissions.clone(), vec![]);
|
||||||
f.assign(permissions, vec![]).await;
|
f.assign(permissions, vec![]).await;
|
||||||
client.requests.lock().unwrap().clear();
|
client.requests.lock().unwrap().clear();
|
||||||
let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await;
|
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,
|
1,
|
||||||
"genuine SQLite Ticket backend result: {created}"
|
"genuine SQLite Ticket backend result: {created}"
|
||||||
);
|
);
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(
|
.execute(
|
||||||
r#"{"title":"revoked after install"}"#,
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
|
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
|
||||||
let f = Fixture::new().await;
|
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;
|
f.assign(vec![], permissions.clone()).await;
|
||||||
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
|
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
|
||||||
child_request["execution_grant"]["redelegation"] = json!(permissions);
|
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()
|
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
|
||||||
{
|
{
|
||||||
let f = Fixture::new().await;
|
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;
|
f.assign(vec![], permissions.clone()).await;
|
||||||
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
|
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
|
||||||
missing_id
|
missing_id
|
||||||
@@ -1836,7 +1879,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
|
|||||||
let (status, body) = f
|
let (status, body) = f
|
||||||
.spawn(
|
.spawn(
|
||||||
&child,
|
&child,
|
||||||
f.child_request("undelegatable-grandchild", vec![activation("ticket")]),
|
f.child_request("undelegatable-grandchild", vec![query_permission()]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
|
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![], vec![activation("ticket")]).await;
|
f.assign(vec![], vec![query_permission()]).await;
|
||||||
let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]);
|
let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]);
|
||||||
singleton["singleton_key"] = json!("model-chosen-singleton");
|
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}});
|
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);
|
spoof["execution_grant"]["subject_id"] = json!(f.subject);
|
||||||
for request in [
|
for request in [
|
||||||
singleton,
|
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()
|
async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy()
|
||||||
{
|
{
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![], vec![activation("ticket")]).await;
|
f.assign(vec![], vec![query_permission()]).await;
|
||||||
let child = f.child("revocable-child", vec![activation("ticket")]).await;
|
let child = f.child("revocable-child", vec![query_permission()]).await;
|
||||||
assert_eq!(
|
assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK);
|
||||||
f.check(&child, &activation("ticket")).await.0,
|
|
||||||
StatusCode::OK
|
|
||||||
);
|
|
||||||
let before = open_subjektiv_store(&f.api)
|
let before = open_subjektiv_store(&f.api)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.worker_execution_grant(&child.runtime_id, &child.worker_id)
|
.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();
|
.unwrap();
|
||||||
f.assign(vec![], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&child, &activation("ticket")).await.0,
|
f.check(&child, &query_permission()).await.0,
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
let observation = f
|
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;
|
Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&child, &activation("ticket")).await.0,
|
f.check(&child, &query_permission()).await.0,
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
// Regranting identical permissions changes the assignment identity. Old
|
||||||
// grants remain revoked rather than refreshing during restore/reconnect.
|
// 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!(
|
assert_eq!(
|
||||||
f.check(&child, &activation("ticket")).await.0,
|
f.check(&child, &query_permission()).await.0,
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
let current = f
|
let current = f
|
||||||
.child("current-before-origin-release", vec![activation("ticket")])
|
.child("current-before-origin-release", vec![query_permission()])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(¤t, &activation("ticket")).await.0,
|
f.check(¤t, &query_permission()).await.0,
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
);
|
);
|
||||||
f.release_source_lease();
|
f.release_source_lease();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(¤t, &activation("ticket")).await.0,
|
f.check(¤t, &query_permission()).await.0,
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
"the captured source lease is no longer current"
|
"the captured source lease is no longer current"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&f.source, &activation("ticket")).await.0,
|
f.check(&f.source, &query_permission()).await.0,
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"released body fails signed-source authentication instead of falling through to legacy compatibility"
|
"released body fails signed-source authentication instead of falling through to legacy compatibility"
|
||||||
);
|
);
|
||||||
let (status, _) = f
|
let (status, _) = f
|
||||||
.spawn(
|
.spawn(
|
||||||
&f.source,
|
&f.source,
|
||||||
f.child_request("released-origin-spawn", vec![activation("ticket")]),
|
f.child_request("released-origin-spawn", vec![query_permission()]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
|
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
let client = f.client(&f.source);
|
let client = f.client(&f.source);
|
||||||
let (mut host, installed) =
|
let (mut host, installed) =
|
||||||
ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await;
|
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 backend = browser_ticket_backend(&f.api).unwrap();
|
||||||
let tickets = backend.list(Default::default()).unwrap();
|
let tickets = backend.list(Default::default()).unwrap();
|
||||||
assert_eq!(tickets.len(), 1);
|
assert_eq!(tickets.len(), 1);
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
assert!(
|
assert!(
|
||||||
invoke
|
invoke
|
||||||
.execute(&args, ToolExecutionContext::direct())
|
.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 f = Fixture::new().await;
|
||||||
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
|
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
|
||||||
"execution":[activation("ticket")],"delegation":[]});
|
"execution":[create_permission()],"delegation":[]});
|
||||||
let (status, body) = response(
|
let (status, body) = response(
|
||||||
&f.api,
|
&f.api,
|
||||||
human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())),
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
|
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![], vec![activation("ticket"), create_permission()])
|
f.assign(vec![], vec![query_permission(), create_permission()])
|
||||||
.await;
|
.await;
|
||||||
let mut request = f.child_request(
|
let mut request = f.child_request(
|
||||||
"redelegating-parent",
|
"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;
|
let (status, body) = f.spawn(&f.source, request).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
let parent = RuntimeWorkerRef::new(
|
let parent = RuntimeWorkerRef::new(
|
||||||
@@ -2111,7 +2151,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
|
|||||||
let (status, body) = f
|
let (status, body) = f
|
||||||
.spawn(
|
.spawn(
|
||||||
&parent,
|
&parent,
|
||||||
f.child_request("allowed-grandchild", vec![activation("ticket")]),
|
f.child_request("allowed-grandchild", vec![query_permission()]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
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"
|
"keep immediate lineage instead of flattening to the body"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&grandchild, &activation("ticket")).await.0,
|
f.check(&grandchild, &query_permission()).await.0,
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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);
|
assert_eq!(f.runtime.spawn_requests().len(), calls);
|
||||||
f.assign(vec![], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.check(&grandchild, &activation("ticket")).await.0,
|
f.check(&grandchild, &query_permission()).await.0,
|
||||||
StatusCode::FORBIDDEN
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
|
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![], vec![activation("ticket")]).await;
|
f.assign(vec![], vec![query_permission()]).await;
|
||||||
let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]);
|
let mut request = f.child_request("profile-bound-once", vec![query_permission()]);
|
||||||
request["profile"] = json!("project:authority-editor");
|
request["profile"] = json!("project:authority-editor");
|
||||||
let (status, body) = f.spawn(&f.source, request.clone()).await;
|
let (status, body) = f.spawn(&f.source, request.clone()).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
|
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
|
||||||
let f = Fixture::new().await;
|
let f = Fixture::new().await;
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
let client = f.client(&f.source);
|
let client = f.client(&f.source);
|
||||||
let (_host, installed, service) =
|
let (_host, installed, service) =
|
||||||
ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await;
|
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"
|
"Service authorized"
|
||||||
);
|
);
|
||||||
assert_eq!(ticket_posts(&client), 1);
|
assert_eq!(ticket_posts(&client), 1);
|
||||||
f.assign(vec![activation("ticket")], vec![]).await;
|
f.assign(vec![], vec![]).await;
|
||||||
assert!(
|
assert!(
|
||||||
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
|
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
-1
@@ -7023,6 +7023,72 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"PermissionResource": {
|
||||||
"oneOf": [
|
"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": {
|
"PluginAddArtifactRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11257,6 +11344,15 @@
|
|||||||
"SubjectPermissionsResponse": {
|
"SubjectPermissionsResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"can_manage": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PermissionOperationDescriptor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -11268,6 +11364,10 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"can_manage",
|
||||||
|
"operations"
|
||||||
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"SubjectPermissionsUpdate": {
|
"SubjectPermissionsUpdate": {
|
||||||
@@ -19062,7 +19162,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f"
|
"x-yoi-source-digest": "sha256:4c0318a432349f448b15d1ca105e4a73a4e6ed8a90caff07f07654c523c364cd"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -111,6 +111,118 @@ export type MemoryStagingListResponse = {
|
|||||||
diagnostics: Array<Diagnostic>;
|
diagnostics: Array<Diagnostic>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<ScopedPermission>;
|
||||||
|
delegation: Array<ScopedPermission>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectPermissionsUpdate = {
|
||||||
|
operation_id: string;
|
||||||
|
expected_change_id: string | null;
|
||||||
|
execution: Array<ScopedPermission>;
|
||||||
|
delegation: Array<ScopedPermission>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<PermissionResourceKind>;
|
||||||
|
availability: PermissionOperationAvailability;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectPermissionsResponse = {
|
||||||
|
permissions: SubjectPermissions | null;
|
||||||
|
can_manage: boolean;
|
||||||
|
operations: Array<PermissionOperationDescriptor>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<SubjectPermissionInterruption>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectPermissionInterruption = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
cancellation_accepted: boolean;
|
||||||
|
backend_command_sessions_closed: boolean;
|
||||||
|
diagnostics: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SubjektivSubjectState = "active" | "retired";
|
export type SubjektivSubjectState = "active" | "retired";
|
||||||
|
|
||||||
export type SubjektivSubjectCreateRequest = {
|
export type SubjektivSubjectCreateRequest = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// 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
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user