feat: integrate Subject permission management with Worker contracts
This commit is contained in:
@@ -53,6 +53,9 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod catalog;
|
||||
pub use catalog::*;
|
||||
|
||||
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
//! 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,
|
||||
WorkerOperation,
|
||||
}
|
||||
|
||||
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,
|
||||
Self::WorkerOperation { .. } => K::WorkerOperation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct SubjectPermissionsResponse {
|
||||
pub permissions: Option<authorization::SubjectPermissions>,
|
||||
pub can_manage: bool,
|
||||
pub operations: Vec<authorization::PermissionOperationDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -11092,6 +11094,16 @@ pub fn memory_api_typescript() -> String {
|
||||
MemoryStagingRecord::decl(&config),
|
||||
MemoryStagingEntry::decl(&config),
|
||||
MemoryStagingListResponse::decl(&config),
|
||||
authorization::PermissionResource::decl(&config),
|
||||
authorization::ScopedPermission::decl(&config),
|
||||
authorization::SubjectPermissions::decl(&config),
|
||||
authorization::SubjectPermissionsUpdate::decl(&config),
|
||||
authorization::PermissionResourceKind::decl(&config),
|
||||
authorization::PermissionOperationAvailability::decl(&config),
|
||||
authorization::PermissionOperationDescriptor::decl(&config),
|
||||
SubjectPermissionsResponse::decl(&config),
|
||||
SubjectPermissionsMutationResponse::decl(&config),
|
||||
SubjectPermissionInterruption::decl(&config),
|
||||
];
|
||||
// These browser DTOs include nested types owned by `memory` and the Worker
|
||||
// launch API. Keep their exported names explicit so the generated Web
|
||||
|
||||
@@ -36,6 +36,27 @@ impl SubjektivStore {
|
||||
&self,
|
||||
subject_id: &str,
|
||||
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> {
|
||||
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
validate_authorization_identifier(&update.operation_id)
|
||||
@@ -70,6 +91,10 @@ impl SubjektivStore {
|
||||
if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() {
|
||||
return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into()));
|
||||
}
|
||||
if managed {
|
||||
server_api::authorization::validate_managed_subject_permissions(current.as_ref(), update)
|
||||
.map_err(SubjektivError::InvalidRecord)?;
|
||||
}
|
||||
let source = SubjectPermissions {
|
||||
change_id: issued_id("permissions"),
|
||||
execution: update.execution.clone(),
|
||||
|
||||
@@ -9774,20 +9774,28 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
let api = self.workspace_api()?.clone();
|
||||
let actor = subject_authorization::administration_actor(&context)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
require_workspace_owner(
|
||||
&api,
|
||||
&workspace_id,
|
||||
actor,
|
||||
"Subject permission administration",
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
validate_workspace_scope(&api, &workspace_id)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(&workspace_id)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::WorkspaceIdMismatch).into_repository_api_error()
|
||||
})?;
|
||||
let can_manage = workspace.owner_account_id == actor.account_id;
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || -> ApiResult<_> {
|
||||
Ok(open_subjektiv_store(&api)?
|
||||
.subject_permissions(&subject_id)
|
||||
.map(|permissions| server_api::SubjectPermissionsResponse { permissions })
|
||||
.map(|permissions| server_api::SubjectPermissionsResponse {
|
||||
permissions,
|
||||
can_manage,
|
||||
operations: server_api::authorization::subject_permission_operations(),
|
||||
})
|
||||
.map_err(subjektiv_store_error)?)
|
||||
})
|
||||
.await
|
||||
@@ -9828,7 +9836,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
let was_current = before.as_ref().map(|policy| &policy.change_id)
|
||||
== request.expected_change_id.as_ref();
|
||||
let permissions = store
|
||||
.set_subject_permissions(&mutation_subject, &request)
|
||||
.set_managed_subject_permissions(&mutation_subject, &request)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let changed = was_current
|
||||
&& before.as_ref().map(|source| &source.change_id)
|
||||
|
||||
@@ -13,6 +13,8 @@ mod ticket_worker_messaging_connected;
|
||||
mod worker_messaging_connected;
|
||||
#[path = "worker_operation_authorization_tests.rs"]
|
||||
mod worker_operation_authorization;
|
||||
#[path = "subject_permission_management_tests.rs"]
|
||||
mod subject_permission_management;
|
||||
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
|
||||
@@ -48,6 +50,14 @@ fn create_permission() -> ScopedPermission {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn query_permission() -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: "ticket.query".into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn authorization_path() -> String {
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
|
||||
}
|
||||
@@ -185,6 +195,27 @@ impl Fixture {
|
||||
self.subject
|
||||
)
|
||||
}
|
||||
// Seed only intentionally unsupported saved input, never a management save.
|
||||
// This models pre-catalog policies without teaching the HTTP validator to issue them.
|
||||
fn seed_saved_policy(
|
||||
&self,
|
||||
execution: Vec<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(
|
||||
&self,
|
||||
execution: Vec<ScopedPermission>,
|
||||
@@ -418,6 +449,7 @@ async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() {
|
||||
let (status, body) = f.check(&f.source, &permission).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
|
||||
}
|
||||
f.seed_saved_policy(vec![activation("ticket")], vec![]);
|
||||
f.assign(vec![activation("ticket"), create_permission()], vec![])
|
||||
.await;
|
||||
for permission in [activation("ticket"), create_permission()] {
|
||||
@@ -603,7 +635,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
f.assign(workspace_grants.clone(), vec![]).await;
|
||||
f.seed_saved_policy(workspace_grants.clone(), vec![]);
|
||||
for permission in &requested {
|
||||
let (status, body) = f.check(&f.source, permission).await;
|
||||
assert_eq!(
|
||||
@@ -618,8 +650,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
|
||||
.iter()
|
||||
.map(|permission| permission.operation.split('.').next().unwrap())
|
||||
.collect();
|
||||
f.assign(feature_names.into_iter().map(activation).collect(), vec![])
|
||||
.await;
|
||||
f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]);
|
||||
for permission in &requested {
|
||||
assert_eq!(
|
||||
f.check(&f.source, permission).await.0,
|
||||
@@ -627,7 +658,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
|
||||
"{permission:?}"
|
||||
);
|
||||
}
|
||||
f.assign(requested.clone(), vec![]).await;
|
||||
f.seed_saved_policy(requested.clone(), vec![]);
|
||||
for permission in &requested {
|
||||
let (status, body) = f.check(&f.source, permission).await;
|
||||
assert_eq!(
|
||||
@@ -653,7 +684,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
|
||||
async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() {
|
||||
let f = Fixture::new().await;
|
||||
let permissions = concrete_resource_permissions(&f);
|
||||
f.assign(permissions.clone(), vec![]).await;
|
||||
f.seed_saved_policy(permissions.clone(), vec![]);
|
||||
for permission in permissions
|
||||
.iter()
|
||||
.filter(|permission| permission.resource.workspace_id().is_some())
|
||||
@@ -802,8 +833,8 @@ async fn signed_host_ungranted_operation_does_not_disclose_ticket_or_subject_exi
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
let body = serde_json::to_value(activation("ticket")).unwrap();
|
||||
f.assign(vec![create_permission()], vec![]).await;
|
||||
let body = serde_json::to_value(create_permission()).unwrap();
|
||||
let (status, error) = response(
|
||||
&f.api,
|
||||
human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())),
|
||||
@@ -901,7 +932,7 @@ async fn authorization_rejects_missing_source_unknown_identity_fields_and_tamper
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() {
|
||||
async fn only_owner_account_can_assign_shrink_and_revoke_subject_permissions() {
|
||||
let f = Fixture::new().await;
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
@@ -912,8 +943,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
||||
assert!(body["permissions"].is_null());
|
||||
let original = f
|
||||
.assign(
|
||||
vec![activation("ticket"), create_permission()],
|
||||
vec![activation("ticket")],
|
||||
vec![create_permission(), query_permission()],
|
||||
vec![create_permission()],
|
||||
)
|
||||
.await;
|
||||
let (status, body) = response(
|
||||
@@ -965,9 +996,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"foreign account {method}: {body}"
|
||||
if method == "GET" {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
},
|
||||
"non-owner account {method}: {body}"
|
||||
);
|
||||
if method == "GET" {
|
||||
assert_eq!(body["can_manage"], false);
|
||||
assert_eq!(
|
||||
body["permissions"],
|
||||
serde_json::to_value(&original).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
open_subjektiv_store(&f.api)
|
||||
@@ -976,7 +1018,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
||||
.unwrap(),
|
||||
Some(original.clone())
|
||||
);
|
||||
let shrunk = f.assign(vec![activation("ticket")], vec![]).await;
|
||||
let shrunk = f.assign(vec![query_permission()], vec![]).await;
|
||||
assert_ne!(shrunk.change_id, original.change_id);
|
||||
assert_eq!(
|
||||
f.check(&f.source, &create_permission()).await.0,
|
||||
@@ -995,7 +1037,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
|
||||
let revoked = f.assign(vec![], vec![]).await;
|
||||
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
|
||||
assert_eq!(
|
||||
f.check(&f.source, &activation("ticket")).await.0,
|
||||
f.check(&f.source, &query_permission()).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
@@ -1146,6 +1188,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
|
||||
("old-feature-only", vec![activation("ticket")]),
|
||||
("wrong-resource", vec![wrong_workspace]),
|
||||
] {
|
||||
f.seed_saved_policy(permissions.clone(), vec![]);
|
||||
f.assign(permissions, vec![]).await;
|
||||
client.requests.lock().unwrap().clear();
|
||||
let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await;
|
||||
@@ -1188,7 +1231,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
|
||||
1,
|
||||
"genuine SQLite Ticket backend result: {created}"
|
||||
);
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
f.assign(vec![], vec![]).await;
|
||||
let result = tool
|
||||
.execute(
|
||||
r#"{"title":"revoked after install"}"#,
|
||||
@@ -1528,7 +1571,7 @@ fn creation_record_counts(f: &Fixture) -> [i64; 3] {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
|
||||
let f = Fixture::new().await;
|
||||
let permissions = vec![activation("ticket"), create_permission()];
|
||||
let permissions = vec![query_permission(), create_permission()];
|
||||
f.assign(vec![], permissions.clone()).await;
|
||||
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
|
||||
child_request["execution_grant"]["redelegation"] = json!(permissions);
|
||||
@@ -1645,7 +1688,7 @@ async fn generic_create_routes_preserve_human_and_non_subject_worker_creation()
|
||||
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let permissions = vec![activation("ticket"), create_permission()];
|
||||
let permissions = vec![query_permission(), create_permission()];
|
||||
f.assign(vec![], permissions.clone()).await;
|
||||
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
|
||||
missing_id
|
||||
@@ -1842,7 +1885,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
|
||||
let (status, body) = f
|
||||
.spawn(
|
||||
&child,
|
||||
f.child_request("undelegatable-grandchild", vec![activation("ticket")]),
|
||||
f.child_request("undelegatable-grandchild", vec![query_permission()]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -1855,12 +1898,12 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![], vec![activation("ticket")]).await;
|
||||
let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]);
|
||||
f.assign(vec![], vec![query_permission()]).await;
|
||||
let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]);
|
||||
singleton["singleton_key"] = json!("model-chosen-singleton");
|
||||
let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]);
|
||||
let mut body_attachment = f.child_request("subject-escalation", vec![query_permission()]);
|
||||
body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}});
|
||||
let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]);
|
||||
let mut spoof = f.child_request("origin-escalation", vec![query_permission()]);
|
||||
spoof["execution_grant"]["subject_id"] = json!(f.subject);
|
||||
for request in [
|
||||
singleton,
|
||||
@@ -1882,12 +1925,9 @@ async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_
|
||||
async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![], vec![activation("ticket")]).await;
|
||||
let child = f.child("revocable-child", vec![activation("ticket")]).await;
|
||||
assert_eq!(
|
||||
f.check(&child, &activation("ticket")).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
f.assign(vec![], vec![query_permission()]).await;
|
||||
let child = f.child("revocable-child", vec![query_permission()]).await;
|
||||
assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK);
|
||||
let before = open_subjektiv_store(&f.api)
|
||||
.unwrap()
|
||||
.worker_execution_grant(&child.runtime_id, &child.worker_id)
|
||||
@@ -1895,7 +1935,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
|
||||
.unwrap();
|
||||
f.assign(vec![], vec![]).await;
|
||||
assert_eq!(
|
||||
f.check(&child, &activation("ticket")).await.0,
|
||||
f.check(&child, &query_permission()).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let observation = f
|
||||
@@ -1913,7 +1953,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
|
||||
Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(
|
||||
f.check(&child, &activation("ticket")).await.0,
|
||||
f.check(&child, &query_permission()).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1925,33 +1965,33 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
|
||||
);
|
||||
// Regranting identical permissions changes the assignment identity. Old
|
||||
// grants remain revoked rather than refreshing during restore/reconnect.
|
||||
f.assign(vec![], vec![activation("ticket")]).await;
|
||||
f.assign(vec![], vec![query_permission()]).await;
|
||||
assert_eq!(
|
||||
f.check(&child, &activation("ticket")).await.0,
|
||||
f.check(&child, &query_permission()).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let current = f
|
||||
.child("current-before-origin-release", vec![activation("ticket")])
|
||||
.child("current-before-origin-release", vec![query_permission()])
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.check(¤t, &activation("ticket")).await.0,
|
||||
f.check(¤t, &query_permission()).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
f.release_source_lease();
|
||||
assert_eq!(
|
||||
f.check(¤t, &activation("ticket")).await.0,
|
||||
f.check(¤t, &query_permission()).await.0,
|
||||
StatusCode::CONFLICT,
|
||||
"the captured source lease is no longer current"
|
||||
);
|
||||
assert_eq!(
|
||||
f.check(&f.source, &activation("ticket")).await.0,
|
||||
f.check(&f.source, &query_permission()).await.0,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"released body fails signed-source authentication instead of falling through to legacy compatibility"
|
||||
);
|
||||
let (status, _) = f
|
||||
.spawn(
|
||||
&f.source,
|
||||
f.child_request("released-origin-spawn", vec![activation("ticket")]),
|
||||
f.child_request("released-origin-spawn", vec![query_permission()]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
@@ -1960,7 +2000,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
f.assign(vec![], vec![]).await;
|
||||
let client = f.client(&f.source);
|
||||
let (mut host, installed) =
|
||||
ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await;
|
||||
@@ -2027,7 +2067,7 @@ async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_bac
|
||||
let backend = browser_ticket_backend(&f.api).unwrap();
|
||||
let tickets = backend.list(Default::default()).unwrap();
|
||||
assert_eq!(tickets.len(), 1);
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
f.assign(vec![], vec![]).await;
|
||||
assert!(
|
||||
invoke
|
||||
.execute(&args, ToolExecutionContext::direct())
|
||||
@@ -2047,7 +2087,7 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
|
||||
"execution":[activation("ticket")],"delegation":[]});
|
||||
"execution":[create_permission()],"delegation":[]});
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())),
|
||||
@@ -2101,13 +2141,13 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![], vec![activation("ticket"), create_permission()])
|
||||
f.assign(vec![], vec![query_permission(), create_permission()])
|
||||
.await;
|
||||
let mut request = f.child_request(
|
||||
"redelegating-parent",
|
||||
vec![activation("ticket"), create_permission()],
|
||||
vec![query_permission(), create_permission()],
|
||||
);
|
||||
request["execution_grant"]["redelegation"] = json!([activation("ticket")]);
|
||||
request["execution_grant"]["redelegation"] = json!([query_permission()]);
|
||||
let (status, body) = f.spawn(&f.source, request).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let parent = RuntimeWorkerRef::new(
|
||||
@@ -2117,7 +2157,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
|
||||
let (status, body) = f
|
||||
.spawn(
|
||||
&parent,
|
||||
f.child_request("allowed-grandchild", vec![activation("ticket")]),
|
||||
f.child_request("allowed-grandchild", vec![query_permission()]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
@@ -2135,7 +2175,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
|
||||
"keep immediate lineage instead of flattening to the body"
|
||||
);
|
||||
assert_eq!(
|
||||
f.check(&grandchild, &activation("ticket")).await.0,
|
||||
f.check(&grandchild, &query_permission()).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2157,7 +2197,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
|
||||
assert_eq!(f.runtime.spawn_requests().len(), calls);
|
||||
f.assign(vec![], vec![]).await;
|
||||
assert_eq!(
|
||||
f.check(&grandchild, &activation("ticket")).await.0,
|
||||
f.check(&grandchild, &query_permission()).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
@@ -2165,8 +2205,8 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![], vec![activation("ticket")]).await;
|
||||
let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]);
|
||||
f.assign(vec![], vec![query_permission()]).await;
|
||||
let mut request = f.child_request("profile-bound-once", vec![query_permission()]);
|
||||
request["profile"] = json!("project:authority-editor");
|
||||
let (status, body) = f.spawn(&f.source, request.clone()).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
@@ -2202,7 +2242,7 @@ async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_oper
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
|
||||
let f = Fixture::new().await;
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
f.assign(vec![], vec![]).await;
|
||||
let client = f.client(&f.source);
|
||||
let (_host, installed, service) =
|
||||
ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await;
|
||||
@@ -2278,7 +2318,7 @@ async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source(
|
||||
"Service authorized"
|
||||
);
|
||||
assert_eq!(ticket_posts(&client), 1);
|
||||
f.assign(vec![activation("ticket")], vec![]).await;
|
||||
f.assign(vec![], vec![]).await;
|
||||
assert!(
|
||||
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
|
||||
.await
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
-1
@@ -7023,6 +7023,72 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionOperationAvailability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "available",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "unavailable",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"reason"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionOperationDescriptor": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"availability": {
|
||||
"$ref": "#/components/schemas/PermissionOperationAvailability"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"resource_kinds": {
|
||||
"description": "Accepted management input kinds, not a claim that a target exists or is usable.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PermissionResourceKind"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation",
|
||||
"label",
|
||||
"description",
|
||||
"resource_kinds",
|
||||
"availability"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionResource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -7377,6 +7443,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionResourceKind": {
|
||||
"enum": [
|
||||
"job",
|
||||
"ticket",
|
||||
"objective",
|
||||
"merge_request",
|
||||
"subject",
|
||||
"drive_entry",
|
||||
"drive_request",
|
||||
"repository",
|
||||
"workdir_attachment",
|
||||
"workspace_config",
|
||||
"network",
|
||||
"sub_worker",
|
||||
"feature",
|
||||
"workspace",
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker_operation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginAddArtifactRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -11276,6 +11364,15 @@
|
||||
"SubjectPermissionsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"can_manage": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"operations": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PermissionOperationDescriptor"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11287,6 +11384,10 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"can_manage",
|
||||
"operations"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubjectPermissionsUpdate": {
|
||||
@@ -19114,7 +19215,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3"
|
||||
"x-yoi-source-digest": "sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -11,3 +11,5 @@
|
||||
- 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。
|
||||
- PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。
|
||||
- 変更時は`deno.json`のcheck・testと必要なbrowser testを選ぶ。scenario変更では実際の画像と`review-context.json`を確認する。browser testの成功を目視の代わりにしない。
|
||||
|
||||
Subject権限の操作確認には[`subject_permissions_review.ts`](browser-tests/subject_permissions_review.ts)、変更前の撮影には[`subject_permissions_before.ts`](browser-tests/subject_permissions_before.ts)を使う。実Webのビルドを合成HTTP fixtureで動かすため、採取結果は実Backendの認証・保存・認可・Worker制御の証拠ではない。画像と診断の目視確認も別途必要である。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
import { journalBrowserFixture } from "./journal_browser_fixture.ts";
|
||||
import {
|
||||
createSubjectPermissionsFixture,
|
||||
permissionFixtureSubjects,
|
||||
} from "./subject_permissions_fixture.ts";
|
||||
|
||||
const port = Number(Deno.args[0]);
|
||||
const buildRoot = Deno.args[1];
|
||||
@@ -11,6 +15,15 @@ if (
|
||||
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
|
||||
}
|
||||
|
||||
// 既存Memory scenarioとpaginationを変えないよう、明示選択時だけ有効にする。
|
||||
const permissionsMode = Deno.args[3] === "permissions";
|
||||
if (permissionsMode) {
|
||||
// 権限の証拠は親から渡された実製品buildを使って採取する。
|
||||
// build欠落時に/healthを成功させたり、fixture HTMLで代替したりしない。
|
||||
await Deno.stat(join(buildRoot, "index.html"));
|
||||
await Deno.stat(join(buildRoot, "_app/immutable"));
|
||||
}
|
||||
const permissionsFixture = createSubjectPermissionsFixture();
|
||||
const workspaceId = "memory-review";
|
||||
const representativeSubjectId =
|
||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
|
||||
@@ -81,6 +94,11 @@ const subjects = [
|
||||
subject(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"),
|
||||
subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"),
|
||||
];
|
||||
if (permissionsMode) {
|
||||
subjects.push(
|
||||
...permissionFixtureSubjects.map(([id, role]) => subject(id, role, "active", "fingerprint-0")),
|
||||
);
|
||||
}
|
||||
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1");
|
||||
|
||||
function connectedWorker(displayName: string) {
|
||||
@@ -132,7 +150,13 @@ function memoriesFor(subjectId: string) {
|
||||
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
|
||||
if (subjectId === staleSubjectId) {
|
||||
return [
|
||||
memorySummary("stale-memory-1", "change-earlier", "lesson", "resolved", "A resolved stale-surface lesson"),
|
||||
memorySummary(
|
||||
"stale-memory-1",
|
||||
"change-earlier",
|
||||
"lesson",
|
||||
"resolved",
|
||||
"A resolved stale-surface lesson",
|
||||
),
|
||||
];
|
||||
}
|
||||
if (subjectId === failedSubjectId) {
|
||||
@@ -227,7 +251,12 @@ function surfaceFor(subjectId: string) {
|
||||
function memoryDetail(memoryId: string, requestedChangeId: string | null) {
|
||||
const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current";
|
||||
const changeId = requestedChangeId ?? currentChangeId;
|
||||
const states: Record<string, string> = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" };
|
||||
const states: Record<string, string> = {
|
||||
"change-original": "retracted",
|
||||
"change-earlier": "resolved",
|
||||
"change-current": "active",
|
||||
"change-parent": "active",
|
||||
};
|
||||
return {
|
||||
memory_id: memoryId,
|
||||
change_id: changeId,
|
||||
@@ -361,6 +390,10 @@ async function staticResponse(pathname: string): Promise<Response> {
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (permissionsMode) {
|
||||
const response = await permissionsFixture(request);
|
||||
if (response) return response;
|
||||
}
|
||||
if (url.pathname === "/fixture/subject-create-requests") {
|
||||
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
||||
}
|
||||
@@ -393,6 +426,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
delete_subjects: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
@@ -409,7 +443,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/workers`) {
|
||||
return json({ workspace_id: workspaceId, limit: 200, items: [], source: "fixture", diagnostics: [] });
|
||||
return json({
|
||||
workspace_id: workspaceId,
|
||||
limit: 200,
|
||||
items: [],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) {
|
||||
return json({
|
||||
@@ -521,7 +561,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const paginated = subjectId === representativeSubjectId;
|
||||
const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor;
|
||||
const items = continued
|
||||
? [memorySummary("memory-page-two", "change-original", "lesson", "active", "Memory on the next page")]
|
||||
? [
|
||||
memorySummary(
|
||||
"memory-page-two",
|
||||
"change-original",
|
||||
"lesson",
|
||||
"active",
|
||||
"Memory on the next page",
|
||||
),
|
||||
]
|
||||
: memoriesFor(subjectId);
|
||||
return json({
|
||||
items,
|
||||
@@ -536,7 +584,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
const requested = url.searchParams.get("change_id");
|
||||
const knownChanges = memoryId === derivedMemoryId ? ["change-parent"] : ["change-original", "change-earlier", "change-current"];
|
||||
const knownChanges = memoryId === derivedMemoryId
|
||||
? ["change-parent"]
|
||||
: ["change-original", "change-earlier", "change-current"];
|
||||
if (requested !== null && !knownChanges.includes(requested)) return json({}, 404);
|
||||
return json(memoryDetail(memoryId, requested));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env -S deno run -A
|
||||
/** 変更前の実Web buildでSubject詳細を撮影する。Web自体はbuildしない。 */
|
||||
import { dirname, fromFileUrl, join, resolve } from "@std/path";
|
||||
import { capture } from "../src/capture.ts";
|
||||
import type { Scenario } from "../src/types.ts";
|
||||
import { freePort } from "./memory_document_harness.ts";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const outputRoot = join(repositoryRoot, "target/web-ux/t-802");
|
||||
const buildRoot = resolve(Deno.args[0] ?? join(repositoryRoot, "web/workspace/build"));
|
||||
const runPrefix = Deno.args[1] ?? `before-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`;
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(runPrefix)) throw new Error("run id must be a safe filename");
|
||||
// Preflightは必須。buildの欠落をfixture HTMLで代替しない。
|
||||
for (const filename of ["index.html", "_app/immutable"]) {
|
||||
await Deno.stat(join(buildRoot, filename)).catch(() => {
|
||||
throw new Error(
|
||||
`Actual Web build missing: ${
|
||||
join(buildRoot, filename)
|
||||
}. Parent must build/supply it; this harness does not build Web or bypass preflight.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
await Deno.mkdir(outputRoot, { recursive: true });
|
||||
const index = await Deno.readFile(join(buildRoot, "index.html"));
|
||||
const indexSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", index)))
|
||||
.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
await Deno.writeTextFile(
|
||||
join(outputRoot, `${runPrefix}-build.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
|
||||
buildRoot,
|
||||
indexSha256,
|
||||
capturedAt: new Date().toISOString(),
|
||||
visualVerdict: "UNREVIEWED — parent must ViewImage before pass",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const port = await freePort();
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `subject-permissions-before-${theme}`,
|
||||
title: "T-802 BEFORE — actual Subject detail / synthetic HTTP, NOT real Backend",
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "synthetic-owner",
|
||||
label: "Synthetic HTTP owner, NOT real Backend authentication",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [
|
||||
{ label: "desktop", width: 1440, height: 900 },
|
||||
{ label: "narrow", width: 768, height: 900 },
|
||||
{ label: "mobile", width: 390, height: 844 },
|
||||
],
|
||||
routes: [{
|
||||
id: "subject-detail",
|
||||
label: "Existing Subject detail before permission UI",
|
||||
path:
|
||||
"/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
|
||||
goal:
|
||||
"Find Subject identity, current state and ordinary routes before permission management changes.",
|
||||
dataState:
|
||||
"Active Subject with current Worker and representative long identifiers. All HTTP is synthetic; no real Backend evidence.",
|
||||
ready: { kind: "selector", selector: ".subject-overview" },
|
||||
capturePoints: [{ id: "initial", label: "BEFORE Subject detail" }],
|
||||
}],
|
||||
processes: [{
|
||||
id: "t-802-owned-synthetic-http",
|
||||
command: Deno.execPath(),
|
||||
cwd: join(repositoryRoot, "tools/web-ux"),
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
join(repositoryRoot, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
|
||||
String(port),
|
||||
buildRoot,
|
||||
"3",
|
||||
"permissions",
|
||||
],
|
||||
readyUrl: `http://127.0.0.1:${port}/health`,
|
||||
}],
|
||||
};
|
||||
const scenarioPath = join(outputRoot, `${runPrefix}-${theme}-scenario.json`);
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const bundle = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: outputRoot,
|
||||
runId: `${runPrefix}-${theme}`,
|
||||
});
|
||||
console.log(JSON.stringify({ runId: bundle.runId, status: bundle.status }));
|
||||
if (bundle.status !== "completed") {
|
||||
throw new Error(
|
||||
`BEFORE capture was not clean: ${bundle.runId}; inspect review-context.json. No visual pass is implied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Browser UXだけの合成HTTP状態。実Backend、認証、認可、操作catalog、取消の実装ではない。
|
||||
* 実Backendの結合証拠は、このbrowser fixtureと分けて保持する。
|
||||
*/
|
||||
export const permissionFixtureSubjects = [
|
||||
["permissions-empty", "New Subject — permissions not configured"],
|
||||
["permissions-edit", "Release coordination"],
|
||||
["permissions-legacy", "Subject with preserved legacy permissions"],
|
||||
["permissions-reader", "Read-only Subject permissions"],
|
||||
["permissions-unavailable", "Unavailable Subject permissions"],
|
||||
] as const;
|
||||
|
||||
export type FixturePermission = {
|
||||
operation: string;
|
||||
resource: Record<string, string | boolean> & { kind: string };
|
||||
};
|
||||
export type FixturePolicy = {
|
||||
change_id: string;
|
||||
execution: FixturePermission[];
|
||||
delegation: FixturePermission[];
|
||||
};
|
||||
type FixtureUpdate = Omit<FixturePolicy, "change_id"> & {
|
||||
operation_id: string;
|
||||
expected_change_id: string | null;
|
||||
};
|
||||
|
||||
const workspaceId = "memory-review";
|
||||
const resource = { kind: "ticket", workspace_id: workspaceId, ticket_id: "T-802" };
|
||||
export const fixtureLegacyPermission: FixturePermission = {
|
||||
operation: "worker.notify",
|
||||
resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" },
|
||||
};
|
||||
// 小さな合成catalogに限定する。製品catalogの複製や第二正本ではない。
|
||||
export const fixturePermissionOperations: {
|
||||
operation: string;
|
||||
label: string;
|
||||
description: string;
|
||||
resource_kinds: string[];
|
||||
availability: { kind: "available" } | { kind: "unavailable"; reason: string };
|
||||
}[] = [
|
||||
{
|
||||
operation: "ticket.read",
|
||||
label: "Read ticket",
|
||||
description: "Read the selected ticket. Does not allow editing or closing it.",
|
||||
resource_kinds: ["ticket"],
|
||||
availability: { kind: "available" },
|
||||
},
|
||||
{
|
||||
operation: "ticket.comment",
|
||||
label: "Comment on ticket",
|
||||
description: "Append a comment to the selected ticket; no completion or assignment authority.",
|
||||
resource_kinds: ["ticket"],
|
||||
availability: { kind: "available" },
|
||||
},
|
||||
{
|
||||
operation: "worker.messaging",
|
||||
label: "Message a Worker (T-803)",
|
||||
description:
|
||||
"Operation use is separate from Backend directional target control grants and execution conditions.",
|
||||
resource_kinds: [],
|
||||
availability: {
|
||||
kind: "unavailable",
|
||||
reason: "T-803 is not implemented in this synthetic browser fixture.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return Response.json(value, {
|
||||
status,
|
||||
headers: { "x-yoi-evidence": "synthetic-http-not-real-backend" },
|
||||
});
|
||||
}
|
||||
function error(message: string, status: number): Response {
|
||||
return json({ error: "Synthetic fixture response", message }, status);
|
||||
}
|
||||
function isUpdate(value: unknown): value is FixtureUpdate {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v.operation_id === "string" && v.operation_id.length > 0 &&
|
||||
(v.expected_change_id === null || typeof v.expected_change_id === "string") &&
|
||||
Array.isArray(v.execution) && Array.isArray(v.delegation) &&
|
||||
[...v.execution, ...v.delegation].every((p) =>
|
||||
p && typeof p.operation === "string" && p.resource && typeof p.resource.kind === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function createSubjectPermissionsFixture() {
|
||||
const policies = new Map<string, FixturePolicy | null>();
|
||||
const receipts = new Map<string, { input: string; permissions: FixturePolicy }>();
|
||||
const requests: { subject_id: string; request: FixtureUpdate; outcome: string }[] = [];
|
||||
let issuedChangeCount = 0;
|
||||
for (const [id] of permissionFixtureSubjects) {
|
||||
policies.set(
|
||||
id,
|
||||
id === "permissions-empty" ? null : {
|
||||
change_id: `fixture-initial-${id}`,
|
||||
execution: [{ operation: "ticket.read", resource: { ...resource } }],
|
||||
delegation: id === "permissions-legacy" ? [structuredClone(fixtureLegacyPermission)] : [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return async (request: Request): Promise<Response | null> => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
// 合成HTTP session projectionで実Webの画面入場判定を通す。
|
||||
// 実Backendのsessionやcookie/tokenは作成・迂回しない。
|
||||
return json({
|
||||
actor: {
|
||||
user_id: "synthetic-owner",
|
||||
account_id: "owner",
|
||||
handle: "fixture-owner",
|
||||
display_name: "Synthetic browser owner",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
authentication_reference: "synthetic-http-not-real-authentication",
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/fixture/permissions/evidence") {
|
||||
if (request.method !== "GET") return error("GET required", 405);
|
||||
return json({ authority: "synthetic-http-not-real-backend", requests });
|
||||
}
|
||||
const match = url.pathname.match(
|
||||
/^\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/permissions$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, workspace, id] = match.map(decodeURIComponent);
|
||||
if (workspace !== workspaceId || !policies.has(id)) return error("Subject unavailable", 404);
|
||||
if (id === "permissions-unavailable") return error("Permission service unavailable", 503);
|
||||
const current = policies.get(id)!;
|
||||
if (request.method === "GET") {
|
||||
return json({
|
||||
permissions: current,
|
||||
can_manage: id !== "permissions-reader",
|
||||
operations: fixturePermissionOperations,
|
||||
});
|
||||
}
|
||||
if (request.method !== "POST") return error("POST required", 405);
|
||||
if (id === "permissions-reader") return error("Actor cannot manage permissions", 403);
|
||||
const payload: unknown = await request.json().catch(() => null);
|
||||
if (!isUpdate(payload)) return error("Invalid synthetic policy update", 400);
|
||||
const input = JSON.stringify(payload);
|
||||
const receiptKey = JSON.stringify([id, payload.operation_id]);
|
||||
const previous = receipts.get(receiptKey);
|
||||
if (previous) {
|
||||
requests.push({ subject_id: id, request: payload, outcome: "replay" });
|
||||
if (previous.input !== input) {
|
||||
return error("Operation identity reused with changed input", 409);
|
||||
}
|
||||
return json({ permissions: previous.permissions, interruptions: [] });
|
||||
}
|
||||
if (payload.expected_change_id !== (current?.change_id ?? null)) {
|
||||
requests.push({ subject_id: id, request: payload, outcome: "conflict" });
|
||||
return error("Policy changed in another browser; reload and review", 409);
|
||||
}
|
||||
// fixtureで使う組合せだけを拒否する。Backend検証の証拠にはしない。
|
||||
for (const p of [...payload.execution, ...payload.delegation]) {
|
||||
const legacy = JSON.stringify(p) === JSON.stringify(fixtureLegacyPermission) &&
|
||||
id === "permissions-legacy";
|
||||
const supported = fixturePermissionOperations.some((o) =>
|
||||
o.availability.kind === "available" && o.operation === p.operation &&
|
||||
o.resource_kinds.includes(p.resource.kind)
|
||||
);
|
||||
if (!legacy && !supported) {
|
||||
return error("Unknown operation or invalid resource combination", 400);
|
||||
}
|
||||
if (
|
||||
p.resource.kind === "ticket" &&
|
||||
(p.resource.workspace_id !== workspaceId || !p.resource.ticket_id)
|
||||
) {
|
||||
return error("Invalid ticket target", 400);
|
||||
}
|
||||
}
|
||||
const saved: FixturePolicy = {
|
||||
change_id: `fixture-change-${++issuedChangeCount}`,
|
||||
execution: structuredClone(payload.execution),
|
||||
delegation: structuredClone(payload.delegation),
|
||||
};
|
||||
policies.set(id, saved);
|
||||
receipts.set(receiptKey, { input, permissions: structuredClone(saved) });
|
||||
requests.push({ subject_id: id, request: payload, outcome: "saved" });
|
||||
return json({ permissions: saved, interruptions: [] });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env -S deno run -A
|
||||
/** 実Web buildを合成HTTPで操作する。実Backend認証やWorker動作の証拠ではない。 */
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join, resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { sha256File } from "../src/artifacts.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
|
||||
|
||||
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build"));
|
||||
const runId = Deno.args[1] ?? `after-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`;
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("run id must be a safe filename");
|
||||
// Web buildは親の責務。欠落時は即失敗させ、合成shellで代替しない。
|
||||
for (const name of ["index.html", "_app/immutable"]) {
|
||||
await Deno.stat(join(buildRoot, name)).catch(() => {
|
||||
throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`);
|
||||
});
|
||||
}
|
||||
const output = join(root, "target/web-ux/t-802", runId);
|
||||
await Deno.mkdir(dirname(output), { recursive: true });
|
||||
await Deno.mkdir(output); // 同じrun IDで既存の証拠を上書きしない。
|
||||
const sourceCommit = await new Deno.Command("git", {
|
||||
args: ["rev-parse", "HEAD"],
|
||||
cwd: root,
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
const sourceStatus = await new Deno.Command("git", {
|
||||
args: ["status", "--porcelain"],
|
||||
cwd: root,
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
const manifest: {
|
||||
evidence: string;
|
||||
buildRoot: string;
|
||||
buildIndexSha256: string;
|
||||
visualVerdict: string;
|
||||
status: string;
|
||||
captures: unknown[];
|
||||
failures: string[];
|
||||
source: { commitSha: string | null; dirty: boolean | null };
|
||||
browser: { name: string; version: string } | null;
|
||||
expectedFaults: string[];
|
||||
} = {
|
||||
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
|
||||
buildRoot,
|
||||
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
|
||||
visualVerdict: "UNREVIEWED — parent must ViewImage and inspect diagnostics before pass",
|
||||
status: "running",
|
||||
captures: [],
|
||||
failures: [],
|
||||
source: {
|
||||
commitSha: sourceCommit.success ? new TextDecoder().decode(sourceCommit.stdout).trim() : null,
|
||||
dirty: sourceStatus.success
|
||||
? new TextDecoder().decode(sourceStatus.stdout).trim().length > 0
|
||||
: null,
|
||||
},
|
||||
browser: null,
|
||||
expectedFaults: [
|
||||
"One intentionally aborted POST response after synthetic persistence per width/theme",
|
||||
"HTTP 409 for stale two-page policy save",
|
||||
"HTTP 503 for permissions-unavailable GET",
|
||||
],
|
||||
};
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
manifest.browser = { name: "chromium", version: browser.version() };
|
||||
type BrowserDiagnostic = {
|
||||
kind: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
resourceType?: string;
|
||||
pageUrl?: string;
|
||||
};
|
||||
const diagnostics = new WeakMap<Page, BrowserDiagnostic[]>();
|
||||
const documents = new WeakMap<Page, number>();
|
||||
const errorCounts = new WeakMap<Page, number>();
|
||||
function observe(page: Page) {
|
||||
const errors: BrowserDiagnostic[] = [];
|
||||
diagnostics.set(page, errors);
|
||||
const add = (e: typeof errors[number]) => {
|
||||
errorCounts.set(page, (errorCounts.get(page) ?? 0) + 1);
|
||||
if (errors.length < 100) errors.push(e);
|
||||
};
|
||||
page.on("pageerror", (e) => add({ kind: "page", message: e.stack || e.message || String(e) }));
|
||||
page.on("console", (e) => {
|
||||
if (e.type() === "error" || e.type() === "warning") add({ kind: "console", message: e.text() });
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(r) =>
|
||||
add({
|
||||
kind: "request",
|
||||
message: r.failure()?.errorText ?? "request failed",
|
||||
url: r.url(),
|
||||
method: r.method(),
|
||||
resourceType: r.resourceType(),
|
||||
pageUrl: page.url(),
|
||||
}),
|
||||
);
|
||||
page.on("response", (r) => {
|
||||
if (r.request().isNavigationRequest()) documents.set(page, r.status());
|
||||
if (r.status() >= 400) {
|
||||
add({
|
||||
kind: "http",
|
||||
message: `HTTP ${r.status()}`,
|
||||
url: r.url(),
|
||||
method: r.request().method(),
|
||||
resourceType: r.request().resourceType(),
|
||||
pageUrl: page.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
async function capturePoint(page: Page, id: string, expectedErrors: string[] = []) {
|
||||
// 製品CSSやlayoutを変えず、確認対象の判断が見える位置へ移動する。
|
||||
if (id.includes("preview")) await page.locator(".diff").scrollIntoViewIfNeeded();
|
||||
else if (/saved|re-read|unknown|recovered|conflict/.test(id)) {
|
||||
await page.locator(".permissions > header").scrollIntoViewIfNeeded();
|
||||
}
|
||||
// overflow検査が失敗する場合も、先に画像を保存して根拠を残す。
|
||||
const image = join(output, `${id}.png`);
|
||||
const snapshot = join(output, `${id}.aria.txt`);
|
||||
await page.screenshot({ path: image });
|
||||
await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot());
|
||||
const mobileRemoveButtons = page.viewportSize()?.width === 390
|
||||
? await page.locator(".lane button").evaluateAll((buttons) =>
|
||||
buttons.map((button) => {
|
||||
const style = button.ownerDocument.defaultView!.getComputedStyle(button);
|
||||
const bounds = button.getBoundingClientRect();
|
||||
const lane = button.closest(".lane")!.getBoundingClientRect();
|
||||
const range = button.ownerDocument.createRange();
|
||||
range.selectNodeContents(button);
|
||||
const textBounds = range.getBoundingClientRect();
|
||||
return {
|
||||
label: button.getAttribute("aria-label"),
|
||||
text: button.textContent?.trim(),
|
||||
whiteSpace: style.whiteSpace,
|
||||
flexShrink: style.flexShrink,
|
||||
textLineBoxes: Array.from(range.getClientRects()).filter((rect) => rect.width > 0).length,
|
||||
textFitsButton: textBounds.left >=
|
||||
bounds.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) - 1 &&
|
||||
textBounds.right <=
|
||||
bounds.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) +
|
||||
1 &&
|
||||
textBounds.top >= bounds.top && textBounds.bottom <= bounds.bottom,
|
||||
buttonFitsLane: bounds.left >= lane.left - 1 && bounds.right <= lane.right + 1,
|
||||
noInternalClipping: button.scrollWidth <= button.clientWidth &&
|
||||
button.scrollHeight <= button.clientHeight,
|
||||
width: bounds.width,
|
||||
textWidth: textBounds.width,
|
||||
};
|
||||
})
|
||||
)
|
||||
: [];
|
||||
manifest.captures.push({
|
||||
id,
|
||||
route: new URL(page.url()).pathname,
|
||||
viewport: page.viewportSize(),
|
||||
theme: await page.evaluate(() =>
|
||||
(globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
),
|
||||
documentStatus: documents.get(page) ?? null,
|
||||
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||
permissionState: await page.locator("[data-permission-state]").getAttribute(
|
||||
"data-permission-state",
|
||||
),
|
||||
image,
|
||||
imageSha256: await sha256File(image),
|
||||
accessibilitySnapshot: snapshot,
|
||||
expectedErrors,
|
||||
// 再読込みで復旧の証拠を消さないよう、pageごとにerrorを累積する。
|
||||
errors: [...diagnostics.get(page) ?? []],
|
||||
errorSummary: {
|
||||
observed: errorCounts.get(page) ?? 0,
|
||||
retained: diagnostics.get(page)?.length ?? 0,
|
||||
truncated: (errorCounts.get(page) ?? 0) > 100,
|
||||
limit: 100,
|
||||
},
|
||||
mobileRemoveButtons,
|
||||
reviewed: false,
|
||||
});
|
||||
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
|
||||
for (const button of mobileRemoveButtons) {
|
||||
assertEquals(button.text, "Remove", `${id}: expected Remove control`);
|
||||
assertEquals(button.whiteSpace, "nowrap", `${id}: ${button.label} can wrap`);
|
||||
assertEquals(button.flexShrink, "0", `${id}: ${button.label} can shrink`);
|
||||
assertEquals(button.textLineBoxes, 1, `${id}: ${button.label} text breaks across lines`);
|
||||
assert(
|
||||
button.textFitsButton && button.buttonFitsLane && button.noInternalClipping,
|
||||
`${id}: ${button.label} clips or exceeds its lane`,
|
||||
);
|
||||
}
|
||||
await assertNoPageWideOverflow(page);
|
||||
}
|
||||
async function open(page: Page, base: string, id: string, state = "ready") {
|
||||
await page.goto(`${base}/w/memory-review/memory/${id}/permissions`);
|
||||
await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor();
|
||||
}
|
||||
async function add(
|
||||
page: Page,
|
||||
lane: "execution" | "delegation",
|
||||
operation: string,
|
||||
target: string,
|
||||
resourceCaptureId?: string,
|
||||
) {
|
||||
await page.getByRole("combobox", { name: "Permission scope", exact: true }).selectOption(lane);
|
||||
await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(operation);
|
||||
// 内部JSON入力ではなく、実UIの名称・説明・資源入力を操作する。
|
||||
await page.getByText(
|
||||
operation === "ticket.read"
|
||||
? "Read the selected ticket. Does not allow editing or closing it."
|
||||
: "Append a comment to the selected ticket; no completion or assignment authority.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await page.getByLabel("Ticket identifier", { exact: true }).fill(target);
|
||||
if (resourceCaptureId) {
|
||||
await page.getByLabel("Ticket identifier", { exact: true }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).evaluate((
|
||||
element,
|
||||
) => element === element.ownerDocument.activeElement),
|
||||
true,
|
||||
);
|
||||
await capturePoint(page, resourceCaptureId);
|
||||
}
|
||||
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).click();
|
||||
await page.locator("[data-permission-state=unsaved]").waitFor();
|
||||
}
|
||||
async function save(page: Page, state = "saved") {
|
||||
await page.getByLabel("I reviewed the operation, resource and delegation differences.").check();
|
||||
await page.getByRole("button", { name: "Save permissions", exact: true }).click();
|
||||
await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor();
|
||||
}
|
||||
try {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const prefix = `${theme}-${width}`;
|
||||
const port = await freePort();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const owned = await startOwnedProcesses(
|
||||
[{
|
||||
id: `t-802-${prefix}-synthetic-http`,
|
||||
command: Deno.execPath(),
|
||||
cwd: join(root, "tools/web-ux"),
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
|
||||
String(port),
|
||||
buildRoot,
|
||||
"3",
|
||||
"permissions",
|
||||
],
|
||||
readyUrl: `${base}/health`,
|
||||
}],
|
||||
join(output, "scenario.json"),
|
||||
join(output, `logs-${prefix}`),
|
||||
[],
|
||||
);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
observe(page);
|
||||
// 通常のSubject詳細から管理導線へ進む。
|
||||
await page.goto(`${base}/w/memory-review/memory/permissions-empty`);
|
||||
await page.locator(".subject-overview").waitFor();
|
||||
await page.locator('main a[href$="/permissions"]').click();
|
||||
await page.locator("[data-permission-state=ready]").waitFor();
|
||||
await page.getByText("Not configured · no permissions", { exact: true }).waitFor();
|
||||
await capturePoint(page, `${prefix}-empty`);
|
||||
await add(
|
||||
page,
|
||||
"execution",
|
||||
"ticket.read",
|
||||
"T-802",
|
||||
`${prefix}-operation-description-resource-input`,
|
||||
);
|
||||
await add(page, "delegation", "ticket.comment", "T-802");
|
||||
// 名前付きlaneで検査し、一つのgrantが両方へ黙って追加されないことを確認する。
|
||||
assertEquals(
|
||||
await page.locator('.lane[aria-label="Direct operations"]').getByText("Read ticket", {
|
||||
exact: true,
|
||||
}).count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator('.lane[aria-label="Direct operations"]').getByText(
|
||||
"Comment on ticket",
|
||||
{ exact: true },
|
||||
).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator('.lane[aria-label="Background Worker delegation ceiling"]').getByText(
|
||||
"Comment on ticket",
|
||||
{ exact: true },
|
||||
).count(),
|
||||
1,
|
||||
);
|
||||
await capturePoint(page, `${prefix}-preview-separate-lanes`);
|
||||
await save(page);
|
||||
await capturePoint(page, `${prefix}-saved`);
|
||||
await page.reload();
|
||||
await page.locator("[data-permission-state=ready]").waitFor();
|
||||
await capturePoint(page, `${prefix}-re-read`);
|
||||
|
||||
// 合成HTTP fixtureがPOSTを受け付けた後、実browserで応答を消失させる。
|
||||
await add(page, "execution", "ticket.comment", "T-803");
|
||||
let lost = false;
|
||||
await page.route("**/subjektiv/subjects/permissions-empty/permissions", async (route) => {
|
||||
if (route.request().method() === "POST" && !lost) {
|
||||
const accepted = await route.fetch();
|
||||
assertEquals(accepted.status(), 200);
|
||||
lost = true;
|
||||
await route.abort("failed");
|
||||
} else await route.continue();
|
||||
});
|
||||
await save(page, "unknown");
|
||||
assert(lost);
|
||||
// 障害注入は一度だけとし、撮影前に通常のbrowser通信へ戻す。
|
||||
await page.unroute("**/subjektiv/subjects/permissions-empty/permissions");
|
||||
await capturePoint(page, `${prefix}-unknown`, [
|
||||
"Intentional POST response loss after synthetic save",
|
||||
]);
|
||||
await page.reload();
|
||||
await page.locator("[data-permission-state=unknown][aria-busy=false]").waitFor();
|
||||
await page.getByRole("button", { name: "Reconcile the same save", exact: true }).click();
|
||||
await page.locator("[data-permission-state=saved]").waitFor();
|
||||
const evidence = await (await fetch(`${base}/fixture/permissions/evidence`)).json();
|
||||
const saves = evidence.requests.filter((r: { subject_id: string }) =>
|
||||
r.subject_id === "permissions-empty"
|
||||
);
|
||||
assertEquals(saves.length, 3);
|
||||
assertEquals(saves[1].request, saves[2].request);
|
||||
assertEquals(saves[2].outcome, "replay");
|
||||
await capturePoint(page, `${prefix}-recovered-same-operation`, [
|
||||
"Intentional earlier POST response loss",
|
||||
]);
|
||||
|
||||
// 二つのbrowser画面で同じ合成change identityを観測してから保存する。
|
||||
const other = await context.newPage();
|
||||
observe(other);
|
||||
await open(page, base, "permissions-edit");
|
||||
await open(other, base, "permissions-edit");
|
||||
await add(page, "delegation", "ticket.comment", "T-802");
|
||||
await other.getByRole("button", {
|
||||
name: "Remove Read ticket from direct operations",
|
||||
exact: true,
|
||||
}).click();
|
||||
await save(page);
|
||||
await save(other, "error");
|
||||
await other.getByRole("alert").filter({ hasText: "Conflict:" }).waitFor();
|
||||
await capturePoint(other, `${prefix}-two-page-conflict`, [
|
||||
"Intentional stale policy POST returns HTTP 409",
|
||||
]);
|
||||
await other.close();
|
||||
await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(
|
||||
"worker.messaging",
|
||||
);
|
||||
await page.getByText("Target unavailable:", { exact: false }).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await capturePoint(page, `${prefix}-unavailable-operation`);
|
||||
await open(page, base, "permissions-reader");
|
||||
assertEquals(await page.locator("form.permission-form").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Save permissions", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await capturePoint(page, `${prefix}-read-only`);
|
||||
await open(page, base, "permissions-unavailable", "error");
|
||||
await capturePoint(page, `${prefix}-service-unavailable`, [
|
||||
"Intentional permission GET returns HTTP 503",
|
||||
]);
|
||||
await open(page, base, "permissions-legacy");
|
||||
await page.getByText(
|
||||
"Preserved, not editable here. No conversion or expansion is implied.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await add(page, "execution", "ticket.comment", "T-802");
|
||||
await save(page);
|
||||
const legacy = await (await fetch(
|
||||
`${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`,
|
||||
)).json();
|
||||
assertEquals(legacy.permissions.delegation, [{
|
||||
operation: "worker.notify",
|
||||
resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" },
|
||||
}]);
|
||||
await capturePoint(page, `${prefix}-legacy-preserved`, [
|
||||
"Intentional earlier service unavailable GET",
|
||||
]);
|
||||
// 直接操作の失効を確認する。ここで証明するのはUIの入力であり、実取消ではない。
|
||||
await page.getByRole("button", {
|
||||
name: "Remove Read ticket from direct operations",
|
||||
exact: true,
|
||||
}).click();
|
||||
await save(page);
|
||||
await capturePoint(page, `${prefix}-revoked-saved`);
|
||||
const finalAudit = await (await fetch(`${base}/fixture/permissions/evidence`)).json();
|
||||
const finalLegacy = await (await fetch(
|
||||
`${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`,
|
||||
)).json();
|
||||
assertEquals(finalAudit.requests.length, 7);
|
||||
assertEquals(
|
||||
finalAudit.requests.filter((r: { outcome: string }) => r.outcome === "conflict").length,
|
||||
1,
|
||||
);
|
||||
assertEquals(finalLegacy.permissions.execution, [{
|
||||
operation: "ticket.comment",
|
||||
resource: { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" },
|
||||
}]);
|
||||
assertEquals(finalLegacy.permissions.delegation, legacy.permissions.delegation);
|
||||
await Deno.writeTextFile(
|
||||
join(output, `${prefix}-synthetic-http-evidence.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
authority: "synthetic-http-not-real-backend",
|
||||
replayAssertions: {
|
||||
originalAndRetryInputsEqual: true,
|
||||
retryOutcome: saves[2].outcome,
|
||||
},
|
||||
legacyDelegationPreserved: legacy.permissions.delegation,
|
||||
requests: finalAudit.requests,
|
||||
currentLegacyPolicyAfterShrink: finalLegacy,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
manifest.failures.push(
|
||||
`${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`,
|
||||
);
|
||||
for (const [index, failedPage] of context.pages().entries()) {
|
||||
const image = join(output, `${prefix}-failure-${index}.png`);
|
||||
const snapshot = join(output, `${prefix}-failure-${index}.aria.txt`);
|
||||
await failedPage.screenshot({ path: image, timeout: 5_000 }).catch(() => {});
|
||||
await Deno.writeTextFile(snapshot, await failedPage.locator("body").ariaSnapshot());
|
||||
manifest.captures.push({
|
||||
id: `${prefix}-failure-${index}`,
|
||||
route: failedPage.url(),
|
||||
documentStatus: documents.get(failedPage) ?? null,
|
||||
image,
|
||||
accessibilitySnapshot: snapshot,
|
||||
errors: diagnostics.get(failedPage) ?? [],
|
||||
reviewed: false,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await context.close();
|
||||
await stopOwnedProcesses(owned);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
await sha256File(join(buildRoot, "index.html")),
|
||||
manifest.buildIndexSha256,
|
||||
"Web build changed during the browser matrix",
|
||||
);
|
||||
manifest.status = "browser-assertions-passed-visual-unreviewed";
|
||||
} catch (e) {
|
||||
manifest.status = "failed";
|
||||
throw e;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
|
||||
console.log(join(output, "review-context.json"));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "subject-permissions-dark",
|
||||
"title": "T-802 actual Web / synthetic HTTP NOT real Backend — dark",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic HTTP projection, NOT real Backend authentication or authorization",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "permissions-empty",
|
||||
"label": "Unset permissions",
|
||||
"path": "/w/memory-review/memory/permissions-empty/permissions",
|
||||
"goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unset permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-edit",
|
||||
"label": "Configured permissions",
|
||||
"path": "/w/memory-review/memory/permissions-edit/permissions",
|
||||
"goal": "Read exact operation and target before editing.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Configured permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-legacy",
|
||||
"label": "Preserved unsupported permission",
|
||||
"path": "/w/memory-review/memory/permissions-legacy/permissions",
|
||||
"goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Preserved unsupported permission"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-reader",
|
||||
"label": "Read-only permissions",
|
||||
"path": "/w/memory-review/memory/permissions-reader/permissions",
|
||||
"goal": "Read policy without unauthorized editing controls.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Read-only permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-unavailable",
|
||||
"label": "Unavailable permission service",
|
||||
"path": "/w/memory-review/memory/permissions-unavailable/permissions",
|
||||
"goal": "Recover without success claims.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=error][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unavailable permission service"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "subject-permissions-synthetic-http",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"-A",
|
||||
"browser-tests/memory_document_fixture_server.ts",
|
||||
"15289",
|
||||
"${WEB_UX_BUILD_ROOT}",
|
||||
"3",
|
||||
"permissions"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "subject-permissions-light",
|
||||
"title": "T-802 actual Web / synthetic HTTP NOT real Backend — light",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic HTTP projection, NOT real Backend authentication or authorization",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "permissions-empty",
|
||||
"label": "Unset permissions",
|
||||
"path": "/w/memory-review/memory/permissions-empty/permissions",
|
||||
"goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unset permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-edit",
|
||||
"label": "Configured permissions",
|
||||
"path": "/w/memory-review/memory/permissions-edit/permissions",
|
||||
"goal": "Read exact operation and target before editing.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Configured permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-legacy",
|
||||
"label": "Preserved unsupported permission",
|
||||
"path": "/w/memory-review/memory/permissions-legacy/permissions",
|
||||
"goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Preserved unsupported permission"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-reader",
|
||||
"label": "Read-only permissions",
|
||||
"path": "/w/memory-review/memory/permissions-reader/permissions",
|
||||
"goal": "Read policy without unauthorized editing controls.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=ready][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Read-only permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-unavailable",
|
||||
"label": "Unavailable permission service",
|
||||
"path": "/w/memory-review/memory/permissions-unavailable/permissions",
|
||||
"goal": "Recover without success claims.",
|
||||
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-permission-state=error][aria-busy=false]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unavailable permission service"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "subject-permissions-synthetic-http",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"-A",
|
||||
"browser-tests/memory_document_fixture_server.ts",
|
||||
"15289",
|
||||
"${WEB_UX_BUILD_ROOT}",
|
||||
"3",
|
||||
"permissions"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import {
|
||||
createSubjectPermissionsFixture,
|
||||
fixtureLegacyPermission,
|
||||
type FixturePolicy,
|
||||
} from "../browser-tests/subject_permissions_fixture.ts";
|
||||
|
||||
const root = "http://127.0.0.1:12345/api/w/memory-review/subjektiv/subjects/";
|
||||
const target = { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" };
|
||||
const direct = { operation: "ticket.read", resource: target };
|
||||
const delegated = { operation: "ticket.comment", resource: target };
|
||||
function request(id: string, body?: unknown): Request {
|
||||
return new Request(
|
||||
`${root}${id}/permissions`,
|
||||
body === undefined ? {} : {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("synthetic browser admission projection is explicitly labeled, with no real credentials", async () => {
|
||||
const handle = createSubjectPermissionsFixture();
|
||||
const response = await handle(new Request("http://127.0.0.1:12345/api/auth/whoami"));
|
||||
assert(response);
|
||||
assertEquals(response.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend");
|
||||
assertEquals(response.headers.get("set-cookie"), null);
|
||||
const view = await response.json();
|
||||
assertEquals(view.authentication_reference, "synthetic-http-not-real-authentication");
|
||||
assertEquals(view.session.kind, "authenticated");
|
||||
assertEquals(view.actor.auth_method, "browser_session");
|
||||
});
|
||||
|
||||
Deno.test("synthetic UX fixture: unset, separate lanes, exact replay and two-editor CAS", async () => {
|
||||
const handle = createSubjectPermissionsFixture();
|
||||
const initial = await handle(request("permissions-empty"));
|
||||
assert(initial);
|
||||
assertEquals(initial.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend");
|
||||
assertEquals((await initial.json()).permissions, null);
|
||||
const update = {
|
||||
operation_id: "browser-save-1",
|
||||
expected_change_id: null,
|
||||
execution: [direct],
|
||||
delegation: [delegated],
|
||||
};
|
||||
const save = await handle(request("permissions-empty", update));
|
||||
assert(save);
|
||||
assertEquals(save.status, 200);
|
||||
const saved = await save.json();
|
||||
assertEquals(saved.permissions.execution, [direct]);
|
||||
assertEquals(saved.permissions.delegation, [delegated]);
|
||||
const replay = await handle(request("permissions-empty", update));
|
||||
assert(replay);
|
||||
assertEquals(await replay.json(), saved);
|
||||
const collision = await handle(request("permissions-empty", { ...update, execution: [] }));
|
||||
assertEquals(collision?.status, 409);
|
||||
const stale = await handle(
|
||||
request("permissions-empty", { ...update, operation_id: "second-tab" }),
|
||||
);
|
||||
assertEquals(stale?.status, 409);
|
||||
const readback = await handle(request("permissions-empty"));
|
||||
assertEquals((await readback!.json()).permissions, saved.permissions);
|
||||
});
|
||||
|
||||
Deno.test("synthetic UX fixture: read-only, unavailable, wrong workspace and unknown targets", async () => {
|
||||
const handle = createSubjectPermissionsFixture();
|
||||
const view = await handle(request("permissions-reader"));
|
||||
assertEquals((await view!.json()).can_manage, false);
|
||||
assertEquals((await handle(request("permissions-reader", {})))?.status, 403);
|
||||
assertEquals((await handle(request("permissions-unavailable")))?.status, 503);
|
||||
assertEquals((await handle(request("missing")))?.status, 404);
|
||||
assertEquals(
|
||||
(await handle(
|
||||
new Request(`${root.replace("memory-review", "other")}permissions-edit/permissions`),
|
||||
))?.status,
|
||||
404,
|
||||
);
|
||||
assertEquals(await handle(new Request("http://127.0.0.1:12345/not-owned")), null);
|
||||
});
|
||||
|
||||
Deno.test("synthetic UX fixture preserves old Worker target without making T-803 usable", async () => {
|
||||
const handle = createSubjectPermissionsFixture();
|
||||
const view = await handle(request("permissions-legacy"));
|
||||
const initial = await view!.json();
|
||||
assertEquals(initial.permissions.delegation, [fixtureLegacyPermission]);
|
||||
const workerOperation = initial.operations.find((o: { operation: string }) =>
|
||||
o.operation === "worker.messaging"
|
||||
);
|
||||
assertEquals(workerOperation.availability.kind, "unavailable");
|
||||
assertEquals(workerOperation.resource_kinds, []);
|
||||
assertEquals(Object.hasOwn(workerOperation, "available"), false);
|
||||
assertEquals(Object.hasOwn(workerOperation, "unavailable_reason"), false);
|
||||
assertEquals(
|
||||
initial.operations.find((o: { operation: string }) => o.operation === "ticket.read")
|
||||
.availability,
|
||||
{ kind: "available" },
|
||||
);
|
||||
const update = {
|
||||
operation_id: "preserve-legacy",
|
||||
expected_change_id: initial.permissions.change_id,
|
||||
execution: [direct, delegated],
|
||||
delegation: initial.permissions.delegation,
|
||||
};
|
||||
const saved = await handle(request("permissions-legacy", update));
|
||||
assertEquals(saved?.status, 200);
|
||||
const value = await saved!.json();
|
||||
assertEquals(value.permissions.delegation, [fixtureLegacyPermission]);
|
||||
const policy = value.permissions as FixturePolicy;
|
||||
for (
|
||||
const invalid of [
|
||||
{ operation: "future.all", resource: target },
|
||||
{ operation: "ticket.read", resource: { kind: "workspace", workspace_id: "memory-review" } },
|
||||
{
|
||||
operation: "worker.messaging",
|
||||
resource: { kind: "workspace", workspace_id: "memory-review" },
|
||||
},
|
||||
]
|
||||
) {
|
||||
const rejected = await handle(request("permissions-legacy", {
|
||||
...update,
|
||||
operation_id: `reject-${invalid.operation}`,
|
||||
expected_change_id: policy.change_id,
|
||||
execution: [invalid],
|
||||
}));
|
||||
assertEquals(rejected?.status, 400);
|
||||
}
|
||||
assertEquals((await (await handle(request("permissions-legacy")))!.json()).permissions, policy);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -15,250 +15,93 @@ export const SUBJEKTIV_API_LIMITS = {
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
|
||||
export type MemoryDocumentResponse = {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
};
|
||||
export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, };
|
||||
|
||||
export type MemoryCandidateKind =
|
||||
| "preference"
|
||||
| "working_assumption"
|
||||
| "constraint"
|
||||
| "decision"
|
||||
| "open_question"
|
||||
| "lesson";
|
||||
export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson";
|
||||
|
||||
export type MemoryEvidenceOriginKind =
|
||||
| "human_input"
|
||||
| "worker_input"
|
||||
| "flow_instruction"
|
||||
| "backend_instruction"
|
||||
| "model_output"
|
||||
| "tool_output"
|
||||
| "derived_summary"
|
||||
| "legacy_unknown";
|
||||
export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
|
||||
|
||||
export type MemoryEvidenceOrigin = {
|
||||
kind: MemoryEvidenceOriginKind;
|
||||
account_id?: string | null;
|
||||
workspace_id?: string | null;
|
||||
runtime_id?: string | null;
|
||||
worker_id?: string | null;
|
||||
flow_selector?: string | null;
|
||||
flow_definition_id?: string | null;
|
||||
flow_definition_fingerprint?: number | null;
|
||||
};
|
||||
export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_fingerprint?: number | null, };
|
||||
|
||||
export type MemorySourceRef = { segment_id: string; range: [number, number] };
|
||||
export type MemorySourceRef = { segment_id: string, range: [number, number], };
|
||||
|
||||
export type MemoryStagingEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, };
|
||||
|
||||
export type MemorySourceEvidenceRef = {
|
||||
session_id: string | null;
|
||||
segment_id: string | null;
|
||||
entry_range: [number, number] | null;
|
||||
evidence_id: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind: string | null;
|
||||
label: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, };
|
||||
|
||||
export type MemoryStagingRecord = {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
extract_run_id: string;
|
||||
source: MemorySourceRef;
|
||||
kind: MemoryCandidateKind;
|
||||
claim: string;
|
||||
why_useful: string;
|
||||
staleness: string | null;
|
||||
evidence: Array<MemoryStagingEvidence>;
|
||||
source_refs: Array<MemorySourceEvidenceRef>;
|
||||
};
|
||||
export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array<MemoryStagingEvidence>, source_refs: Array<MemorySourceEvidenceRef>, };
|
||||
|
||||
export type MemoryStagingEntry = {
|
||||
id: string;
|
||||
byte_len: number;
|
||||
record: MemoryStagingRecord;
|
||||
};
|
||||
export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, };
|
||||
|
||||
export type MemoryStagingListResponse = {
|
||||
limit: number;
|
||||
returned_count: number;
|
||||
total_valid_count: number;
|
||||
invalid_count: number;
|
||||
truncated: boolean;
|
||||
order: string;
|
||||
record_authority: string;
|
||||
items: Array<MemoryStagingEntry>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array<MemoryStagingEntry>, 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_operation", workspace_id: string, } | { "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" | "worker_operation";
|
||||
|
||||
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 SubjektivSubjectCreateRequest = {
|
||||
role: string;
|
||||
behavior_md?: string;
|
||||
};
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectDeleteRequest = { operation_id: string };
|
||||
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
||||
|
||||
export type SubjektivSubjectDeleteResponse = {
|
||||
subject_id: string;
|
||||
operation_id: string;
|
||||
completed: boolean;
|
||||
};
|
||||
export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id: string, completed: boolean, };
|
||||
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = {
|
||||
expected_behavior_md: string;
|
||||
behavior_md: string;
|
||||
};
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = {
|
||||
id: string;
|
||||
role: string;
|
||||
behavior_md: string;
|
||||
state: SubjektivSubjectState;
|
||||
memory_fingerprint: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
current_worker?:
|
||||
| import("./worker-launch-api").WorkerLaunchWorkerSummary
|
||||
| null;
|
||||
};
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = {
|
||||
limit: number;
|
||||
items: Array<SubjektivSubjectResponse>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||
|
||||
export type SubjektivMemoryChangeRef = { memory_id: string; change_id: string };
|
||||
export type SubjektivMemoryChangeRef = { memory_id: string, change_id: string, };
|
||||
|
||||
export type SubjektivResidentSurfaceAvailability =
|
||||
| "ungenerated"
|
||||
| "stale"
|
||||
| "failed"
|
||||
| "ready";
|
||||
export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready";
|
||||
|
||||
export type SubjektivResidentSurfaceSnapshot = {
|
||||
snapshot_id: string;
|
||||
body_md: string;
|
||||
memory_refs: Array<SubjektivMemoryChangeRef>;
|
||||
built_from_memory_fingerprint: string;
|
||||
created_at: string;
|
||||
};
|
||||
export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array<SubjektivMemoryChangeRef>, built_from_memory_fingerprint: string, created_at: string, };
|
||||
|
||||
export type SubjektivResidentSurfaceResponse = {
|
||||
subject_id: string;
|
||||
availability: SubjektivResidentSurfaceAvailability;
|
||||
snapshot?: SubjektivResidentSurfaceSnapshot | null;
|
||||
};
|
||||
export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, };
|
||||
|
||||
export type SubjektivMemoryQueryItem = {
|
||||
id: string;
|
||||
change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
excerpt: string;
|
||||
updated_at: string;
|
||||
};
|
||||
export type SubjektivMemoryQueryItem = { id: string, change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryQueryResponse = {
|
||||
items: Array<SubjektivMemoryQueryItem>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
export type SubjektivMemoryQueryResponse = { items: Array<SubjektivMemoryQueryItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range?: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt?: string | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, };
|
||||
|
||||
export type SubjektivMemorySourceEvidenceRef = {
|
||||
session_id?: string | null;
|
||||
segment_id?: string | null;
|
||||
entry_range?: [number, number] | null;
|
||||
evidence_id?: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind?: string | null;
|
||||
label?: string | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, };
|
||||
|
||||
export type SubjektivMemoryEvidenceCandidate = {
|
||||
candidate_id: string;
|
||||
evidence: Array<SubjektivMemoryEvidence>;
|
||||
evidence_total: number;
|
||||
evidence_truncated: boolean;
|
||||
source_refs: Array<SubjektivMemorySourceEvidenceRef>;
|
||||
source_refs_total: number;
|
||||
source_refs_truncated: boolean;
|
||||
};
|
||||
export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array<SubjektivMemoryEvidence>, evidence_total: number, evidence_truncated: boolean, source_refs: Array<SubjektivMemorySourceEvidenceRef>, source_refs_total: number, source_refs_truncated: boolean, };
|
||||
|
||||
export type SubjektivMemoryReadResponse = {
|
||||
memory_id: string;
|
||||
change_id: string;
|
||||
current_change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
body_md: string;
|
||||
why_useful: string;
|
||||
staleness?: string | null;
|
||||
change_reason: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
body_offset: number;
|
||||
body_byte_offset: number;
|
||||
body_next_offset?: number | null;
|
||||
body_next_byte_offset?: number | null;
|
||||
body_truncated: boolean;
|
||||
source_candidate_ids: Array<string>;
|
||||
source_candidates: Array<SubjektivMemoryEvidenceCandidate>;
|
||||
derived_from: Array<SubjektivMemoryChangeRef>;
|
||||
evidence_next_cursor?: string | null;
|
||||
evidence_has_more: boolean;
|
||||
};
|
||||
export type SubjektivMemoryReadResponse = { memory_id: string, change_id: string, current_change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array<string>, source_candidates: Array<SubjektivMemoryEvidenceCandidate>, derived_from: Array<SubjektivMemoryChangeRef>, evidence_next_cursor?: string | null, evidence_has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryChangeItem = {
|
||||
change_id: string;
|
||||
kind: MemoryCandidateKind;
|
||||
state: SubjektivMemoryState;
|
||||
claim: string;
|
||||
change_reason: string;
|
||||
updated_at: string;
|
||||
};
|
||||
export type SubjektivMemoryChangeItem = { change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryListChangesResponse = {
|
||||
memory_id: string;
|
||||
current_change_id: string;
|
||||
items: Array<SubjektivMemoryChangeItem>;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
export type SubjektivMemoryListChangesResponse = { memory_id: string, current_change_id: string, items: Array<SubjektivMemoryChangeItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import SubjectPermissions from "./SubjectPermissions.svelte";
|
||||
import type { Policy, PolicyUpdate } from "./permissions.ts";
|
||||
const ops = [
|
||||
{
|
||||
operation: "ticket.create",
|
||||
label: "Create Tickets",
|
||||
description: "Create an open Ticket in this Workspace.",
|
||||
resource_kinds: ["workspace"],
|
||||
availability: { kind: "available" },
|
||||
},
|
||||
{
|
||||
operation: "filesystem.read",
|
||||
label: "Read files",
|
||||
description: "Read only the selected Workdir path.",
|
||||
resource_kinds: ["workdir"],
|
||||
availability: { kind: "available" },
|
||||
},
|
||||
{
|
||||
operation: "filesystem.write",
|
||||
label: "Edit files",
|
||||
description: "Write only the selected Workdir path.",
|
||||
resource_kinds: ["workdir"],
|
||||
availability: { kind: "available" },
|
||||
},
|
||||
{
|
||||
operation: "worker.messaging",
|
||||
label: "Message Worker",
|
||||
description: "Separate operation use and directional target control.",
|
||||
resource_kinds: ["workspace"],
|
||||
availability: { kind: "unavailable", reason: "T-803 connection pending" },
|
||||
},
|
||||
];
|
||||
const props = { workspaceId: "w", subjectId: "s" };
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
function harness(initial: Policy | null = null, canManage = true) {
|
||||
let policy = initial;
|
||||
const requests: PolicyUpdate[] = [];
|
||||
const fetch = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
const request = JSON.parse(init.body as string) as PolicyUpdate;
|
||||
requests.push(request);
|
||||
policy = {
|
||||
change_id: request.operation_id,
|
||||
execution: request.execution,
|
||||
delegation: request.delegation,
|
||||
};
|
||||
return Response.json({ permissions: policy, interruptions: [] });
|
||||
}
|
||||
return Response.json({
|
||||
permissions: policy,
|
||||
can_manage: canManage,
|
||||
operations: ops,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return { fetch, requests, setPolicy: (next: Policy | null) => policy = next };
|
||||
}
|
||||
async function choose(name = "ticket.create") {
|
||||
await screen.findByRole("combobox", { name: "Operation" });
|
||||
await fireEvent.change(screen.getByRole("combobox", { name: "Operation" }), {
|
||||
target: { value: name },
|
||||
});
|
||||
}
|
||||
async function add(which = "execution", name = "ticket.create") {
|
||||
await choose(name);
|
||||
await fireEvent.change(
|
||||
screen.getByRole("combobox", { name: "Permission scope" }),
|
||||
{ target: { value: which } },
|
||||
);
|
||||
if (name.startsWith("filesystem")) {
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Workdir identifier" }),
|
||||
{ target: { value: "wd-only" } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Relative path" }),
|
||||
{ target: { value: "src" } },
|
||||
);
|
||||
}
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add to unsaved changes" }),
|
||||
);
|
||||
}
|
||||
async function confirmSave() {
|
||||
await fireEvent.click(screen.getByRole("checkbox", { name: /I reviewed/ }));
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save permissions" }),
|
||||
);
|
||||
}
|
||||
test("new Subject selects direct and narrow delegation independently, previews exact diff and saves through existing endpoint", async () => {
|
||||
const h = harness();
|
||||
render(SubjectPermissions, props);
|
||||
await add();
|
||||
await add("delegation", "filesystem.write");
|
||||
expect(h.requests).toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Unsaved permission differences" })
|
||||
.textContent,
|
||||
).toContain("Edit files");
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Save permissions",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await confirmSave();
|
||||
await screen.findByText("Saved and re-read from the current policy.");
|
||||
expect(h.requests).toHaveLength(1);
|
||||
expect(h.requests[0].expected_change_id).toBe(null);
|
||||
expect(h.requests[0].execution[0].operation).toBe("ticket.create");
|
||||
expect(h.requests[0].delegation[0]).toEqual({
|
||||
operation: "filesystem.write",
|
||||
resource: {
|
||||
kind: "workdir",
|
||||
working_directory_id: "wd-only",
|
||||
path: "src",
|
||||
recursive: false,
|
||||
},
|
||||
});
|
||||
expect(h.fetch.mock.calls[1][0]).toBe(
|
||||
"/api/w/w/subjektiv/subjects/s/permissions",
|
||||
);
|
||||
});
|
||||
test("unsupported legacy target is retained verbatim while an unrelated operation is added", async () => {
|
||||
const legacy = {
|
||||
operation: "worker.notify",
|
||||
resource: {
|
||||
kind: "worker" as const,
|
||||
runtime_id: "r",
|
||||
worker_id: "only-target",
|
||||
},
|
||||
};
|
||||
const h = harness({ change_id: "old", execution: [], delegation: [legacy] });
|
||||
render(SubjectPermissions, props);
|
||||
await add();
|
||||
await confirmSave();
|
||||
await screen.findByText("Saved and re-read from the current policy.");
|
||||
expect(h.requests[0].delegation).toEqual([legacy]);
|
||||
expect(screen.getByText(/Preserved, not editable/)).toBeTruthy();
|
||||
});
|
||||
test("unimplemented Worker operation has reason, no Worker-ID fields and cannot be added", async () => {
|
||||
harness();
|
||||
render(SubjectPermissions, props);
|
||||
await choose("worker.messaging");
|
||||
expect(screen.getByText(/Target unavailable: T-803/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Add to unsaved changes" }))
|
||||
.toBeNull();
|
||||
expect(screen.queryByRole("textbox", { name: /Worker identifier/ }))
|
||||
.toBeNull();
|
||||
});
|
||||
test("read-only projection retains current permission visibility without save or edit actions", async () => {
|
||||
harness({
|
||||
change_id: "p",
|
||||
execution: [{
|
||||
operation: "ticket.create",
|
||||
resource: { kind: "workspace", workspace_id: "w" },
|
||||
}],
|
||||
delegation: [],
|
||||
}, false);
|
||||
render(SubjectPermissions, props);
|
||||
await screen.findByText(/Read-only. The Backend/);
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Remove/ })).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("region", { name: "Direct operations" })).getByText(
|
||||
"Create Tickets",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
test("conflict retains unsaved diff and blocks editing until explicit reload, never rebases CAS silently", async () => {
|
||||
const h = harness();
|
||||
h.fetch.mockImplementation(async (_path, init) =>
|
||||
init?.method === "POST"
|
||||
? new Response(null, { status: 409 })
|
||||
: Response.json({ permissions: null, can_manage: true, operations: ops })
|
||||
);
|
||||
render(SubjectPermissions, props);
|
||||
await add();
|
||||
await confirmSave();
|
||||
await screen.findByText(/Conflict: another save/);
|
||||
expect(screen.getByRole("region", { name: "Unsaved permission differences" }))
|
||||
.toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Save permissions" })).toBeNull();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
test("lost response survives remount and exact replay confirms receipt without reviving later policy", async () => {
|
||||
const h = harness();
|
||||
let saved: PolicyUpdate | null = null;
|
||||
const later: Policy = {
|
||||
change_id: "later-shrink",
|
||||
execution: [],
|
||||
delegation: [],
|
||||
};
|
||||
let lost = false;
|
||||
h.fetch.mockImplementation(async (_path, init) => {
|
||||
if (init?.method === "POST") {
|
||||
const request = JSON.parse(init.body as string) as PolicyUpdate;
|
||||
if (!lost) {
|
||||
saved = request;
|
||||
lost = true;
|
||||
throw new Error("lost after commit");
|
||||
}
|
||||
expect(request).toEqual(saved);
|
||||
return Response.json({
|
||||
permissions: {
|
||||
change_id: request.operation_id,
|
||||
execution: request.execution,
|
||||
delegation: request.delegation,
|
||||
},
|
||||
interruptions: [],
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
permissions: lost ? later : null,
|
||||
can_manage: true,
|
||||
operations: ops,
|
||||
});
|
||||
});
|
||||
const first = render(SubjectPermissions, props);
|
||||
await add();
|
||||
await confirmSave();
|
||||
await screen.findByText("Save outcome not yet confirmed.");
|
||||
first.unmount();
|
||||
render(SubjectPermissions, props);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Reconcile the same save",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Reconcile the same save" }),
|
||||
);
|
||||
await screen.findByText(/original save was confirmed, but a later change/);
|
||||
expect(
|
||||
within(screen.getByRole("region", { name: "Direct operations" }))
|
||||
.queryByText("Create Tickets"),
|
||||
).toBeNull();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
test("deleted Subject is refused with no success indication", async () => {
|
||||
const h = harness();
|
||||
h.fetch.mockImplementation(async () => new Response(null, { status: 404 }));
|
||||
render(SubjectPermissions, props);
|
||||
await screen.findByText(
|
||||
/Target unavailable. This Subject may have been deleted/,
|
||||
);
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(h.requests).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { attemptKey, fieldLabel, makeResource, PermissionHttpError, permissionKey, policyDiff, readAttempt, readPermissions, resourceFields, resourceLabel, writePermissions } from './permissions.ts';
|
||||
import type { Interruption, Lane, PermissionResource, PermissionView, PolicyUpdate, ScopedPermission } from './permissions.ts';
|
||||
let { workspaceId, subjectId }: { workspaceId: string; subjectId: string } = $props();
|
||||
let view = $state<PermissionView | null>(null);
|
||||
let execution = $state<ScopedPermission[]>([]);
|
||||
let delegation = $state<ScopedPermission[]>([]);
|
||||
let pending = $state<PolicyUpdate | null>(null);
|
||||
let storageBlocked = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
let confirming = $state(false);
|
||||
let interruptions = $state<Interruption[]>([]);
|
||||
let lane = $state<Lane>('execution');
|
||||
let operationName = $state('');
|
||||
let resourceKind = $state<PermissionResource['kind']>('workspace');
|
||||
let inputs = $state<Record<string, string>>({});
|
||||
let recursive = $state(false);
|
||||
const selected = $derived(view?.operations.find((o) => o.operation === operationName));
|
||||
const changes = $derived({ execution: policyDiff(view?.permissions?.execution ?? [], execution), delegation: policyDiff(view?.permissions?.delegation ?? [], delegation) });
|
||||
const dirty = $derived(Object.values(changes).some((d) => d.added.length || d.removed.length));
|
||||
const editable = $derived(view?.can_manage && !busy && !pending && !storageBlocked);
|
||||
const label = (p: ScopedPermission) => view?.operations.find((o) => o.operation === p.operation)?.label ?? 'Preserved unsupported permission';
|
||||
const supported = (p: ScopedPermission) => view?.operations.some((o) => o.operation === p.operation && o.available && o.resource_kinds.includes(p.resource.kind));
|
||||
function accept(v: PermissionView) {
|
||||
view = v;
|
||||
execution = structuredClone(v.permissions?.execution ?? []);
|
||||
delegation = structuredClone(v.permissions?.delegation ?? []);
|
||||
confirming = false;
|
||||
}
|
||||
function describe(error: unknown) {
|
||||
if (error instanceof PermissionHttpError) {
|
||||
if (error.status === 403 || error.status === 401) return 'Read-only / permission unavailable. This account cannot manage this Subject.';
|
||||
if (error.status === 404) return 'Target unavailable. This Subject may have been deleted; an old screen cannot restore it.';
|
||||
if (error.status === 409) return 'Conflict: another save changed the policy. Your edits were not applied. Reload current permissions and review again.';
|
||||
if (error.status === 400) return 'The operation or resource was rejected. Check the selected target; no new permissions were saved.';
|
||||
}
|
||||
return 'The request could not be confirmed. Do not assume success or create another save.';
|
||||
}
|
||||
async function reload() {
|
||||
if (busy) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try { accept(await readPermissions(fetch, workspaceId, subjectId)); }
|
||||
catch (e) { error = describe(e); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
try { pending = readAttempt(sessionStorage, workspaceId, subjectId); }
|
||||
catch { storageBlocked = true; error = 'The saved request cannot be read. Saving is blocked; do not start another request.'; }
|
||||
if (!storageBlocked) void reload();
|
||||
});
|
||||
function chooseOperation() {
|
||||
resourceKind = selected?.resource_kinds[0] ?? 'workspace'; inputs = {}; recursive = false; confirming = false;
|
||||
}
|
||||
function add() {
|
||||
if (!editable || !selected?.available || !selected.resource_kinds.includes(resourceKind)) return;
|
||||
try {
|
||||
const p = { operation: selected.operation, resource: makeResource(resourceKind, workspaceId, subjectId, inputs, recursive) };
|
||||
const current = lane === 'execution' ? execution : delegation;
|
||||
if (!current.some((x) => permissionKey(x) === permissionKey(p))) {
|
||||
if (lane === 'execution') execution = [...execution, p]; else delegation = [...delegation, p];
|
||||
}
|
||||
confirming = false; error = ''; notice = '';
|
||||
} catch (e) { error = e instanceof Error ? e.message : 'Invalid resource'; }
|
||||
}
|
||||
function remove(which: Lane, index: number) {
|
||||
if (!editable) return;
|
||||
if (which === 'execution') execution = execution.filter((_, i) => i !== index);
|
||||
else delegation = delegation.filter((_, i) => i !== index);
|
||||
confirming = false; notice = '';
|
||||
}
|
||||
async function save(request?: PolicyUpdate) {
|
||||
if (busy || storageBlocked || !view?.can_manage) return;
|
||||
const attempt = request ?? { operation_id: crypto.randomUUID(), expected_change_id: view.permissions?.change_id ?? null, execution: $state.snapshot(execution), delegation: $state.snapshot(delegation) };
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
// 永続入力を保持できなければPOSTしない。再接続で新しいoperationを作らない。
|
||||
sessionStorage.setItem(attemptKey(workspaceId, subjectId), JSON.stringify(attempt));
|
||||
pending = attempt;
|
||||
} catch { error = 'Cannot retain the save request in this tab. Nothing was sent.'; busy = false; return; }
|
||||
let receiptAccepted = false;
|
||||
try {
|
||||
const receipt = await writePermissions(fetch, workspaceId, subjectId, attempt);
|
||||
receiptAccepted = true;
|
||||
const current = await readPermissions(fetch, workspaceId, subjectId);
|
||||
accept(current); interruptions = receipt.interruptions;
|
||||
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
|
||||
notice = current.permissions?.change_id === receipt.permissions.change_id
|
||||
? 'Saved and re-read from the current policy.'
|
||||
: 'The original save was confirmed, but a later change is now current. The current policy is shown; the receipt did not restore older permissions.';
|
||||
} catch (e) {
|
||||
const definitive = !receiptAccepted && e instanceof PermissionHttpError && [400, 401, 403, 404, 409].includes(e.status);
|
||||
if (definitive) {
|
||||
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
|
||||
// 競合後の新しい観測を元の下書きのCASへ黙って差し替えない。
|
||||
view = view ? { ...view, can_manage: false } : null;
|
||||
}
|
||||
error = receiptAccepted ? 'The save receipt was accepted, but current policy could not be re-read. Reconcile the same request; do not treat its receipt as current authority.' : describe(e);
|
||||
} finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="permissions" aria-labelledby="permissions-heading" aria-busy={busy} data-permission-state={pending ? 'unknown' : error ? 'error' : dirty ? 'unsaved' : notice ? 'saved' : view ? 'ready' : 'loading'}>
|
||||
<header><h2 id="permissions-heading">Operation permissions</h2><span class="state">{pending ? 'Outcome unknown' : dirty ? 'Unsaved changes' : notice ? 'Saved / current policy re-read' : view?.permissions ? 'Saved policy' : view ? 'Not configured · no permissions' : 'Loading…'}</span></header>
|
||||
<p>Direct operations and the background Worker delegation ceiling are independent. Profile and Feature selection never grant permissions. Workdir/provider grants, target existence and assignment conditions still apply.</p>
|
||||
{#if error}<p class="attention" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if pending}
|
||||
<div class="attention"><strong>Save outcome not yet confirmed.</strong><p>This tab retained the exact request. Reconcile it using the same operation and then re-read the current policy. Do not start another save.</p><button disabled={busy || !view?.can_manage} onclick={() => save(pending!)}>Reconcile the same save</button></div>
|
||||
{/if}
|
||||
{#if !view}
|
||||
<button disabled={busy || storageBlocked} onclick={reload}>Read current permissions</button>
|
||||
{:else}
|
||||
{#if !view.can_manage}<p role="status">Read-only. The Backend must authorize permission management; hiding controls is not authorization.</p>{/if}
|
||||
<div class="lanes">
|
||||
{#each ['execution', 'delegation'] as which}
|
||||
{@const permissions = which === 'execution' ? execution : delegation}
|
||||
<section class="lane" aria-label={which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}>
|
||||
<h3>{which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}</h3>
|
||||
<p>{which === 'execution' ? 'Operations the Subject body may use, if its tools and current connections support them.' : 'Maximum operations and resources that may be explicitly passed to background Workers. This does not give the body work tools or start a Worker.'}</p>
|
||||
{#if permissions.length === 0}<p class="empty">No permissions in this scope.</p>{/if}
|
||||
<ul>{#each permissions as p, index}
|
||||
<li><div><strong>{label(p)}</strong><span class="resource">{resourceLabel(p.resource)}</span>
|
||||
{#if !supported(p)}<span class="legacy">Preserved, not editable here. No conversion or expansion is implied.</span><details><summary>Saved operation identifier</summary><code>{p.operation}</code></details>{/if}
|
||||
</div>{#if editable}<button aria-label={`Remove ${label(p)} from ${which === 'execution' ? 'direct operations' : 'delegation ceiling'}`} onclick={() => remove(which as Lane, index)}>Remove</button>{/if}</li>
|
||||
{/each}</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if view.can_manage}
|
||||
<form onsubmit={(event) => { event.preventDefault(); add(); }} class="permission-form">
|
||||
<h3>Add an explicit operation</h3>
|
||||
<fieldset disabled={!editable}>
|
||||
<label>Permission scope<select value={lane} onchange={(event) => { lane = event.currentTarget.value as Lane; }}><option value="execution">Direct operations</option><option value="delegation">Background Worker delegation ceiling</option></select></label>
|
||||
<label>Operation<select value={operationName} onchange={(event) => { operationName = event.currentTarget.value; chooseOperation(); }}><option value="">Choose an operation</option>{#each view.operations as o}<option value={o.operation}>{o.label}{o.available ? '' : ' — unavailable'}</option>{/each}</select></label>
|
||||
{#if selected}
|
||||
<p>{selected.description}</p>
|
||||
{#if !selected.available}<p class="attention">Target unavailable: {selected.unavailable_reason} No permission will be added.</p>
|
||||
{:else}
|
||||
<label>Resource type<select value={resourceKind} onchange={(event) => { resourceKind = event.currentTarget.value as PermissionResource['kind']; inputs = {}; recursive = false; }}>{#each selected.resource_kinds as kind}<option value={kind}>{kind.replaceAll('_', ' ')}</option>{/each}</select></label>
|
||||
{#if resourceKind === 'workspace'}<p>Collection operations in this Workspace only; this does not cover individual resources.</p>{/if}
|
||||
{#if resourceKind === 'feature'}<p>MCP is a temporary coarse activation exception. This is not per-server or per-tool authorization and does not grant ordinary Feature operations.</p>{/if}
|
||||
{#each resourceFields(resourceKind) as field}<label>{fieldLabel[field]}<input bind:value={inputs[field]} placeholder={field === 'subject_id' ? subjectId : field === 'path' ? '.' : ''} required={field !== 'subject_id' && field !== 'path'} /></label>{/each}
|
||||
{#if resourceKind === 'workdir' || resourceKind === 'workspace_config'}<label class="check"><input type="checkbox" bind:checked={recursive} />Include descendant paths</label>{/if}
|
||||
<button type="submit">Add to unsaved changes</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</fieldset>
|
||||
</form>
|
||||
{/if}
|
||||
<details class="help"><summary>Examples and enforcement boundaries</summary><p>For read-only work, add a read operation and its exact resource to the delegation ceiling. For limited editing, separately add the write operation for only the intended Workdir and relative path. Nothing else is implied; no wildcard or fixed all-powerful role is added.</p><p>Worker operations require both permission to use the operation and a directional Backend control grant for the actual target. Idle/CAS and lifecycle rules remain separate. This editor never asks for per-Worker notification/stop permissions or copies creation grants into the Subject policy. The T-803 connection is not yet available. Subject Ticket assignment migration (T-778) is not enabled by this screen.</p><p>Saving fences later operations using the current policy. Running work may receive cancellation requests; acceptance is not a confirmed stop. Already completed effects are not undone. Existing child grants are invalidated rather than silently refreshed.</p></details>
|
||||
{#if dirty && !pending}
|
||||
<section class="diff" aria-label="Unsaved permission differences"><h3>Review before saving</h3>
|
||||
{#each ['execution', 'delegation'] as which}<h4>{which === 'execution' ? 'Direct operations' : 'Delegation ceiling'}</h4>
|
||||
<ul>{#each changes[which as Lane].added as p}<li>+ Add: {label(p)} — {resourceLabel(p.resource)}</li>{/each}{#each changes[which as Lane].removed as p}<li>− Remove: {label(p)} — {resourceLabel(p.resource)}</li>{/each}</ul>
|
||||
{/each}
|
||||
{#if editable}<label class="check"><input type="checkbox" bind:checked={confirming} />I reviewed the operation, resource and delegation differences.</label><button class="save" disabled={!confirming || busy} onclick={() => save()}>Save permissions</button>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<button class="reload" disabled={busy || !!pending || storageBlocked} onclick={reload}>{dirty ? 'Discard unsaved changes and reload current policy' : 'Reload current policy'}</button>
|
||||
{#if interruptions.length}<section aria-label="Cancellation evidence"><h3>Cancellation requests, not stop confirmation</h3><ul>{#each interruptions as i}<li>Worker {i.worker_id}: cancellation {i.cancellation_accepted ? 'accepted' : 'not confirmed'}; Backend command cleanup {i.backend_command_sessions_closed ? 'confirmed' : 'not confirmed'}. Runtime stop remains unconfirmed.</li>{/each}</ul></section>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.permissions { max-width: 72rem; min-width: 0; }
|
||||
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
|
||||
h2 { margin: 0; font-size: var(--font-size-body); } h3 { font-size: var(--font-size-body); margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
|
||||
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: var(--font-size-compact); }
|
||||
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
|
||||
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
|
||||
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||
li > div { min-width: 0; } li button { flex-shrink: 0; white-space: nowrap; } .resource, .legacy { display: block; margin-top: .35rem; font-size: var(--font-size-body); } .legacy { color: var(--text-muted); }
|
||||
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
|
||||
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
|
||||
label { display: grid; gap: .35rem; font-size: var(--font-size-body); } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
|
||||
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: var(--font-size-body); } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
|
||||
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
|
||||
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
|
||||
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
attemptKey,
|
||||
makeResource,
|
||||
parseView,
|
||||
permissionKey,
|
||||
policyDiff,
|
||||
readAttempt,
|
||||
readPermissions,
|
||||
writePermissions,
|
||||
} from "./permissions.ts";
|
||||
import type { ScopedPermission } from "./permissions.ts";
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
function assert(condition: unknown): asserts condition {
|
||||
if (!condition) throw new Error("Assertion failed");
|
||||
}
|
||||
const p: ScopedPermission = {
|
||||
operation: "filesystem.read",
|
||||
resource: {
|
||||
kind: "workdir",
|
||||
working_directory_id: "wd",
|
||||
path: "src",
|
||||
recursive: true,
|
||||
},
|
||||
};
|
||||
Deno.test("permission differences preserve exact legacy resource and separate lanes", () => {
|
||||
const legacy: ScopedPermission = {
|
||||
operation: "worker.notify",
|
||||
resource: {
|
||||
kind: "worker",
|
||||
runtime_id: "runtime",
|
||||
worker_id: "only-worker",
|
||||
},
|
||||
};
|
||||
const direct = [legacy, p];
|
||||
assert(policyDiff([legacy], direct).added.length === 1);
|
||||
assert(policyDiff([legacy], direct).removed.length === 0);
|
||||
assert(
|
||||
permissionKey(p) ===
|
||||
permissionKey({
|
||||
operation: p.operation,
|
||||
resource: {
|
||||
recursive: true,
|
||||
path: "src",
|
||||
working_directory_id: "wd",
|
||||
kind: "workdir",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert(policyDiff([legacy], []).removed[0] === legacy);
|
||||
});
|
||||
Deno.test("typed resource input binds workspace, preserves narrow paths and rejects missing targets", () => {
|
||||
assert(
|
||||
JSON.stringify(
|
||||
makeResource("workdir", "w", "s", {
|
||||
working_directory_id: "wd",
|
||||
path: "src",
|
||||
}, false),
|
||||
) ===
|
||||
JSON.stringify({
|
||||
kind: "workdir",
|
||||
working_directory_id: "wd",
|
||||
path: "src",
|
||||
recursive: false,
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(makeResource("subject", "w", "s", {}, false)) ===
|
||||
JSON.stringify({ kind: "subject", workspace_id: "w", subject_id: "s" }),
|
||||
);
|
||||
let rejected = false;
|
||||
try {
|
||||
makeResource("ticket", "w", "s", {}, false);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected);
|
||||
});
|
||||
Deno.test("catalog parsing refuses unknown resource types and malformed management permission", () => {
|
||||
for (
|
||||
const value of [
|
||||
{ permissions: null, can_manage: "yes", operations: [] },
|
||||
{
|
||||
permissions: null,
|
||||
can_manage: true,
|
||||
operations: [{
|
||||
operation: "unknown",
|
||||
label: "Unknown",
|
||||
description: "",
|
||||
resource_kinds: ["everything"],
|
||||
availability: { kind: "available" },
|
||||
}],
|
||||
},
|
||||
]
|
||||
) {
|
||||
let rejected = false;
|
||||
try {
|
||||
parseView(value);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected);
|
||||
}
|
||||
});
|
||||
Deno.test("save retry keeps exact complete input scoped to Workspace and Subject", async () => {
|
||||
const request = {
|
||||
operation_id: "stable-operation",
|
||||
expected_change_id: "old",
|
||||
execution: [p],
|
||||
delegation: [],
|
||||
};
|
||||
const memory = new Map([[attemptKey("w", "s"), JSON.stringify(request)]]);
|
||||
const storage = { getItem: (key: string) => memory.get(key) ?? null };
|
||||
assert(
|
||||
JSON.stringify(readAttempt(storage, "w", "s")) === JSON.stringify(request),
|
||||
);
|
||||
assert(readAttempt(storage, "other", "s") === null);
|
||||
assert(readAttempt(storage, "w", "other") === null);
|
||||
const calls: { url: string; body: string }[] = [];
|
||||
const fake = ((input: string, init?: RequestInit) => {
|
||||
calls.push({ url: input, body: init?.body as string });
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
permissions: { change_id: "new", execution: [p], delegation: [] },
|
||||
interruptions: [],
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
await writePermissions(fake, "w", "s", request);
|
||||
await writePermissions(fake, "w", "s", readAttempt(storage, "w", "s")!);
|
||||
assert(calls[0].url === "/api/w/w/subjektiv/subjects/s/permissions");
|
||||
assert(calls[0].body === calls[1].body);
|
||||
});
|
||||
Deno.test("read permission projection uses the existing scoped endpoint and no fallback grant", async () => {
|
||||
let path = "";
|
||||
const fake = ((input: string) => {
|
||||
path = input;
|
||||
return Promise.resolve(
|
||||
Response.json({ permissions: null, can_manage: false, operations: [] }),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const view = await readPermissions(fake, "space", "subject");
|
||||
assert(path === "/api/w/space/subjektiv/subjects/subject/permissions");
|
||||
assert(
|
||||
view.permissions === null && !view.can_manage &&
|
||||
view.operations.length === 0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import type {
|
||||
PermissionOperationDescriptor,
|
||||
PermissionResource,
|
||||
ScopedPermission,
|
||||
SubjectPermissionInterruption,
|
||||
SubjectPermissions,
|
||||
SubjectPermissionsMutationResponse,
|
||||
SubjectPermissionsResponse,
|
||||
SubjectPermissionsUpdate,
|
||||
} from "#lib/generated/memory-api.ts";
|
||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
|
||||
export type { PermissionResource, ScopedPermission };
|
||||
export type Policy = SubjectPermissions;
|
||||
export type PolicyUpdate = SubjectPermissionsUpdate;
|
||||
export type Operation = Omit<PermissionOperationDescriptor, "availability"> & {
|
||||
available: boolean;
|
||||
unavailable_reason: string | null;
|
||||
};
|
||||
export type PermissionView = Omit<SubjectPermissionsResponse, "operations"> & {
|
||||
operations: Operation[];
|
||||
};
|
||||
export type Interruption = SubjectPermissionInterruption;
|
||||
export type Mutation = SubjectPermissionsMutationResponse;
|
||||
export type Lane = "execution" | "delegation";
|
||||
|
||||
const MAX_BYTES = 1_048_576;
|
||||
const fields = {
|
||||
workspace: ["workspace_id"],
|
||||
ticket: ["workspace_id", "ticket_id"],
|
||||
objective: ["workspace_id", "objective_id"],
|
||||
merge_request: ["workspace_id", "merge_request_id"],
|
||||
subject: ["workspace_id", "subject_id"],
|
||||
drive_entry: ["workspace_id", "node_id"],
|
||||
drive_request: ["workspace_id", "request_id"],
|
||||
repository: ["workspace_id", "repository_key"],
|
||||
workdir_attachment: ["workspace_id", "connection_id"],
|
||||
workspace_config: ["workspace_id", "path", "recursive"],
|
||||
network: ["url"],
|
||||
sub_worker: ["runtime_id", "worker_id", "name"],
|
||||
feature: [],
|
||||
workdir: ["working_directory_id", "path", "recursive"],
|
||||
worker: ["runtime_id", "worker_id"],
|
||||
job: ["workspace_id", "job_id"],
|
||||
} satisfies Record<PermissionResource["kind"], string[]>;
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Invalid permission response");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length > 16_384) {
|
||||
throw new Error("Invalid permission text");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function bool(value: unknown): boolean {
|
||||
if (typeof value !== "boolean") throw new Error("Invalid permission flag");
|
||||
return value;
|
||||
}
|
||||
function list<T>(value: unknown, parse: (item: unknown) => T): T[] {
|
||||
if (!Array.isArray(value) || value.length > 256) {
|
||||
throw new Error("Invalid permission list");
|
||||
}
|
||||
return value.map(parse);
|
||||
}
|
||||
export function parseResource(value: unknown): PermissionResource {
|
||||
const r = object(value);
|
||||
const kind = text(r.kind) as PermissionResource["kind"];
|
||||
if (!Object.hasOwn(fields, kind)) throw new Error("Unknown resource kind");
|
||||
const keys: string[] = fields[kind];
|
||||
if (
|
||||
Object.keys(r).some((key) => key !== "kind" && !keys.includes(key)) ||
|
||||
keys.some((key) => !(key in r))
|
||||
) throw new Error("Invalid resource fields");
|
||||
for (const key of keys) key === "recursive" ? bool(r[key]) : text(r[key]);
|
||||
return r as PermissionResource;
|
||||
}
|
||||
export function parsePermission(value: unknown): ScopedPermission {
|
||||
const p = object(value);
|
||||
return { operation: text(p.operation), resource: parseResource(p.resource) };
|
||||
}
|
||||
export function parsePolicy(value: unknown): Policy {
|
||||
const p = object(value);
|
||||
return {
|
||||
change_id: text(p.change_id),
|
||||
execution: list(p.execution, parsePermission),
|
||||
delegation: list(p.delegation, parsePermission),
|
||||
};
|
||||
}
|
||||
export function parseView(value: unknown): PermissionView {
|
||||
const v = object(value);
|
||||
return {
|
||||
permissions: v.permissions === null ? null : parsePolicy(v.permissions),
|
||||
can_manage: bool(v.can_manage),
|
||||
operations: list(v.operations, (value) => {
|
||||
const o = object(value);
|
||||
const availability = object(o.availability);
|
||||
if (
|
||||
availability.kind !== "available" && availability.kind !== "unavailable"
|
||||
) throw new Error("Unknown operation availability");
|
||||
return {
|
||||
operation: text(o.operation),
|
||||
label: text(o.label),
|
||||
description: text(o.description),
|
||||
resource_kinds: list(o.resource_kinds, (value) => {
|
||||
const kind = text(value) as PermissionResource["kind"];
|
||||
if (!Object.hasOwn(fields, kind)) {
|
||||
throw new Error("Unknown resource kind");
|
||||
}
|
||||
return kind;
|
||||
}),
|
||||
available: availability.kind === "available",
|
||||
unavailable_reason: availability.kind === "unavailable"
|
||||
? text(availability.reason)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
export class PermissionHttpError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(`Permission request rejected (HTTP ${status}).`);
|
||||
}
|
||||
}
|
||||
const path = (workspaceId: string, subjectId: string) =>
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/permissions`,
|
||||
);
|
||||
export async function readPermissions(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
): Promise<PermissionView> {
|
||||
const response = await fetchFn(path(workspaceId, subjectId));
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new PermissionHttpError(response.status);
|
||||
}
|
||||
return parseView(await readBoundedJson(response, MAX_BYTES));
|
||||
}
|
||||
export async function writePermissions(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
request: PolicyUpdate,
|
||||
): Promise<Mutation> {
|
||||
const response = await fetchFn(path(workspaceId, subjectId), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new PermissionHttpError(response.status);
|
||||
}
|
||||
const v = object(await readBoundedJson(response, MAX_BYTES));
|
||||
return {
|
||||
permissions: parsePolicy(v.permissions),
|
||||
interruptions: list(v.interruptions, (value) => {
|
||||
const i = object(value);
|
||||
return {
|
||||
runtime_id: text(i.runtime_id),
|
||||
worker_id: text(i.worker_id),
|
||||
cancellation_accepted: bool(i.cancellation_accepted),
|
||||
backend_command_sessions_closed: bool(
|
||||
i.backend_command_sessions_closed,
|
||||
),
|
||||
diagnostics: list(i.diagnostics, text),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
export function permissionKey(permission: ScopedPermission): string {
|
||||
return JSON.stringify([
|
||||
permission.operation,
|
||||
Object.entries(permission.resource).sort(([a], [b]) => a.localeCompare(b)),
|
||||
]);
|
||||
}
|
||||
export function policyDiff(
|
||||
before: ScopedPermission[],
|
||||
after: ScopedPermission[],
|
||||
) {
|
||||
return {
|
||||
added: after.filter((p) =>
|
||||
!before.some((b) => permissionKey(b) === permissionKey(p))
|
||||
),
|
||||
removed: before.filter((p) =>
|
||||
!after.some((a) => permissionKey(a) === permissionKey(p))
|
||||
),
|
||||
};
|
||||
}
|
||||
export function resourceLabel(resource: PermissionResource): string {
|
||||
const labels: Record<string, string> = {
|
||||
workspace: "Current workspace",
|
||||
subject: "Subject",
|
||||
ticket: "Ticket",
|
||||
objective: "Objective",
|
||||
merge_request: "Merge request",
|
||||
workdir: "Workdir",
|
||||
workspace_config: "Workspace configuration",
|
||||
network: "URL",
|
||||
drive_entry: "Drive entry",
|
||||
drive_request: "Drive request",
|
||||
repository: "Repository",
|
||||
workdir_attachment: "Workdir connection",
|
||||
feature: "MCP activation / legacy feature",
|
||||
worker: "Legacy Worker target",
|
||||
sub_worker: "Internal SubWorker",
|
||||
job: "Job",
|
||||
};
|
||||
const identifiers = Object.entries(resource).filter(([key]) =>
|
||||
!["kind", "workspace_id", "recursive"].includes(key)
|
||||
).map(([_, value]) => String(value));
|
||||
return `${labels[resource.kind]}${
|
||||
identifiers.length ? ` · ${identifiers.join(" / ")}` : ""
|
||||
}${
|
||||
"recursive" in resource && resource.recursive
|
||||
? " (including descendants)"
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
// 保存値の正本ではなく、同じ操作の完全一致再送に必要な入力だけをtab内に保持する。
|
||||
export function attemptKey(workspaceId: string, subjectId: string): string {
|
||||
return `yoi.subject-permissions:${JSON.stringify([workspaceId, subjectId])}`;
|
||||
}
|
||||
export function readAttempt(
|
||||
storage: Pick<Storage, "getItem">,
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
): PolicyUpdate | null {
|
||||
const raw = storage.getItem(attemptKey(workspaceId, subjectId));
|
||||
if (raw === null) return null;
|
||||
if (raw.length > MAX_BYTES) {
|
||||
throw new Error(
|
||||
"Saved permission request is unreadable. Do not start another save.",
|
||||
);
|
||||
}
|
||||
const v = object(JSON.parse(raw));
|
||||
return {
|
||||
operation_id: text(v.operation_id),
|
||||
expected_change_id: v.expected_change_id === null
|
||||
? null
|
||||
: text(v.expected_change_id),
|
||||
execution: list(v.execution, parsePermission),
|
||||
delegation: list(v.delegation, parsePermission),
|
||||
};
|
||||
}
|
||||
export function makeResource(
|
||||
kind: PermissionResource["kind"],
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
inputs: Record<string, string>,
|
||||
recursive: boolean,
|
||||
): PermissionResource {
|
||||
const r: Record<string, unknown> = { kind };
|
||||
for (const key of fields[kind]) {
|
||||
r[key] = key === "workspace_id"
|
||||
? workspaceId
|
||||
: key === "recursive"
|
||||
? recursive
|
||||
: key === "subject_id"
|
||||
? (inputs[key]?.trim() || subjectId)
|
||||
: inputs[key]?.trim() ?? "";
|
||||
if (
|
||||
typeof r[key] === "string" && !(r[key] as string).length && key !== "path"
|
||||
) throw new Error("Enter the required target resource.");
|
||||
}
|
||||
if (kind === "workdir" && !r.path) r.path = ".";
|
||||
return parseResource(r);
|
||||
}
|
||||
export function resourceFields(kind: PermissionResource["kind"]): string[] {
|
||||
return fields[kind].filter((key) =>
|
||||
!["workspace_id", "recursive"].includes(key)
|
||||
);
|
||||
}
|
||||
export const fieldLabel: Record<string, string> = {
|
||||
ticket_id: "Ticket identifier",
|
||||
objective_id: "Objective identifier",
|
||||
merge_request_id: "Merge request identifier",
|
||||
subject_id: "Subject identifier (empty means this Subject)",
|
||||
node_id: "Drive entry number",
|
||||
request_id: "Drive request identifier",
|
||||
repository_key: "Repository key",
|
||||
connection_id: "Workdir connection identifier",
|
||||
path: "Relative path",
|
||||
url: "Exact HTTP(S) URL",
|
||||
working_directory_id: "Workdir identifier",
|
||||
job_id: "Job identifier",
|
||||
runtime_id: "Runtime identifier",
|
||||
worker_id: "Worker identifier",
|
||||
name: "Internal SubWorker name",
|
||||
};
|
||||
@@ -180,7 +180,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !deletionStarted}
|
||||
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
|
||||
<nav aria-label="Subjectの記録と管理"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav>
|
||||
|
||||
{#if subject}
|
||||
<section class="subject-overview" aria-label="Subject status">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
|
||||
import PageContent from './PageContent.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
<DeferredPage {data} title="Subject permissions">
|
||||
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
|
||||
</DeferredPage>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "#lib/workspace/memory/api.ts";
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = deferPage(async (
|
||||
{ fetch, params }: Parameters<PageLoad>[0],
|
||||
) => ({
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId: params.subjectId,
|
||||
subject: await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/subjektiv/subjects/${encodeURIComponent(params.subjectId)}`,
|
||||
),
|
||||
undefined,
|
||||
(value) => parseSubjektivSubjectResponse(value, params.subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
}));
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import SubjectPermissions from '#lib/workspace/memory/SubjectPermissions.svelte';
|
||||
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: ResolvedPageData<PageData> } = $props();
|
||||
</script>
|
||||
<svelte:head><title>Permissions · {data.subject.data?.role ?? 'Subject'} · Yoi Workspace</title></svelte:head>
|
||||
<section class="permission-page">
|
||||
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← Subject details</a>
|
||||
<h1>{data.subject.data?.role ?? 'Subject'} permissions</h1>
|
||||
{#if data.subject.data}
|
||||
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
|
||||
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
|
||||
</section>
|
||||
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: var(--font-size-title); margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
|
||||
@@ -78,6 +78,7 @@ function subject() {
|
||||
|
||||
function subjectIndexData(items = [subject()]) {
|
||||
return {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
cursor: null,
|
||||
subjects: result({
|
||||
@@ -91,6 +92,7 @@ function subjectIndexData(items = [subject()]) {
|
||||
test("renders the subject index as the Memory product entry", () => {
|
||||
render(SubjectIndexPage, {
|
||||
data: {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
cursor: "subject-current",
|
||||
subjects: result({
|
||||
@@ -273,6 +275,7 @@ test("warns about an unknown create outcome without replaying the POST", async (
|
||||
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
@@ -368,6 +371,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
@@ -440,6 +444,7 @@ test.each([false, true])(
|
||||
|
||||
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
|
||||
return {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId,
|
||||
workspace: memoryWorkspace(workspaceId),
|
||||
subjectId: record.id,
|
||||
@@ -773,6 +778,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
||||
// the same empty committed list as a new Subject; candidates are not a page input.
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
@@ -857,6 +863,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
@@ -943,6 +950,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => {
|
||||
render(MemoryDetailPage, {
|
||||
data: {
|
||||
workspace: { permissions: { delete_subjects: false } },
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
|
||||
Reference in New Issue
Block a user