feat: enforce explicit Subject permissions and delegated Worker grants

This commit is contained in:
2026-10-20 06:22:58 +09:00
parent a774925dec
commit b135fa5e99
52 changed files with 7422 additions and 100 deletions
+1
View File
@@ -908,6 +908,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
};
let response = create_backend_worker_with_client(&target, &create, &api)
+1
View File
@@ -567,6 +567,7 @@ impl BackendWorkspaceProductClient {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
};
let workspace_id = self.workspace_id.clone();
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move {
+475
View File
@@ -0,0 +1,475 @@
//! Neutral explicit-allow authorization contracts. Resource containment never crosses kinds.
//!
//! # Trusted Feature boundary
//!
//! A Profile requests execution configuration, not authority. The Worker asks the
//! Host for each enabled Feature's exact activation name before initialization,
//! then trusted Feature code asks for each protected operation and its semantic
//! resource before access or effects. `ticket` on [`PermissionResource::Feature`]
//! activates Ticket; it implies neither `ticket.create` nor any future operation.
//! `ticket.create` on a Workspace permits creation there but cannot activate the
//! Feature. There are no wildcard permissions or implicit dotted-name inheritance.
//!
//! Identity is absent from the authorization request intentionally: the Host
//! authenticates the bound Runtime/Worker and resolves current source permissions,
//! body ownership and any per-Worker execution grant. A shared client, Profile,
//! remembered receipt or caller-supplied Subject claim is not authority. Features
//! depend on this contract, never on a particular Subject policy implementation.
//!
//! Supported cooperative boundaries are Feature activation, ordinary Worker
//! delegation, Ticket creation (Tool/native WIP/typed service), Workdir filesystem
//! read/write and whole-attachment command execution, and scoped result notify.
//! Workdir providers still enforce resolved paths, traversal, capability, Ticket
//! and provider ceilings. The returned scope is for provider narrowing, not a
//! transferable credential. Scoped checkout/overlap operations are refused until
//! their complete access semantics are adapted. Other Feature operations retain
//! their existing owner checks; they do not automatically acquire this operation
//! policy. Missing calls are trusted Feature bugs, not interception by a universal
//! Tool executor or protection against malicious Feature code. Connection tests
//! must exercise each exposed surface, assert a genuine permitted effect and
//! assert zero downstream calls on denial, including revocation and provider denial.
//!
//! # Delegation callers and recovery
//!
//! Callers such as a chat Feature supply explicit operation/resource selections
//! and retain the same public operation ID and full request after an unknown
//! result. Normal Profile/Worker allocation and control/resource grants are reused;
//! there is no Subject Worker registry. Effective grants bind immutable Profile
//! resolution, the reserved Worker and source assignment. Permission replacement
//! invalidates existing children rather than refreshing them. Body replacement,
//! retirement and restore must recheck current authority; historical result lookup
//! is not new execution permission. Cancellation acceptance is distinct from
//! terminal confirmation and does not undo completed effects.
//!
//! A child receives no Subject body identity, singleton, whole Memory or public
//! conversation-post authority. `worker.result` is explicit notify authority to a
//! named origin Worker, not arbitrary control or publication. Chat delivery and
//! conversation correlation belong to the caller integration, not this contract.
//! Unset existing Subject policies deny new Subject execution/delegation; ordinary
//! non-Subject Workers explicitly retain existing Backend/provider contracts.
//! Standalone non-Workspace activation compatibility grants no Workspace operation.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PermissionResource {
Feature,
Workspace {
workspace_id: String,
},
Workdir {
working_directory_id: String,
path: String,
recursive: bool,
},
Worker {
runtime_id: String,
worker_id: String,
},
}
// Serde's internally tagged unit variants ignore extra fields even with deny_unknown_fields.
// A struct-shaped wire variant preserves the public unit variant while rejecting those claims.
impl<'de> Deserialize<'de> for PermissionResource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum Wire {
Feature {},
Workspace {
workspace_id: String,
},
Workdir {
working_directory_id: String,
path: String,
recursive: bool,
},
Worker {
runtime_id: String,
worker_id: String,
},
}
Ok(match Wire::deserialize(deserializer)? {
Wire::Feature {} => Self::Feature,
Wire::Workspace { workspace_id } => Self::Workspace { workspace_id },
Wire::Workdir {
working_directory_id,
path,
recursive,
} => Self::Workdir {
working_directory_id,
path,
recursive,
},
Wire::Worker {
runtime_id,
worker_id,
} => Self::Worker {
runtime_id,
worker_id,
},
})
}
}
impl PermissionResource {
pub fn validate(&self) -> Result<(), String> {
match self {
Self::Feature => Ok(()),
Self::Workspace { workspace_id } => validate_authorization_identifier(workspace_id),
Self::Workdir {
working_directory_id,
path,
..
} => {
validate_authorization_identifier(working_directory_id)?;
validate_relative_permission_path(path)
}
Self::Worker {
runtime_id,
worker_id,
} => {
validate_authorization_identifier(runtime_id)?;
validate_authorization_identifier(worker_id)
}
}
}
pub fn covers(&self, other: &Self) -> bool {
if self.validate().is_err() || other.validate().is_err() {
return false;
}
match (self, other) {
(Self::Feature, Self::Feature) => true,
(Self::Workspace { workspace_id: a }, Self::Workspace { workspace_id: b }) => a == b,
(
Self::Worker {
runtime_id: ar,
worker_id: aw,
},
Self::Worker {
runtime_id: br,
worker_id: bw,
},
) => ar == br && aw == bw,
(
Self::Workdir {
working_directory_id: a,
path: ap,
recursive: ar,
},
Self::Workdir {
working_directory_id: b,
path: bp,
recursive: br,
},
) => {
a == b
&& if *ar {
ap == "."
|| ap == bp
|| bp
.strip_prefix(ap)
.is_some_and(|tail| tail.starts_with('/'))
} else {
ap == bp && !br
}
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ScopedPermission {
pub operation: String,
pub resource: PermissionResource,
}
impl ScopedPermission {
pub fn validate(&self) -> Result<(), String> {
validate_operation_name(&self.operation)?;
self.resource.validate()?;
if matches!(self.resource, PermissionResource::Feature) && self.operation.contains('.') {
return Err("Feature resources authorize activation names only, not operations".into());
}
Ok(())
}
pub fn covers(&self, other: &Self) -> bool {
self.validate().is_ok()
&& other.validate().is_ok()
&& self.operation == other.operation
&& self.resource.covers(&other.resource)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectPermissions {
pub change_id: String,
pub execution: Vec<ScopedPermission>,
pub delegation: Vec<ScopedPermission>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsUpdate {
pub operation_id: String,
pub expected_change_id: Option<String>,
pub execution: Vec<ScopedPermission>,
pub delegation: Vec<ScopedPermission>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkerExecutionGrant {
pub subject_id: String,
pub origin_runtime_id: String,
pub origin_worker_id: String,
pub source_change_id: String,
pub runtime_id: String,
pub worker_id: String,
pub profile: String,
pub profile_digest: String,
pub permissions: Vec<ScopedPermission>,
pub redelegation: Vec<ScopedPermission>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkerDelegationRequest {
pub permissions: Vec<ScopedPermission>,
pub redelegation: Vec<ScopedPermission>,
}
impl WorkerDelegationRequest {
pub fn validate(&self) -> Result<(), String> {
validate_scoped_permissions(&self.permissions)?;
validate_scoped_permissions(&self.redelegation)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct OperationAuthorizationRequest {
pub operation: String,
pub resource: PermissionResource,
}
impl OperationAuthorizationRequest {
pub fn validate(&self) -> Result<(), String> {
ScopedPermission {
operation: self.operation.clone(),
resource: self.resource.clone(),
}
.validate()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct OperationAuthorizationResponse {
/// Matching granted scope for provider-resolved checks, not a credential.
/// `None` identifies the explicit non-Subject legacy authorization path.
#[serde(default)]
pub scope: Option<PermissionResource>,
}
pub fn validate_operation_name(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 256
|| !value.split('.').all(|part| {
!part.is_empty()
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
{
return Err(
"permission operation must be an exact, nonempty dot-separated name (no wildcards)"
.into(),
);
}
Ok(())
}
pub fn validate_scoped_permissions(permissions: &[ScopedPermission]) -> Result<(), String> {
if permissions.len() > MAX_SCOPED_PERMISSIONS {
return Err("too many scoped permissions".into());
}
for permission in permissions {
permission.validate()?;
}
Ok(())
}
pub fn validate_authorization_identifier(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 256
|| value.trim() != value
|| value.chars().any(|c| c.is_control() || c == '*')
{
return Err("authorization identity must be nonempty, bounded and exact".into());
}
Ok(())
}
/// Paths are already-normalized lexical paths, not host filesystem paths. The Host must
/// still enforce symlink/provider boundaries; lexical containment grants no filesystem access.
pub fn validate_relative_permission_path(path: &str) -> Result<(), String> {
if path == "." {
return Ok(());
}
if path.is_empty()
|| path.len() > 4096
|| path.contains(['\\', ':'])
|| path.chars().any(char::is_control)
|| path
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return Err("permission path must be a normalized relative path without escape".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn workdir(path: &str, recursive: bool) -> PermissionResource {
PermissionResource::Workdir {
working_directory_id: "wd".into(),
path: path.into(),
recursive,
}
}
#[test]
fn lexical_containment_rejects_escape_and_respects_component_and_recursion_boundaries() {
for path in [
"",
"/src",
"../src",
"src/../secret",
"src/./a",
"src//a",
"src/",
"C:/src",
"src\\a",
] {
assert!(workdir(path, true).validate().is_err(), "{path}");
}
let root = workdir(".", true);
let src = workdir("src", true);
assert!(root.covers(&src));
assert!(src.covers(&workdir("src/a", true)));
assert!(!src.covers(&workdir("src-other", false)));
assert!(!workdir("src", false).covers(&src));
assert!(workdir("src", false).covers(&workdir("src", false)));
assert!(!workdir("src", false).covers(&workdir("src/a", false)));
assert!(!src.covers(&PermissionResource::Workdir {
working_directory_id: "other".into(),
path: "src".into(),
recursive: false
}));
}
#[test]
fn operation_hierarchy_never_implies_inheritance_or_feature_activation() {
for name in [
"",
"*",
"ticket.*",
".ticket",
"ticket.",
"ticket..read",
"ticket read",
] {
assert!(validate_operation_name(name).is_err(), "{name}");
}
let activation = ScopedPermission {
operation: "ticket".into(),
resource: PermissionResource::Feature,
};
let create = ScopedPermission {
operation: "ticket.create".into(),
resource: PermissionResource::Workspace {
workspace_id: "ws".into(),
},
};
let parent_name = ScopedPermission {
operation: "ticket".into(),
resource: create.resource.clone(),
};
assert!(!parent_name.covers(&create));
assert!(!create.covers(&parent_name));
assert!(!activation.covers(&create));
assert!(!create.covers(&activation));
assert!(
ScopedPermission {
operation: "ticket.create".into(),
resource: PermissionResource::Feature
}
.validate()
.is_err()
);
assert!(
!PermissionResource::Workspace {
workspace_id: "ws".into()
}
.covers(&workdir(".", true))
);
}
#[test]
fn wire_contract_rejects_self_claimed_identity_and_unknown_fields() {
let request = serde_json::json!({"permissions":[],"redelegation":[],"subject_id":"forged"});
assert!(serde_json::from_value::<WorkerDelegationRequest>(request).is_err());
let resource: PermissionResource =
serde_json::from_value(serde_json::json!({"kind":"feature"})).unwrap();
assert_eq!(resource, PermissionResource::Feature);
assert!(
serde_json::from_value::<PermissionResource>(
serde_json::json!({"kind":"feature","workspace_id":"ws"})
)
.is_err()
);
}
#[test]
fn authorization_response_carries_matching_scope_and_defaults_legacy_absence() {
let legacy: OperationAuthorizationResponse =
serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(legacy.scope, None);
assert_eq!(
serde_json::to_value(legacy).unwrap(),
serde_json::json!({"scope":null})
);
let granted = OperationAuthorizationResponse {
scope: Some(workdir("src", true)),
};
let wire = serde_json::to_value(&granted).unwrap();
assert_eq!(
wire,
serde_json::json!({"scope":{"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}})
);
assert_eq!(
serde_json::from_value::<OperationAuthorizationResponse>(wire).unwrap(),
granted
);
}
}
+89 -1
View File
@@ -6,6 +6,7 @@ use api_macros::api;
pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod drive;
mod notification;
pub use notification::*;
@@ -23,6 +24,34 @@ use webauthn_rs_proto::{
};
pub use workspace_config::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsResponse {
pub permissions: Option<authorization::SubjectPermissions>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsMutationResponse {
pub permissions: authorization::SubjectPermissions,
/// New operations are already fenced. These are cancellation requests and
/// Backend command cleanup evidence, not proof that a Runtime turn stopped.
pub interruptions: Vec<SubjectPermissionInterruption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionInterruption {
pub runtime_id: String,
pub worker_id: String,
pub cancellation_accepted: bool,
pub backend_command_sessions_closed: bool,
pub diagnostics: Vec<String>,
}
#[allow(dead_code)]
#[derive(JsonSchema)]
struct ServerJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64);
@@ -168,6 +197,11 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
SubjectPermissionsResponse,
SubjectPermissionsMutationResponse,
SubjectPermissionInterruption,
authorization::SubjectPermissions,
authorization::SubjectPermissionsUpdate,
AuthBootstrapUserRequest,
AuthPublicConfig,
HostingSettings,
@@ -1298,6 +1332,49 @@ pub trait ServerApi {
#[body] request: MemoryConsolidateStagingRequest,
) -> Result<MemoryConsolidationResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/authorization/check",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
openapi = false,
)]
async fn operation_authorize(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: authorization::OperationAuthorizationRequest,
) -> Result<authorization::OperationAuthorizationResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
browser_auth = true,
)]
async fn subjektiv_subject_permissions_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjectPermissionsResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
browser_auth = true,
)]
async fn subjektiv_subject_permissions_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: authorization::SubjectPermissionsUpdate,
) -> Result<SubjectPermissionsMutationResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/subjects",
status = 201,
@@ -10434,6 +10511,10 @@ pub struct CreateWorkspaceWorkerRequest {
/// Backend idempotency key used only for authenticated Worker-owned spawn/control.
#[serde(default)]
pub control_operation_id: Option<String>,
/// Requested least-authority child permissions. Host derives the origin; this
/// input cannot assert Subject identity or create a body/singleton attachment.
#[serde(default)]
pub execution_grant: Option<authorization::WorkerDelegationRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -12935,6 +13016,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
})
.unwrap();
assert_eq!(
@@ -12949,6 +13031,7 @@ mod tests {
"workdir_attachments": [],
"feature_connections": {},
"control_operation_id": null,
"execution_grant": null,
})
);
}
@@ -14538,6 +14621,7 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"operation_authorize",
"current_worker_workspace_config_get",
"current_worker_workspace_config_attach",
"current_worker_workspace_config_observe",
@@ -14575,7 +14659,11 @@ mod openapi_artifact_tests {
// the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
const BROWSER_ONLY: &[&str] = &[
"merge_request_selector_repair",
"subjektiv_subject_permissions_get",
"subjektiv_subject_permissions_set",
];
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
let value: serde_json::Value =
+8
View File
@@ -1299,6 +1299,14 @@ pub type OperationResult<T> = std::result::Result<T, OperationError>;
pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError {
match error {
crate::SubjektivError::AuthorizationDenied(reason) => {
OperationError::PermissionDenied(reason)
}
crate::SubjektivError::SubjectPermissionsConflict(_)
| crate::SubjektivError::PermissionOperationConflict(_)
| crate::SubjektivError::WorkerExecutionGrantConflict(_) => {
OperationError::Conflict(error.to_string())
}
crate::SubjektivError::SubjectScopeMismatch { .. } => {
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
}
+1 -1
View File
@@ -929,7 +929,7 @@ mod tests {
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
assert!(migrated_subject.behavior_md.is_empty());
assert!(migrated_subject.behavior_md.is_empty());
assert!(store.subject_permissions(&subject_id).unwrap().is_none());
let lifecycle_attribution = attribution(
&subject_id,
"runtime-1",
+16
View File
@@ -4,6 +4,7 @@ pub mod api;
pub mod job;
pub mod journal;
mod legacy_migrations;
pub mod permissions;
mod schema;
use legacy_migrations::{
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution,
@@ -82,6 +83,16 @@ pub enum SubjektivError {
},
#[error("subject `{subject_id}` behavior content changed")]
SubjectBehaviorConflict { subject_id: String },
#[error("subject `{0}` permission source changed")]
SubjectPermissionsConflict(String),
#[error("permission operation `{0}` was reused with different input")]
PermissionOperationConflict(String),
#[error("authorization denied: {0}")]
AuthorizationDenied(String),
#[error("Worker execution grant binding conflicts for `{0}`")]
WorkerExecutionGrantConflict(String),
#[error("Subject `{subject_id}` has more than {limit} retained Worker execution grants")]
WorkerExecutionGrantListOverflow { subject_id: String, limit: usize },
#[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -623,6 +634,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add immutable candidate Journal context links",
journal::memory_links::migrate,
),
FeatureMigration::new(
12,
"add explicit Subject permission sources and immutable child execution grants",
permissions::migrate,
),
];
pub const REGISTRATION: FeatureRegistration =
+288
View File
@@ -0,0 +1,288 @@
//! Host-administered permission sources and immutable child grants, separate from behavior.
//!
//! The Host authenticates administrators before source writes and binds verified Worker/Profile
//! identities before grant writes. These records are not a Worker registry. The Host alone
//! authenticates root execution ownership against its canonical Backend singleton and checks
//! every live lineage edge; Journal bindings and retained grants cannot establish ownership.
//! Replays return historical evidence without restoring a source or granting current authority.
use super::*;
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
WorkerExecutionGrant, validate_authorization_identifier, validate_scoped_permissions,
};
use sha2::{Digest, Sha256};
pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000;
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch(include_str!("permissions.sql"))?;
Ok(())
}
impl SubjektivStore {
/// Unset sources are `None` (deny), including migrated Subjects. This is an admin read,
/// not an effective-authorization decision; retired Subjects retain their source evidence.
pub fn subject_permissions(&self, subject_id: &str) -> Result<Option<SubjectPermissions>> {
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
source_in_connection(connection, subject_id)
})
}
/// Host-admin-only replacement. Every replacement issues a fresh change ID, including
/// identical contents. Revocation is an explicit empty replacement. A scope-wide operation
/// ID binds Subject, CAS input, and both permission vectors; result and receipt commit together.
pub fn set_subject_permissions(
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
) -> Result<SubjectPermissions> {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
validate_authorization_identifier(&update.operation_id)
.map_err(SubjektivError::InvalidRecord)?;
if let Some(change) = &update.expected_change_id {
validate_authorization_identifier(change).map_err(SubjektivError::InvalidRecord)?;
}
validate_scope(self.scope_id(), &update.execution)?;
validate_scope(self.scope_id(), &update.delegation)?;
let fingerprint: String = Sha256::digest(serde_json::to_vec(&(subject_id, update))?)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
self.database.try_transaction(|tx| {
require_subject(tx, subject_id)?;
let receipt: Option<(String, String)> = tx.query_row(
"SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1",
[&update.operation_id], |row| Ok((row.get(0)?, row.get(1)?)),
).optional()?;
if let Some((saved, change_id)) = receipt {
if saved != fingerprint {
return Err(SubjektivError::PermissionOperationConflict(update.operation_id.clone()));
}
let raw: String = tx.query_row(
"SELECT record_json FROM subject_permission_changes WHERE subject_id=?1 AND change_id=?2",
params![subject_id, change_id], |row| row.get(0),
)?;
return Ok(serde_json::from_str(&raw)?);
}
require_active_subject(tx, subject_id)?;
let current = source_in_connection(tx, subject_id)?;
if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() {
return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into()));
}
let source = SubjectPermissions {
change_id: issued_id("permissions"),
execution: update.execution.clone(),
delegation: update.delegation.clone(),
};
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
params![subject_id, source.change_id, serde_json::to_string(&source)?])?;
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2) ON CONFLICT(subject_id) DO UPDATE SET change_id=excluded.change_id",
params![subject_id, source.change_id])?;
tx.execute("INSERT INTO subject_permission_receipts(operation_id,subject_id,request_fingerprint,change_id) VALUES(?1,?2,?3,?4)",
params![update.operation_id, subject_id, fingerprint, source.change_id])?;
Ok(source)
})
}
/// Binds child grants only. Exact retries recover an immutable binding even after revocation;
/// that success is not authorization to execute or redelegate. New bindings require the
/// current source, an active Subject and explicit delegation containment. The Host must
/// authenticate root singleton ownership and every live lineage edge before calling;
/// a missing retained parent is not ownership evidence. Profile resolution, provider scope,
/// Worker creation and caller authentication also remain Host duties.
pub fn bind_worker_execution_grant(&self, grant: &WorkerExecutionGrant) -> Result<()> {
validate_grant(self.scope_id(), grant)?;
self.database.try_transaction(|tx| {
if let Some(existing) = grant_in_connection(tx, &grant.runtime_id, &grant.worker_id)? {
return if existing == *grant { Ok(()) } else {
Err(SubjektivError::WorkerExecutionGrantConflict(format!("{}/{}", grant.runtime_id, grant.worker_id)))
};
}
require_active_subject(tx, &grant.subject_id)?;
let source = source_in_connection(tx, &grant.subject_id)?
.ok_or_else(|| denied("Subject permissions are unset"))?;
if source.change_id != grant.source_change_id {
return Err(denied("Subject permission source is no longer current"));
}
require_covered(&source.delegation, &grant.permissions)?;
require_covered(&source.delegation, &grant.redelegation)?;
// A child grant cannot turn a body (including a released historical body) into a
// delegated child or attach a Worker historically attributed to a different Subject.
let invalid_target: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)",
params![grant.runtime_id, grant.worker_id, grant.subject_id], |row| row.get(0),
)?;
if invalid_target { return Err(denied("target is a body or belongs to another Subject")); }
let origin = require_retained_origin(tx, grant)?;
if let Some(origin) = origin {
require_covered(&origin.redelegation, &grant.permissions)?;
require_covered(&origin.redelegation, &grant.redelegation)?;
}
tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)",
params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?;
Ok(())
})
}
/// Returns this Subject's retained grants, including stale grants needed for cancellation
/// and reporting. Never an ownership list or effective-authorization decision. Overflow is
/// explicit rather than silently omitting Workers from a cancellation/reporting pass.
pub fn list_subject_worker_execution_grants(
&self,
subject_id: &str,
) -> Result<Vec<WorkerExecutionGrant>> {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
let mut statement = connection.prepare(
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2",
)?;
let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?;
let mut grants = Vec::new();
while let Some(row) = rows.next()? {
if grants.len() == MAX_SUBJECT_WORKER_EXECUTION_GRANTS {
return Err(SubjektivError::WorkerExecutionGrantListOverflow {
subject_id: subject_id.into(),
limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS,
});
}
grants.push(serde_json::from_str(&row.get::<_, String>(0)?)?);
}
Ok(grants)
})
}
/// Returns retained binding evidence, not current authorization. The Host must compare the
/// active Subject, source change, current origin ownership, Profile and provider constraints.
pub fn worker_execution_grant(
&self,
runtime: &str,
worker: &str,
) -> Result<Option<WorkerExecutionGrant>> {
validate_authorization_identifier(runtime).map_err(SubjektivError::InvalidRecord)?;
validate_authorization_identifier(worker).map_err(SubjektivError::InvalidRecord)?;
self.database
.try_with_connection(|connection| grant_in_connection(connection, runtime, worker))
}
}
fn source_in_connection(
connection: &Connection,
subject: &str,
) -> Result<Option<SubjectPermissions>> {
let raw: Option<String> = connection.query_row(
"SELECT c.record_json FROM subject_permissions p JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id WHERE p.subject_id=?1",
[subject], |row| row.get(0),
).optional()?;
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
.transpose()
}
fn grant_in_connection(
connection: &Connection,
runtime: &str,
worker: &str,
) -> Result<Option<WorkerExecutionGrant>> {
let raw: Option<String> = connection
.query_row(
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2",
params![runtime, worker],
|row| row.get(0),
)
.optional()?;
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
.transpose()
}
fn validate_scope(scope: &str, permissions: &[ScopedPermission]) -> Result<()> {
validate_scoped_permissions(permissions).map_err(SubjektivError::InvalidRecord)?;
for permission in permissions {
if let PermissionResource::Workspace { workspace_id } = &permission.resource
&& workspace_id != scope
{
return Err(denied("Workspace permission is outside this store scope"));
}
}
Ok(())
}
fn validate_grant(scope: &str, grant: &WorkerExecutionGrant) -> Result<()> {
for value in [
&grant.subject_id,
&grant.origin_runtime_id,
&grant.origin_worker_id,
&grant.source_change_id,
&grant.runtime_id,
&grant.worker_id,
&grant.profile,
&grant.profile_digest,
] {
validate_authorization_identifier(value).map_err(SubjektivError::InvalidRecord)?;
}
if grant.origin_runtime_id == grant.runtime_id && grant.origin_worker_id == grant.worker_id {
return Err(denied("a child grant cannot originate from itself"));
}
validate_scope(scope, &grant.permissions)?;
validate_scope(scope, &grant.redelegation)
}
fn require_covered(ceiling: &[ScopedPermission], requested: &[ScopedPermission]) -> Result<()> {
if requested
.iter()
.all(|permission| ceiling.iter().any(|allowed| allowed.covers(permission)))
{
Ok(())
} else {
Err(denied(
"requested permission or redelegation exceeds its explicit ceiling",
))
}
}
fn require_retained_origin(
tx: &Transaction<'_>,
grant: &WorkerExecutionGrant,
) -> Result<Option<WorkerExecutionGrant>> {
let mut runtime = grant.origin_runtime_id.clone();
let mut worker = grant.origin_worker_id.clone();
let mut direct_origin = None;
let mut visited = HashSet::from([(grant.runtime_id.clone(), grant.worker_id.clone())]);
// Validate retained lineage only. Absence terminates at a Host-authenticated root;
// neither Journal active state nor absence of a grant proves current singleton ownership.
for _ in 0..64 {
if !visited.insert((runtime.clone(), worker.clone())) {
return Err(denied("cyclic grant origin"));
}
let foreign_attribution: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)",
params![runtime, worker, grant.subject_id], |row| row.get(0),
)?;
if foreign_attribution {
return Err(denied("grant origin is attributed to another Subject"));
}
let Some(origin) = grant_in_connection(tx, &runtime, &worker)? else {
return Ok(direct_origin);
};
if origin.subject_id != grant.subject_id
|| origin.source_change_id != grant.source_change_id
{
return Err(denied(
"grant origin belongs to another Subject or permission source",
));
}
if direct_origin.is_none() {
direct_origin = Some(origin.clone());
}
runtime = origin.origin_runtime_id;
worker = origin.origin_worker_id;
}
Err(denied("grant origin exceeds maximum delegation depth"))
}
fn denied(reason: &str) -> SubjektivError {
SubjektivError::AuthorizationDenied(reason.into())
}
#[cfg(test)]
mod tests;
+45
View File
@@ -0,0 +1,45 @@
CREATE TABLE subject_permission_changes (
subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT,
change_id TEXT NOT NULL CHECK (length(change_id) > 0),
record_json TEXT NOT NULL,
PRIMARY KEY (subject_id, change_id)
);
CREATE TABLE subject_permissions (
subject_id TEXT PRIMARY KEY REFERENCES subjects(subject_id) ON DELETE RESTRICT,
change_id TEXT NOT NULL,
FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE subject_permission_receipts (
operation_id TEXT PRIMARY KEY CHECK (length(operation_id) > 0),
subject_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
change_id TEXT NOT NULL,
FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT
);
CREATE TABLE worker_execution_grants (
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
subject_id TEXT NOT NULL,
source_change_id TEXT NOT NULL,
record_json TEXT NOT NULL,
PRIMARY KEY (runtime_id, worker_id),
FOREIGN KEY (subject_id, source_change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT
);
CREATE TRIGGER subject_permission_changes_no_update BEFORE UPDATE ON subject_permission_changes BEGIN
SELECT RAISE(ABORT, 'Subject permission changes are immutable');
END;
CREATE TRIGGER subject_permission_changes_no_delete BEFORE DELETE ON subject_permission_changes BEGIN
SELECT RAISE(ABORT, 'Subject permission changes are retained');
END;
CREATE TRIGGER subject_permission_receipts_no_update BEFORE UPDATE ON subject_permission_receipts BEGIN
SELECT RAISE(ABORT, 'Subject permission receipts are immutable');
END;
CREATE TRIGGER subject_permission_receipts_no_delete BEFORE DELETE ON subject_permission_receipts BEGIN
SELECT RAISE(ABORT, 'Subject permission receipts are retained');
END;
CREATE TRIGGER worker_execution_grants_no_update BEFORE UPDATE ON worker_execution_grants BEGIN
SELECT RAISE(ABORT, 'Worker execution grants are immutable');
END;
CREATE TRIGGER worker_execution_grants_no_delete BEFORE DELETE ON worker_execution_grants BEGIN
SELECT RAISE(ABORT, 'Worker execution grants are retained');
END;
+656
View File
@@ -0,0 +1,656 @@
//! Real SQLite component tests through store APIs; Host authentication, canonical singleton
//! ownership and live lineage checks are outside this suite. Root origins are Host-attested inputs;
//! Journal fixtures exercise defensive attribution only, never execution authority.
use super::*;
use crate::tests::{open_store, role};
use feature_storage::FeatureStorage;
use std::path::Path;
fn permission(operation: &str, path: &str, recursive: bool) -> ScopedPermission {
ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Workdir {
working_directory_id: "wd".into(),
path: path.into(),
recursive,
},
}
}
fn update(
operation: &str,
expected: Option<&str>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissionsUpdate {
SubjectPermissionsUpdate {
operation_id: operation.into(),
expected_change_id: expected.map(Into::into),
execution: Vec::new(),
delegation,
}
}
fn grant(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant {
WorkerExecutionGrant {
subject_id: subject.into(),
origin_runtime_id: "runtime".into(),
origin_worker_id: "body".into(),
source_change_id: source.into(),
runtime_id: "runtime".into(),
worker_id: worker.into(),
profile: "builtin:coder".into(),
profile_digest: "resolved-profile-sha256".into(),
permissions: vec![permission("file.read", "src", false)],
redelegation: Vec::new(),
}
}
fn schema(store: &SubjektivStore) -> Vec<(String, String)> {
store.database.try_with_connection::<_, SubjektivError>(|connection| {
let mut statement = connection.prepare("SELECT name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")?;
Ok(statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?.collect::<std::result::Result<_, _>>()?)
}).unwrap()
}
fn open_version(
root: &Path,
version: usize,
) -> (FeatureStorage, ScopedFeatureStorage, SubjektivStore) {
let manager = FeatureStorage::new(root);
let scope = manager.scope("workspace-a").unwrap();
let registration = scope
.register(FeatureRegistration::new(
SUBJEKTIV_FEATURE_ID,
&MIGRATIONS[..version],
))
.unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
(manager, scope, store)
}
#[test]
fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() {
let temp = tempfile::tempdir().unwrap();
let subject;
let first;
let first_update = update("allow", None, vec![permission("file.read", ".", true)]);
{
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
subject = store.create_subject(role()).unwrap().id;
assert!(store.subject_permissions(&subject).unwrap().is_none());
first = store
.set_subject_permissions(&subject, &first_update)
.unwrap();
assert_eq!(
store
.set_subject_permissions(&subject, &first_update)
.unwrap(),
first
);
assert!(matches!(
store.set_subject_permissions(&subject, &update("stale", None, Vec::new())),
Err(SubjektivError::SubjectPermissionsConflict(_))
));
assert!(matches!(
store.set_subject_permissions(&subject, &update("allow", None, Vec::new())),
Err(SubjektivError::PermissionOperationConflict(_))
));
let other = store.create_subject(role()).unwrap();
assert!(matches!(
store.set_subject_permissions(&other.id, &first_update),
Err(SubjektivError::PermissionOperationConflict(_))
));
let same = store
.set_subject_permissions(
&subject,
&update("same", Some(&first.change_id), first.delegation.clone()),
)
.unwrap();
assert_ne!(same.change_id, first.change_id);
let revoked = store
.set_subject_permissions(
&subject,
&update("revoke", Some(&same.change_id), Vec::new()),
)
.unwrap();
assert!(revoked.delegation.is_empty());
assert_eq!(
store
.set_subject_permissions(&subject, &first_update)
.unwrap(),
first
);
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(revoked));
}
let (_reopened_manager, _reopened_scope, reopened) = open_store(temp.path(), "workspace-a");
assert!(
reopened
.subject_permissions(&subject)
.unwrap()
.unwrap()
.delegation
.is_empty()
);
assert_eq!(
reopened
.set_subject_permissions(&subject, &first_update)
.unwrap(),
first
);
}
#[test]
fn failed_receipt_insert_rolls_back_source_history_and_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let first = store
.set_subject_permissions(&subject, &update("first", None, Vec::new()))
.unwrap();
store.database.try_transaction::<_, SubjektivError>(|tx| {
tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
Ok(())
}).unwrap();
let replacement = update(
"second",
Some(&first.change_id),
vec![permission("file.read", ".", true)],
);
assert!(
store
.set_subject_permissions(&subject, &replacement)
.is_err()
);
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first));
store.database.try_transaction::<_, SubjektivError>(|tx| {
let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
assert_eq!(counts, (1, 1));
tx.execute_batch("DROP TRIGGER fail_permission_receipt")?;
Ok(())
}).unwrap();
assert!(
store
.set_subject_permissions(&subject, &replacement)
.is_ok()
);
}
#[test]
fn scope_and_validation_rejections_do_not_issue_permission_sources() {
let temp = tempfile::tempdir().unwrap();
let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a");
let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b");
let subject = a.create_subject(role()).unwrap().id;
assert!(
b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new()))
.is_err()
);
let cross_workspace = ScopedPermission {
operation: "ticket.create".into(),
resource: PermissionResource::Workspace {
workspace_id: "workspace-b".into(),
},
};
assert!(
a.set_subject_permissions(
&subject,
&update("cross-workspace", None, vec![cross_workspace])
)
.is_err()
);
for (operation, path) in [
("file.*", "src"),
("file..read", "src"),
("file.read", "../src"),
("file.read", "src/../escape"),
] {
assert!(
a.set_subject_permissions(
&subject,
&update("invalid", None, vec![permission(operation, path, true)])
)
.is_err()
);
}
assert!(a.subject_permissions(&subject).unwrap().is_none());
}
#[test]
fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", "src", true)]),
)
.unwrap();
let child = grant(&subject, &source.change_id, "child");
store.bind_worker_execution_grant(&child).unwrap();
store.bind_worker_execution_grant(&child).unwrap();
assert_eq!(
store.worker_execution_grant("runtime", "child").unwrap(),
Some(child.clone())
);
assert!(
store
.journal_body_is_unpublished(&subject, "runtime", "child")
.unwrap()
);
let mut changed = child.clone();
changed.profile_digest = "changed-profile".into();
assert!(matches!(
store.bind_worker_execution_grant(&changed),
Err(SubjektivError::WorkerExecutionGrantConflict(_))
));
for (worker, requested) in [
("write", permission("file.write", "src", false)),
("escape", permission("file.read", ".", true)),
("prefix", permission("file.read", "src-other", false)),
] {
let mut candidate = grant(&subject, &source.change_id, worker);
candidate.permissions = vec![requested];
assert!(store.bind_worker_execution_grant(&candidate).is_err());
assert!(
store
.worker_execution_grant("runtime", worker)
.unwrap()
.is_none()
);
}
let mut self_origin = child.clone();
self_origin.worker_id = "body".into();
assert!(store.bind_worker_execution_grant(&self_origin).is_err());
let other = store.create_subject(role()).unwrap();
store
.claim_journal_writer(&other.id, "runtime", "other-body")
.unwrap();
let mut invalid = grant(&subject, &source.change_id, "other-body");
assert!(store.bind_worker_execution_grant(&invalid).is_err());
invalid.worker_id = "forged-child".into();
invalid.origin_worker_id = "other-body".into();
assert!(store.bind_worker_execution_grant(&invalid).is_err());
}
#[test]
fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
)
.unwrap();
let mut child = grant(&subject, &source.change_id, "child");
child.redelegation = vec![permission("file.read", "src", true)];
store.bind_worker_execution_grant(&child).unwrap();
let mut descendant = grant(&subject, &source.change_id, "grandchild");
descendant.origin_worker_id = "child".into();
store.bind_worker_execution_grant(&descendant).unwrap();
let mut excessive = descendant.clone();
excessive.worker_id = "excessive".into();
excessive.redelegation = vec![permission("file.read", ".", true)];
assert!(store.bind_worker_execution_grant(&excessive).is_err());
let mut nondelegator = descendant.clone();
nondelegator.origin_worker_id = "grandchild".into();
nondelegator.worker_id = "great-grandchild".into();
assert!(store.bind_worker_execution_grant(&nondelegator).is_err());
let mut stale = descendant.clone();
stale.worker_id = "after-source-replacement".into();
let revoked = store
.set_subject_permissions(
&subject,
&update("revoke", Some(&source.change_id), Vec::new()),
)
.unwrap();
assert_ne!(revoked.change_id, source.change_id);
stale.origin_worker_id = "new-body".into();
assert!(store.bind_worker_execution_grant(&stale).is_err());
// Exact retries/readback recover evidence but cannot revive the source or origin.
store.bind_worker_execution_grant(&child).unwrap();
assert_eq!(
store.worker_execution_grant("runtime", "child").unwrap(),
Some(child)
);
store.retire_subject(&subject).unwrap();
stale.source_change_id = revoked.change_id;
assert!(store.bind_worker_execution_grant(&stale).is_err());
}
#[test]
fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceilings() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
)
.unwrap();
// The Host has authenticated root ownership outside this store. No Journal record is
// required; changing Journal writer state cannot supply or invalidate that authentication.
store
.bind_worker_execution_grant(&grant(&subject, &source.change_id, "without-journal"))
.unwrap();
let journal = store
.claim_journal_writer(&subject, "runtime", "body")
.unwrap();
store
.bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-active-journal"))
.unwrap();
store.release_journal_writer(&journal).unwrap();
store
.bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-released-journal"))
.unwrap();
// An active Journal binding must never upgrade a child's empty redelegation ceiling.
store
.claim_journal_writer(&subject, "runtime", "without-journal")
.unwrap();
let mut descendant = grant(&subject, &source.change_id, "descendant");
descendant.origin_worker_id = "without-journal".into();
assert!(matches!(
store.bind_worker_execution_grant(&descendant),
Err(SubjektivError::AuthorizationDenied(_))
));
assert!(
store
.worker_execution_grant("runtime", "descendant")
.unwrap()
.is_none()
);
}
#[test]
fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
)
.unwrap();
let mut child = grant(&subject, &source.change_id, "child");
child.redelegation = source.delegation.clone();
store.bind_worker_execution_grant(&child).unwrap();
// Closing a retained chain back through its previously unbound root must not create a cycle.
let mut cycle = grant(&subject, &source.change_id, "body");
cycle.origin_worker_id = "child".into();
assert!(matches!(
store.bind_worker_execution_grant(&cycle),
Err(SubjektivError::AuthorizationDenied(_))
));
let other = store.create_subject(role()).unwrap().id;
let other_source = store
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
.unwrap();
let mut foreign = grant(&other, &other_source.change_id, "foreign-child");
foreign.origin_worker_id = "child".into();
assert!(matches!(
store.bind_worker_execution_grant(&foreign),
Err(SubjektivError::AuthorizationDenied(_))
));
let current = store
.set_subject_permissions(
&subject,
&update("replace", Some(&source.change_id), source.delegation),
)
.unwrap();
let mut stale_parent = grant(&subject, &current.change_id, "stale-parent-child");
stale_parent.origin_worker_id = "child".into();
assert!(matches!(
store.bind_worker_execution_grant(&stale_parent),
Err(SubjektivError::AuthorizationDenied(_))
));
}
#[test]
fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
)
.unwrap();
let child = grant(&subject, &source.change_id, "child");
store.database.try_transaction::<_, SubjektivError>(|tx| {
tx.execute_batch("CREATE TRIGGER fail_grant BEFORE INSERT ON worker_execution_grants BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?;
Ok(())
}).unwrap();
assert!(store.bind_worker_execution_grant(&child).is_err());
assert!(
store
.worker_execution_grant("runtime", "child")
.unwrap()
.is_none()
);
store
.database
.try_transaction::<_, SubjektivError>(|tx| {
tx.execute_batch("DROP TRIGGER fail_grant")?;
Ok(())
})
.unwrap();
store.bind_worker_execution_grant(&child).unwrap();
for sql in [
"UPDATE worker_execution_grants SET source_change_id='forged'",
"DELETE FROM worker_execution_grants",
"UPDATE subject_permission_changes SET record_json='{}'",
"DELETE FROM subject_permission_receipts",
] {
assert!(
store
.database
.try_transaction::<_, SubjektivError>(|tx| {
tx.execute_batch(sql)?;
Ok(())
})
.is_err(),
"{sql}"
);
}
assert_eq!(
store.worker_execution_grant("runtime", "child").unwrap(),
Some(child)
);
}
#[test]
fn current_schema_migration_preserves_subject_and_matches_new_database() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("migrated");
let original;
{
let (_old_manager, _old_scope, old) = open_version(&root, 11);
original = old
.create_subject_with_behavior(role(), "Keep behavior separate".into())
.unwrap();
}
let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a");
assert_eq!(
migrated.subject(&original.id).unwrap(),
Some(original.clone())
);
assert!(
migrated
.subject_permissions(&original.id)
.unwrap()
.is_none()
);
let (_fresh_manager, _fresh_scope, fresh) =
open_store(&temp.path().join("fresh"), "workspace-a");
assert_eq!(schema(&migrated), schema(&fresh));
let source = migrated
.set_subject_permissions(&original.id, &update("new-source", None, Vec::new()))
.unwrap();
assert!(!source.change_id.is_empty());
assert_eq!(migrated.subject(&original.id).unwrap(), Some(original));
}
#[test]
fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_survive_reopen() {
let temp = tempfile::tempdir().unwrap();
let saved;
{
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
assert!(
store
.bind_worker_execution_grant(&grant(&subject, "unset", "child"))
.is_err()
);
let mut execution_only = update("execution", None, Vec::new());
execution_only.execution = vec![permission("file.read", ".", true)];
let source = store
.set_subject_permissions(&subject, &execution_only)
.unwrap();
assert!(
store
.bind_worker_execution_grant(&grant(&subject, &source.change_id, "child"))
.is_err()
);
let source = store
.set_subject_permissions(
&subject,
&update(
"delegation",
Some(&source.change_id),
execution_only.execution,
),
)
.unwrap();
saved = grant(&subject, &source.change_id, "child");
store.bind_worker_execution_grant(&saved).unwrap();
}
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert_eq!(
store.worker_execution_grant("runtime", "child").unwrap(),
Some(saved.clone())
);
store.bind_worker_execution_grant(&saved).unwrap();
let mut conflicting = saved;
conflicting.profile = "builtin:default".into();
assert!(matches!(
store.bind_worker_execution_grant(&conflicting),
Err(SubjektivError::WorkerExecutionGrantConflict(_))
));
}
#[test]
fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let other = store.create_subject(role()).unwrap().id;
assert!(
store
.list_subject_worker_execution_grants(&subject)
.unwrap()
.is_empty()
);
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
)
.unwrap();
let other_source = store
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
.unwrap();
let other_grant = grant(&other, &other_source.change_id, "other-child");
store.bind_worker_execution_grant(&other_grant).unwrap();
for index in (0..MAX_SUBJECT_WORKER_EXECUTION_GRANTS).rev() {
store
.bind_worker_execution_grant(&grant(
&subject,
&source.change_id,
&format!("child-{index:04}"),
))
.unwrap();
}
store
.set_subject_permissions(
&subject,
&update("revoke", Some(&source.change_id), Vec::new()),
)
.unwrap();
store.retire_subject(&subject).unwrap();
let listed = store
.list_subject_worker_execution_grants(&subject)
.unwrap();
assert_eq!(listed.len(), MAX_SUBJECT_WORKER_EXECUTION_GRANTS);
assert!(
listed
.iter()
.all(|grant| grant.subject_id == subject && grant.source_change_id == source.change_id)
);
assert!(
listed
.windows(2)
.all(|pair| (&pair[0].runtime_id, &pair[0].worker_id)
< (&pair[1].runtime_id, &pair[1].worker_id))
);
assert_eq!(
store.list_subject_worker_execution_grants(&other).unwrap(),
vec![other_grant.clone()]
);
// Seed one more retained row directly: the retired Subject cannot issue new grants.
// Listing must report overflow rather than returning a seemingly complete cancellation set.
let overflow = grant(&subject, &source.change_id, "overflow");
store.database.try_transaction::<_, SubjektivError>(|tx| {
tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)",
params![overflow.runtime_id, overflow.worker_id, overflow.subject_id, overflow.source_change_id, serde_json::to_string(&overflow)?])?;
Ok(())
}).unwrap();
assert!(
matches!(store.list_subject_worker_execution_grants(&subject), Err(SubjektivError::WorkerExecutionGrantListOverflow { subject_id, limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS }) if subject_id == subject)
);
assert_eq!(
store.list_subject_worker_execution_grants(&other).unwrap(),
vec![other_grant]
);
let (_foreign_manager, _foreign_scope, foreign) =
open_store(&temp.path().join("foreign"), "workspace-b");
assert!(matches!(
foreign.list_subject_worker_execution_grants(&subject),
Err(SubjektivError::SubjectNotFound(_))
));
}
#[test]
fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() {
let temp = tempfile::tempdir().unwrap();
let original;
{
let (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
original = old.create_subject(role()).unwrap();
old.database
.try_transaction::<_, SubjektivError>(|tx| {
tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?;
Ok(())
})
.unwrap();
}
{
let manager = FeatureStorage::new(temp.path());
let scope = manager.scope("workspace-a").unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
assert!(SubjektivStore::open(&scope, &registration).is_err());
}
let (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
assert_eq!(old.database.schema_version().unwrap(), 11);
assert_eq!(old.subject(&original.id).unwrap(), Some(original));
old.database.try_with_connection::<_, SubjektivError>(|connection| {
let created: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_permission_changes')", [], |row| row.get(0))?;
assert!(!created);
Ok(())
}).unwrap();
}
+1
View File
@@ -162,6 +162,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
ticket_assignment: None,
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}
}
+24 -10
View File
@@ -1113,6 +1113,7 @@ fn runtime_local_workdir_router(
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
command_output_scope: manifest::SharedScope,
authorization_client: Option<Arc<dyn WorkspaceClient>>,
) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = Arc::new(WorkdirSessionRouter::new());
for (alias, binding) in attachments {
@@ -1121,10 +1122,7 @@ fn runtime_local_workdir_router(
.copied()
.unwrap_or(WorkdirSessionCapabilities::READ_ONLY);
let scope = runtime_attachment_scope(binding.root(), capabilities)?;
router
.attach(
alias.clone(),
runtime_local_workdir_session(
let source = runtime_local_workdir_session(
&binding.working_directory.id,
binding.root(),
binding.cwd(),
@@ -1132,11 +1130,20 @@ fn runtime_local_workdir_router(
command_output_scope.clone(),
capabilities,
binding.command_environment(),
binding.session_resources().map_err(|error| {
format!("open Workdir attachment `{alias}` resources: {error}")
})?,
binding
.session_resources()
.map_err(|error| format!("open Workdir attachment `{alias}` resources: {error}"))?,
);
let source = match authorization_client.as_ref() {
Some(client) => worker::authorized_workdir::authorized_workdir_session(
source,
client.clone(),
binding.working_directory.id.clone(),
),
)
None => source,
};
router
.attach(alias.clone(), source)
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
}
Ok(router)
@@ -1163,7 +1170,12 @@ fn restored_workdir_router(
local_capabilities.insert(attachment.alias.clone(), attachment.capabilities);
}
}
let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?;
let router = runtime_local_workdir_router(
local_attachments,
&local_capabilities,
scope,
Some(workspace_client.clone()),
)?;
for attachment in logical_attachments {
if let Some(local) = local_attachments.get(&attachment.alias) {
if local.working_directory.id != attachment.working_directory_id {
@@ -1572,6 +1584,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.workdir_attachments,
&workdir_capabilities,
worker.scope().clone(),
Some(worker.workspace_client_handle()),
)?);
if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone())
@@ -10234,7 +10247,8 @@ mod tests {
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let attachments = BTreeMap::from([(alias, binding.clone())]);
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap();
let router =
runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope, None).unwrap();
session_clone.close().await.unwrap();
assert_eq!(
materializer.cleanup_working_directory(id).unwrap_err().code,
File diff suppressed because it is too large Load Diff
+14
View File
@@ -145,6 +145,13 @@ where
pub async fn prepare(
self,
) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> {
crate::feature::authorize_requested_manifest(
self.workspace_context.client_handle().as_ref(),
&self.manifest,
)
.map_err(|error| {
WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string()))
})?;
let mut worker = Worker::from_manifest_with_context_and_model_client(
self.manifest,
self.store,
@@ -197,6 +204,13 @@ where
.await
.map_err(WorkerBootstrapError::Worker)?;
crate::feature::authorize_requested_manifest(
worker.workspace_client_handle().as_ref(),
worker.manifest(),
)
.map_err(|error| {
WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string()))
})?;
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
+26 -3
View File
@@ -1453,6 +1453,14 @@ where
// Worker-immutable snapshots taken before the mutable worker borrow
// below so the worker borrow doesn't conflict with reads on `worker`.
let feature_config = worker.manifest().feature.clone();
// First boundary: before any Feature initialization, Tool/WIP/service
// registration, prompt contribution, or discovery side effect. Host derives
// the live caller grant; the Profile only requests these switches.
crate::feature::authorize_requested_manifest(
worker.workspace_client_handle().as_ref(),
worker.manifest(),
)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?;
let workdir_sessions = worker.workdir_sessions();
let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some();
let workdir_tool_broker = Some(
@@ -4139,6 +4147,13 @@ mod tests {
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
{
if request.path == "/api/w/workspace-1/authorization/check" {
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Post);
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope":null}).to_string(),
});
}
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
if request.path == "/api/w/workspace-1/drive/capabilities" {
@@ -4217,7 +4232,7 @@ mod tests {
worker.workdir_sessions(),
)
.descriptor();
register_worker_tools(
let registered = register_worker_tools(
&mut worker,
root.path().join("bash-output"),
root.path().join("runtime"),
@@ -4225,8 +4240,16 @@ mod tests {
None,
None,
)
.await
.unwrap();
.await;
if enabled && (!available || workspace.is_none()) {
assert_eq!(
registered.unwrap_err().kind(),
std::io::ErrorKind::PermissionDenied
);
assert!(requests.lock().unwrap().is_empty());
continue;
}
registered.unwrap();
let configured = enabled && available && workspace.is_some();
let tools = worker.engine().tool_server_handle();
tools.flush_pending();
+270
View File
@@ -1751,6 +1751,59 @@ pub enum FeaturePlanError {
/// Builder/installer for enabled feature modules.
pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>,
activation_guard: Option<Arc<dyn Fn() -> Result<(), String> + Send + Sync>>,
}
/// Profile switches request activation, not authority. Names are the actual
/// public config keys (not internal module ids such as `builtin:sub-worker-control`).
/// Check even switches whose runtime prerequisites will later make them inert.
/// No parent/child permission inheritance is performed here.
pub(crate) fn authorize_requested_features(
client: &dyn crate::worker::WorkspaceClient,
config: &manifest::FeatureConfig,
) -> Result<(), crate::worker::WorkspaceClientError> {
for (name, enabled) in [
("task", config.task.enabled),
("memory", config.memory.enabled()),
("subjektiv", config.subjektiv.enabled()),
("web", config.web.enabled),
("image", config.image.enabled),
("sub_worker", config.sub_worker.enabled),
("flow", config.flow.enabled),
("worker", config.worker.enabled),
(
"workspace_worker_discovery",
config.workspace_worker_discovery.enabled,
),
("objective", config.objective.enabled),
("manage_workdir", config.manage_workdir.enabled),
("workdir_catalog", config.workdir_catalog.enabled),
("workspace_config", config.workspace_config.enabled),
("drive", config.drive.enabled),
("ticket", config.ticket.enabled),
("merge_request", config.merge_request.any()),
("orchestration", config.orchestration.enabled),
] {
if enabled {
client.authorize_feature_activation(name).map_err(|error| {
crate::worker::WorkspaceClientError::Request(format!(
"Feature `{name}` activation rejected: {error}"
))
})?;
}
}
Ok(())
}
pub(crate) fn authorize_requested_manifest(
client: &dyn crate::worker::WorkspaceClient,
manifest: &manifest::WorkerManifest,
) -> Result<(), crate::worker::WorkspaceClientError> {
authorize_requested_features(client, &manifest.feature)?;
if !manifest.mcp.stdio_servers.is_empty() {
client.authorize_feature_activation("mcp")?;
}
Ok(())
}
fn build_feature_install_plan(
@@ -1945,6 +1998,7 @@ impl Default for FeatureRegistryBuilder {
fn default() -> Self {
Self {
modules: Vec::new(),
activation_guard: None,
}
}
}
@@ -1954,6 +2008,22 @@ impl FeatureRegistryBuilder {
Self::default()
}
/// Add a trusted, live preflight that runs before *any* module install. Worker
/// hosts use this in addition to the earlier pre-construction preflight.
pub fn with_activation_guard(
mut self,
guard: impl Fn() -> Result<(), String> + Send + Sync + 'static,
) -> Self {
let previous = self.activation_guard.take();
self.activation_guard = Some(Arc::new(move || {
if let Some(previous) = &previous {
previous()?;
}
guard()
}));
self
}
pub fn add_module<M>(&mut self, module: M) -> &mut Self
where
M: FeatureModule + 'static,
@@ -2025,6 +2095,42 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport {
if let Some(error) = self
.activation_guard
.as_ref()
.and_then(|guard| guard().err())
{
return FeatureRegistryInstallReport {
reports: {
let mut descriptors = self.descriptors();
if descriptors.is_empty() {
descriptors.push(FeatureDescriptor::builtin(
"activation",
"Feature activation",
));
}
descriptors
.iter()
.map(|descriptor| {
let mut report = FeatureInstallReport::new(descriptor);
report
.diagnostics
.push(FeatureDiagnostic::error(error.clone()));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"Feature activation rejected before installation",
);
report
})
.collect()
},
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: None,
};
}
let descriptors: Vec<_> = self
.modules
.iter()
@@ -2300,6 +2406,170 @@ mod tests {
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct ActivationClient {
allowed: Vec<String>,
requested: std::sync::Mutex<Vec<String>>,
}
impl crate::worker::WorkspaceClient for ActivationClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"activation-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
assert_eq!(request.path, "/api/w/workspace/authorization/check");
let input: OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.resource, PermissionResource::Feature);
self.requested.lock().unwrap().push(input.operation.clone());
Ok(crate::worker::WorkspaceResponse {
status: if self.allowed.contains(&input.operation) {
200
} else {
403
},
body: "{}".into(),
})
}
}
#[test]
fn requested_activation_uses_exact_public_config_names_before_install() {
let mut config = manifest::FeatureConfig::default();
config.workdir_catalog.enabled = false;
// Enabling each config key independently also detects default-on flags.
for name in [
"task",
"memory",
"subjektiv",
"web",
"image",
"sub_worker",
"flow",
"worker",
"workspace_worker_discovery",
"objective",
"manage_workdir",
"workdir_catalog",
"workspace_config",
"drive",
"ticket",
"merge_request",
"orchestration",
] {
let mut value = serde_json::to_value(&config).unwrap();
if name == "memory" || name == "subjektiv" {
value[name]["profile"]["enabled"] = json!(true);
} else if name == "merge_request" {
value[name]["show"] = json!(true);
} else {
value[name]["enabled"] = json!(true);
}
let requested: manifest::FeatureConfig = serde_json::from_value(value).unwrap();
let client = ActivationClient {
allowed: vec![name.into()],
requested: Default::default(),
};
authorize_requested_features(&client, &requested).unwrap();
assert_eq!(*client.requested.lock().unwrap(), [name], "{name}");
}
}
#[test]
fn ticket_operation_permission_does_not_enable_feature_or_install_services() {
let mut config = manifest::FeatureConfig::default();
config.workdir_catalog.enabled = false;
config.ticket.enabled = true;
for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] {
let client = Arc::new(ActivationClient {
allowed: allowed.iter().map(|name| name.to_string()).collect(),
requested: Default::default(),
});
let module = PlannedServiceFeature::new(
FeatureDescriptor::builtin("ticket", "Ticket")
.with_provided_service(provided_service("ticket.test", "1")),
);
let calls = module.install_calls.clone();
let config = config.clone();
let registry = FeatureRegistryBuilder::new()
.with_module(module)
.with_activation_guard(move || {
authorize_requested_features(client.as_ref(), &config)
.map_err(|error| error.to_string())
});
let mut tools = Vec::new();
let report = registry.install_into_pending(&mut tools, &mut HookRegistryBuilder::new());
let permitted = allowed == ["ticket"];
assert_eq!(report.has_errors(), !permitted);
assert_eq!(calls.load(Ordering::SeqCst), usize::from(permitted));
assert!(tools.is_empty());
if !permitted {
assert!(report.error_message().contains("ticket"));
}
}
}
#[test]
fn local_compatibility_is_explicit_and_never_grants_workspace_operations() {
use crate::worker::{WorkspaceClient, unavailable_workspace_client};
use server_api::authorization::PermissionResource;
let local = unavailable_workspace_client(None, "standalone");
assert!(local.authorize_feature_activation("task").is_ok());
assert!(
local
.authorize_operation(
"ticket.create",
&PermissionResource::Workspace {
workspace_id: "workspace".into()
}
)
.is_err()
);
let unbound = ActivationClient {
allowed: vec!["ticket".into()],
requested: Default::default(),
};
assert!(unbound.authorize_feature_activation("ticket").is_ok());
let local_files = crate::worker::WorkerWorkspaceContext::local_filesystem(None);
assert!(
local_files
.client_handle()
.authorize_feature_activation("task")
.is_ok()
);
assert!(
local_files
.client_handle()
.authorize_operation(
"ticket.create",
&PermissionResource::Workspace {
workspace_id: "workspace".into()
}
)
.is_err()
);
let marker = crate::worker::marker_workspace_client(None, "local-filesystem");
assert!(
marker.authorize_feature_activation("task").is_err(),
"display kind is not authority"
);
let missing = unavailable_workspace_client(
Some(&crate::worker::WorkspaceId::new("workspace").unwrap()),
"Host unavailable",
);
assert!(missing.authorize_feature_activation("ticket").is_err());
}
#[test]
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
let feature_source = include_str!("feature.rs")
@@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
self.inner.is_available()
}
fn authorize_operation(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
self.inner.authorize_operation(operation, resource)
}
fn authorize_operation_scope(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
self.inner.authorize_operation_scope(operation, resource)
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
self.inner.authorize_feature_activation(feature)
}
fn execute(
&self,
request: WorkspaceRequest,
@@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use uuid::Uuid;
use protocol::Segment;
use server_api::{
@@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest {
pub profile: String,
pub singleton_key: Option<String>,
pub ticket_id: Option<String>,
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
pub operation_id: Option<String>,
/// Explicit requested authority. Profile selection alone grants nothing.
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
pub display_name: String,
pub initial_submit: Vec<Segment>,
}
@@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService {
fn workspace_worker_create_request(
request: WorkerLifecycleSpawnRequest,
) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> {
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => {
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id,
})
}
(None, None) => None,
_ => {
let control_operation_id = request.operation_id.filter(|id| {
!id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
}).ok_or_else(|| WorkspaceClientError::Request(
"operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into()
))?;
if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() {
return Err(WorkspaceClientError::Request(
"ticket_id and operation_id must be provided together".to_string(),
"orchestrator_assignment_id requires ticket_id".into(),
));
}
};
let control_operation_id = ticket_assignment
.as_ref()
.map(|assignment| assignment.operation_id.clone())
.unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7()));
if let Some(grant) = &request.execution_grant {
grant.validate().map_err(WorkspaceClientError::Request)?;
}
let ticket_assignment =
request
.ticket_id
.map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id: control_operation_id.clone(),
});
Ok(CreateWorkspaceWorkerRequest {
runtime_id: request.runtime_id,
display_name: request.display_name,
@@ -551,6 +555,7 @@ fn workspace_worker_create_request(
.collect(),
feature_connections: Default::default(),
control_operation_id: Some(control_operation_id),
execution_grant: request.execution_grant,
})
}
@@ -744,7 +749,13 @@ struct WorkerSpawnInput {
runtime_id: String,
/// Singular Workdir selection wrapped into the shared attachment collection
/// under the stable `workdir` alias.
working_directory_id: String,
#[serde(default)]
working_directory_id: Option<String>,
/// Stable caller-chosen intent ID. Exact retries retain this ID and full input.
operation_id: String,
/// Explicit permission/resource request; selecting a Profile grants nothing.
#[serde(default)]
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
profile: String,
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
#[serde(default)]
@@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool {
.ticket_id
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
.transpose()?;
let operation_id = ticket_id
.as_ref()
.map(|ticket_id| {
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
})
.transpose()?;
let operation_id = Some(authority_id(&input.operation_id, "operation_id")?);
let workdir_attachments = match input.working_directory_id {
Some(id) => vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(&id, "working_directory_id")?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
None if input.relative_cwd.is_none() => Vec::new(),
None => {
return Err(ToolError::ExecutionFailed(
"relative_cwd requires working_directory_id".into(),
));
}
};
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
@@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool {
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
workdir_attachments,
execution_grant: input.execution_grant,
profile: non_empty(input.profile, "profile")?,
singleton_key: input.singleton_key,
ticket_id,
@@ -1594,6 +1606,7 @@ mod tests {
"relative_cwd": "crates/worker",
"profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS",
"operation_id": "stable-ticket-intent",
"initial_submit": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Implement Ticket 00001" }
@@ -1623,7 +1636,7 @@ mod tests {
body["ticket_assignment"],
serde_json::json!({
"ticket_id": "00001KZ9E0DBS",
"operation_id": "worker-spawn:00001KZ9E0DBS:call-1"
"operation_id": "stable-ticket-intent"
})
);
assert_eq!(body["workdir_attachments"].as_array().unwrap().len(), 1);
@@ -1910,10 +1923,80 @@ mod tests {
);
}
#[tokio::test]
async fn ordinary_spawn_requires_stable_id_and_forwards_grant_without_ticket_or_workdir() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Spawn,
control: test_control(client.clone()),
runtime_worker_control: true,
};
let mut input = serde_json::json!({"runtime_id":"runtime-1", "profile":"builtin:coder"});
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
input["operation_id"] = serde_json::json!("ordinary-intent-1");
input["execution_grant"] = serde_json::json!({"permissions":[{"operation":"ticket", "resource":{"kind":"feature"}}], "redelegation":[]});
for call_id in ["call-1", "retry-another-call"] {
tool.execute(
&input.to_string(),
ToolExecutionContext::new(call_id, "batch", 0),
)
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body, requests[1].body);
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["control_operation_id"], "ordinary-intent-1");
assert_eq!(body["execution_grant"], input["execution_grant"]);
assert_eq!(body["workdir_attachments"], serde_json::json!([]));
assert!(
body.get("ticket_assignment")
.is_none_or(|value| value.is_null())
);
}
#[test]
fn lifecycle_spawn_rejects_missing_or_blank_id_before_backend() {
for operation_id in [
None,
Some("".into()),
Some(" ".into()),
Some("bad\nidentity".into()),
] {
let result = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: None,
runtime_id: "runtime-1".into(),
workdir_attachments: Vec::new(),
profile: "builtin:coder".into(),
singleton_key: None,
ticket_id: None,
operation_id,
execution_grant: None,
display_name: "Worker".into(),
initial_submit: Vec::new(),
});
assert!(result.unwrap_err().to_string().contains("operation_id"));
}
}
#[test]
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
let text = serde_json::to_string(&schema).unwrap();
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("operation_id"))
);
assert!(text.contains("execution_grant"));
assert!(text.contains("initial_submit"));
assert!(text.contains("ticket_id"));
assert!(text.contains("selector"));
@@ -1941,7 +2024,8 @@ mod tests {
profile: "builtin:coder".to_string(),
singleton_key: Some("subjektiv:subject-1".to_string()),
ticket_id: None,
operation_id: None,
operation_id: Some("ordinary-spawn-intent-1".into()),
execution_grant: None,
display_name: "Coder".to_string(),
initial_submit: vec![
Segment::Flow {
@@ -1991,6 +2075,7 @@ mod tests {
singleton_key: None,
ticket_id: Some("T-719".to_string()),
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
execution_grant: None,
display_name: "Worker · T-719".to_string(),
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
})
@@ -147,6 +147,7 @@ impl Tool for SpawnTicketWorkerTool {
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
display_name: format!("Worker · {}", ticket.resource_key),
initial_submit,
execution_grant: None,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
+365 -3
View File
@@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
/// Typed creation without a synthetic Tool context. The default keeps legacy
/// handoff-only fixture implementations fail-closed; production installs
/// provide creation through the shared authorized backend dispatch.
fn create(&self, _input: NewTicket) -> TicketResult<TicketRef> {
Err(TicketError::Conflict(
"Ticket creation service is unavailable".into(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -526,6 +535,7 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
access: TicketFeatureAccess,
}
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
@@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
// Services retain the same Profile ceiling as Tools and WIP. Host
// permission cannot turn a read-only Feature surface into an author.
if !self.access.authoring {
return Err(TicketError::Conflict(
"Ticket creation is disabled by the Profile authoring policy".into(),
));
}
self.backend.create(input)
}
}
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
@@ -801,8 +822,17 @@ impl FeatureModule for TicketFeature {
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let workspace_client = self.workspace_client();
// Direct registry installs do not necessarily have a matching manifest
// switch. The public module identity still requires live activation
// authority before any Tool, instruction or service contribution.
workspace_client
.authorize_feature_activation(FEATURE_ID)
.map_err(|error| {
FeatureInstallError::Install(format!("Ticket activation rejected: {error}"))
})?;
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
access: self.access,
});
context.services().provide(
ServiceDeclaration::new(
@@ -1104,6 +1134,22 @@ impl WorkspaceHttpTicketBackend {
"Tickets must be created open; closing requires a case judgment".into(),
));
}
// Shared trusted boundary for Tool, WIP and Ticket service callers.
// Activation (`ticket`) is not `ticket.create`. Other Ticket operations
// are not yet adapted to this seam; missing checks there are not
// supplied automatically by a universal Tool executor.
client
.authorize_operation(
"ticket.create",
&server_api::authorization::PermissionResource::Workspace {
workspace_id: workspace_id.clone(),
},
)
.map_err(|error| {
TicketError::Conflict(format!(
"Ticket create authorization failed: {error}"
))
})?;
let ticket = Self::request(
client,
WorkspaceRequestMethod::Post,
@@ -3541,9 +3587,13 @@ mod tests {
}
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
let client: Arc<dyn WorkspaceClient> = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed: true,
backend_status: 200,
requests: Default::default(),
});
ticket_tools_feature(client, access)
}
@@ -4075,6 +4125,7 @@ mod tests {
});
let service = WorkspaceTicketService {
access: TicketFeatureAccess::read_only(),
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
@@ -4095,11 +4146,322 @@ mod tests {
}
}
// This suite keeps the real Feature/Tool/WIP/backend-dispatch code; only the
// Host transport is replaced. Host grant/storage behavior is tested at Server.
#[derive(Debug)]
struct CreateAuthorizationClient {
workspace: Option<&'static str>,
activation_allowed: bool,
allowed: bool,
backend_status: u16,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for CreateAuthorizationClient {
fn workspace_id(&self) -> Option<&str> {
self.workspace
}
fn kind(&self) -> &str {
"create-authorization-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
let authorization = request.path.ends_with("/authorization/check");
let mut permitted = self.allowed;
if authorization {
assert_eq!(request.method, WorkspaceRequestMethod::Post);
let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
if body["operation"] == "ticket" {
assert_eq!(
body,
json!({"operation":"ticket", "resource":{"kind":"feature"}})
);
permitted = self.activation_allowed;
} else {
assert_eq!(
body,
json!({"operation":"ticket.create", "resource":{"kind":"workspace","workspace_id":"workspace"}})
);
}
} else {
assert_eq!(request.path, "/api/w/workspace/tickets");
}
self.requests.lock().unwrap().push(request);
Ok(crate::worker::WorkspaceResponse {
status: if authorization {
if permitted { 200 } else { 403 }
} else {
self.backend_status
},
body: if authorization {
if permitted {
"{}".into()
} else {
json!({"message":"Ticket permission revoked"}).to_string()
}
} else if self.backend_status != 200 {
json!({"error":"Forbidden", "message":"backend create denied", "diagnostics":[]}).to_string()
} else {
serde_json::to_string(&TicketRef {
id: "created-1".into(),
resource_key: Some("T-1".into()),
slug: "created".into(),
status: ticket::TicketStatus::Open,
})
.unwrap()
},
})
}
}
#[tokio::test]
async fn create_authorization_guards_tool_wip_installed_service_and_backend_without_local_root()
{
for (allowed, backend_status) in [(false, 200), (true, 200), (true, 403)] {
for surface in ["tool", "wip", "service", "backend"] {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed,
backend_status,
requests: Default::default(),
});
let result = match surface {
"backend" => WorkspaceHttpTicketBackend::new(client.clone())
.create(NewTicket::new("Create"))
.map(|_| ())
.map_err(|error| error.to_string()),
"service" => {
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(
&mut Vec::new(),
&mut HookRegistryBuilder::default(),
);
assert!(!report.has_errors(), "{}", report.error_message());
let service = report
.services
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
.unwrap();
service
.create(NewTicket::new("Create"))
.map(|_| ())
.map_err(|error| error.to_string())
}
"tool" => {
let tools = enabled_ticket_definitions(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
);
let (_, tool) = tools
.into_iter()
.find(|definition| definition().0.name == "TicketCreate")
.unwrap()();
tool.execute(r#"{"title":"Create"}"#, ToolExecutionContext::direct())
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
_ => {
let tools = native_ticket_tools(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
)
.unwrap();
let handler = TicketCollectionWipHandler {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
handler
.call(
"create",
&BTreeMap::from([(
"title".into(),
WipValue::String("Create".into()),
)]),
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "test".into(),
},
)
.await
.map(|_| ())
.map_err(|error| match error {
WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
message,
..
}) => message,
_ => panic!("a definitive denial is not outcome-unknown"),
})
}
};
assert_eq!(
result.is_ok(),
allowed && backend_status == 200,
"{surface}: {result:?}"
);
if allowed && backend_status == 403 {
let error = result.unwrap_err();
assert!(error.contains("HTTP status 403"), "{surface}: {error}");
assert!(
error.contains("backend create denied"),
"{surface}: {error}"
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(
requests.len(),
1 + usize::from(allowed) + usize::from(surface == "service"),
"{surface}"
);
assert!(requests[0].path.ends_with("/authorization/check"));
assert_eq!(
requests
.iter()
.filter(|request| request.path.ends_with("/tickets"))
.count(),
usize::from(allowed)
);
}
}
}
#[test]
fn direct_ticket_install_requires_activation_even_without_manifest_switch() {
assert!(!manifest::FeatureConfig::default().ticket.enabled);
for create_allowed in [false, true] {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: false,
allowed: create_allowed,
backend_status: 200,
requests: Default::default(),
});
let mut tools = Vec::new();
// No manifest-config guard: this is the explicit module install path.
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
assert!(report.has_errors());
assert!(
report
.error_message()
.contains("Ticket activation rejected")
);
assert!(report.error_message().contains("HTTP status 403"));
assert!(tools.is_empty());
assert!(report.installed_instruction_contributions().is_empty());
assert!(report.services.providers().is_empty());
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
#[test]
fn installed_ticket_service_readonly_profile_cannot_create_with_host_permission() {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed: true,
backend_status: 200,
requests: Default::default(),
});
let mut tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::read_only(),
))
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
assert!(!report.has_errors(), "{}", report.error_message());
assert!(!tools.iter().any(|tool| tool().0.name == "TicketCreate"));
let service = report
.services
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
.unwrap();
assert!(
service
.create(NewTicket::new("No profile bypass"))
.unwrap_err()
.to_string()
.contains("Profile authoring policy")
);
let requests = client.requests.lock().unwrap();
assert_eq!(
requests.len(),
1,
"only activation; no create check or backend effect"
);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["operation"], "ticket");
}
#[test]
fn legacy_handoff_only_service_does_not_gain_creation() {
struct LegacyService;
impl TicketService for LegacyService {
fn ticket_handoff(&self, _: &str) -> Result<TicketHandoff, TicketError> {
unreachable!("creation must not call handoff")
}
}
assert!(
LegacyService
.create(NewTicket::new("No implicit creation"))
.unwrap_err()
.to_string()
.contains("unavailable")
);
}
#[test]
fn create_without_workspace_identity_fails_before_transport_and_backend_denial_is_preserved() {
for (workspace, allowed, backend_status, expected_calls) in
[(None, true, 200, 0), (Some("workspace"), true, 403, 2)]
{
let client = Arc::new(CreateAuthorizationClient {
workspace,
activation_allowed: true,
allowed,
backend_status,
requests: Default::default(),
});
let result =
WorkspaceHttpTicketBackend::new(client.clone()).create(NewTicket::new("Create"));
assert!(result.is_err());
assert_eq!(client.requests.lock().unwrap().len(), expected_calls);
}
}
#[test]
fn workspace_http_backend_executes_ticket_create_operation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut authorization, _) = listener.accept().unwrap();
authorization
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut buffer = [0_u8; 8192];
let len = authorization.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("POST /api/w/workspace-a/authorization/check HTTP/1.1"));
assert!(request.contains("\"operation\":\"ticket.create\""));
assert!(request.contains("\"workspace_id\":\"workspace-a\""));
write!(authorization, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap();
drop(authorization);
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
+1
View File
@@ -1,5 +1,6 @@
#![doc = include_str!("../README.md")]
pub mod authorized_workdir;
mod backend_job;
pub use backend_job::BackendJobExecutionBinding;
pub mod bootstrap;
+25 -2
View File
@@ -326,9 +326,32 @@ mod tests {
use crate::feature::builtin::merge_request::MergeRequestFeature;
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
use crate::hook::HookRegistryBuilder;
use crate::worker::TestWorkspaceHttpClient;
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
};
use manifest::MergeRequestFeatureConfig;
#[derive(Debug)]
struct MetadataOnlyHost;
impl WorkspaceClient for MetadataOnlyHost {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"metadata-fixture"
}
fn is_available(&self) -> bool {
true
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
assert_eq!(feature, "ticket");
Ok(())
}
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
panic!("metadata-only prompt fixture must not perform protected operations")
}
}
// These builtin resources are the model-visible workflow compatibility contract.
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
fn ticket_workflow_fixture(
@@ -351,7 +374,7 @@ mod tests {
mr_config: MergeRequestFeatureConfig,
access: TicketFeatureAccess,
) -> (String, Vec<agen::tool::ToolMeta>) {
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let client = Arc::new(MetadataOnlyHost);
let mut tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
+6
View File
@@ -1987,6 +1987,12 @@ enabled = false
"test"
}
fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> {
// This fixture represents a non-Subject Host admitting ordinary
// Internal Worker configuration, not an operation permission grant.
Ok(())
}
fn is_available(&self) -> bool {
true
}
+8 -3
View File
@@ -6108,6 +6108,10 @@ mod tests {
};
let authoring_client = Arc::new(ScriptedWorkspaceClient::new([
query_response,
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"scope": null}).to_string(),
},
create_response,
comment_response,
]));
@@ -6189,11 +6193,12 @@ mod tests {
.await
.unwrap();
let authoring_requests = authoring_client.requests();
assert_eq!(authoring_requests.len(), 3);
assert_eq!(authoring_requests.len(), 4);
assert!(authoring_requests[0].path.ends_with("/tickets/query"));
assert!(authoring_requests[1].path.ends_with("/tickets"));
assert!(authoring_requests[1].path.ends_with("/authorization/check"));
assert!(authoring_requests[2].path.ends_with("/tickets"));
assert!(
authoring_requests[2]
authoring_requests[3]
.path
.ends_with("/tickets/T-9/thread-events")
);
+376 -1
View File
@@ -1052,6 +1052,116 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
self.execute(request)
}
/// Ask the Host using this client's authenticated source, never a model-supplied
/// identity. Trusted Features call this before protected effects on every surface.
/// This is not a universal Tool executor: a missing call is a Feature bug, and
/// operations not yet adapted to this seam retain only their existing checks.
fn authorize_operation(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
self.authorize_operation_scope(operation, resource)
.map(|_| ())
}
/// Return the Host-selected matching scope for provider-resolved containment.
/// `None` is only the Host's explicit legacy decision, never a transport fallback.
fn authorize_operation_scope(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
use server_api::authorization::{
OperationAuthorizationRequest, OperationAuthorizationResponse, ScopedPermission,
};
if !self.is_available() {
return Err(WorkspaceClientError::Unavailable(
"operation authorization requires a Host-bound Workspace client".into(),
));
}
let workspace_id = self
.workspace_id()
.filter(|id| !id.is_empty())
.ok_or_else(|| {
WorkspaceClientError::Unavailable(
"operation authorization requires Workspace identity".into(),
)
})?;
ScopedPermission {
operation: operation.into(),
resource: resource.clone(),
}
.validate()
.map_err(WorkspaceClientError::Request)?;
let request = workspace_server_json_request(
format!(
"/api/w/{}/authorization/check",
encode_workspace_path_segment(workspace_id)
),
&OperationAuthorizationRequest {
operation: operation.into(),
resource: resource.clone(),
},
)?;
// Blocking transports must not run inside a Tokio runtime. The bound
// transport enforces this timeout; no authorization success is cached.
let execute = || self.execute_with_timeout(request, Duration::from_secs(10));
let response = if tokio::runtime::Handle::try_current().is_ok() {
std::thread::scope(|scope| scope.spawn(execute).join()).map_err(|_| {
WorkspaceClientError::Request("authorization request thread panicked".into())
})??
} else {
execute()?
};
if response.body.len() > 16 * 1024 {
return Err(WorkspaceClientError::Request(
"authorization response exceeded 16 KiB".into(),
));
}
if !response.is_success() {
// Expose only the bounded public reason, not provider diagnostics or
// arbitrary response data. Keep status parseable by Feature adapters.
let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default();
let reason: String = value
.get("message")
.or_else(|| value.get("error"))
.and_then(serde_json::Value::as_str)
.unwrap_or("Host rejected authorization")
.chars()
.take(1024)
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
return Err(WorkspaceClientError::Request(format!(
"authorization denied for {operation}: HTTP status {}: {reason}",
response.status
)));
}
let authorization = serde_json::from_str::<OperationAuthorizationResponse>(&response.body)
.map_err(|error| {
WorkspaceClientError::Request(format!("invalid authorization response: {error}"))
})?;
if authorization
.scope
.as_ref()
.is_some_and(|scope| !scope.covers(resource))
{
return Err(WorkspaceClientError::Request(
"Host authorization scope does not cover requested resource".into(),
));
}
Ok(authorization.scope)
}
/// Activation is separate from operation authority. Only explicit standalone
/// contexts with no Workspace binding retain legacy activation without a Host.
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
self.authorize_operation(
feature,
&server_api::authorization::PermissionResource::Feature,
)
}
/// Binary transport is unavailable unless a host-bound implementation opts in.
fn execute_binary(
&self,
@@ -1170,6 +1280,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
self.inner.current_prompt_projection()
}
fn authorize_operation(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
// The query has no protected effect. Keep the review capability's
// mutation filter intact while asking the bound Host for live authority.
self.inner.authorize_operation(operation, resource)
}
fn authorize_operation_scope(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
self.inner.authorize_operation_scope(operation, resource)
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
self.inner.authorize_feature_activation(feature)
}
fn execute_binary(
&self,
request: WorkspaceBinaryRequest,
@@ -1322,6 +1454,7 @@ struct MarkerWorkspaceClient {
workspace_id: Option<String>,
kind: String,
available: bool,
legacy_local_activation: bool,
reason: String,
}
@@ -1338,6 +1471,18 @@ impl WorkspaceClient for MarkerWorkspaceClient {
self.available
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
// Explicit compatibility for standalone Workers with no Workspace binding.
// A missing/broken Workspace client must never become an implicit grant.
if self.workspace_id.is_none() && self.legacy_local_activation {
return Ok(());
}
self.authorize_operation(
feature,
&server_api::authorization::PermissionResource::Feature,
)
}
fn execute(
&self,
_request: WorkspaceRequest,
@@ -1346,6 +1491,196 @@ impl WorkspaceClient for MarkerWorkspaceClient {
}
}
#[cfg(test)]
mod operation_authorization_tests {
use super::*;
use server_api::authorization::PermissionResource;
#[derive(Debug)]
struct BoundClient {
response: Mutex<WorkspaceResponse>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for BoundClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace/test")
}
fn kind(&self) -> &str {
"bound-authorization-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
panic!("authorization must select the bounded transport")
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
assert_eq!(timeout, Duration::from_secs(10));
self.requests.lock().unwrap().push(request);
Ok(self.response.lock().unwrap().clone())
}
}
#[tokio::test]
async fn authorization_is_bounded_live_and_contains_no_caller_identity() {
let client = BoundClient {
response: Mutex::new(WorkspaceResponse {
status: 200,
body: "{}".into(),
}),
requests: Default::default(),
};
let resource = PermissionResource::Workspace {
workspace_id: "workspace/test".into(),
};
client
.authorize_operation("ticket.create", &resource)
.unwrap();
*client.response.lock().unwrap() = WorkspaceResponse {
status: 403,
body: r#"{"message":"permission revoked","diagnostics":[{"message":"private"}]}"#
.into(),
};
let reason = client
.authorize_operation("ticket.create", &resource)
.unwrap_err()
.to_string();
assert!(reason.contains("HTTP status 403"));
assert!(reason.contains("permission revoked"));
assert!(!reason.contains("private"));
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2, "success must not become a cached grant");
for request in requests.iter() {
assert_eq!(request.method, WorkspaceRequestMethod::Post);
assert_eq!(request.path, "/api/w/workspace%2Ftest/authorization/check");
assert_eq!(
serde_json::from_str::<serde_json::Value>(request.body.as_deref().unwrap())
.unwrap(),
serde_json::json!({ "operation":"ticket.create", "resource":{"kind":"workspace", "workspace_id":"workspace/test"} })
);
}
}
#[test]
fn scoped_authorization_preserves_matching_scope_and_rejects_cross_resource() {
let resource = PermissionResource::Workdir {
working_directory_id: "wd-1".into(),
path: "docs/file.md".into(),
recursive: false,
};
let granted = PermissionResource::Workdir {
working_directory_id: "wd-1".into(),
path: "docs".into(),
recursive: true,
};
let client = BoundClient {
response: Mutex::new(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope":granted}).to_string(),
}),
requests: Default::default(),
};
assert_eq!(
client
.authorize_operation_scope("filesystem.read", &resource)
.unwrap(),
Some(granted)
);
*client.response.lock().unwrap() = WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope":{"kind":"workspace","workspace_id":"workspace/test"}})
.to_string(),
};
assert!(
client
.authorize_operation_scope("filesystem.read", &resource)
.is_err()
);
}
#[test]
fn reviewer_query_preserves_host_scope_and_does_not_widen_mutation_access() {
let scope = PermissionResource::Workdir {
working_directory_id: "wd-1".into(),
path: "docs".into(),
recursive: true,
};
let inner = Arc::new(BoundClient {
response: Mutex::new(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope":scope}).to_string(),
}),
requests: Default::default(),
});
let reviewer = ReviewerChildWorkspaceClient::new(
inner.clone(),
ReviewerContext {
ticket_id: "T-1".into(),
merge_request_id: "MR-1".into(),
},
"capability".into(),
);
let requested = PermissionResource::Workdir {
working_directory_id: "wd-1".into(),
path: "docs/file.md".into(),
recursive: false,
};
assert_eq!(
reviewer
.authorize_operation_scope("filesystem.read", &requested)
.unwrap(),
Some(scope)
);
assert!(
reviewer
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
"/api/w/workspace%2Ftest/tickets",
"{}"
))
.is_err()
);
assert_eq!(
inner.requests.lock().unwrap().len(),
1,
"only the bounded authorization query reaches the Host"
);
*inner.response.lock().unwrap() = WorkspaceResponse {
status: 403,
body: "{}".into(),
};
assert!(
reviewer
.authorize_operation_scope("filesystem.read", &requested)
.is_err()
);
}
#[test]
fn malformed_and_oversized_authorization_responses_fail_closed() {
for body in [
"not-json".into(),
"{\"unexpected_grant\":true}".into(),
" ".repeat(16 * 1024 + 1),
] {
let client = BoundClient {
response: Mutex::new(WorkspaceResponse { status: 200, body }),
requests: Default::default(),
};
assert!(
client
.authorize_operation("ticket", &PermissionResource::Feature)
.is_err()
);
}
}
}
#[cfg(test)]
mod reviewer_client_tests {
use super::*;
@@ -1587,6 +1922,7 @@ pub fn unavailable_workspace_client(
workspace_id: workspace_id.map(|id| id.as_str().to_string()),
kind: "unavailable".to_string(),
available: false,
legacy_local_activation: workspace_id.is_none(),
reason: reason.into(),
})
}
@@ -1601,6 +1937,7 @@ pub fn marker_workspace_client(
reason: format!("workspace client kind `{kind}` does not expose Workspace operations"),
kind,
available: true,
legacy_local_activation: false,
})
}
@@ -1649,7 +1986,15 @@ impl WorkerWorkspaceContext {
}
pub fn local_filesystem(workspace_id: Option<WorkspaceId>) -> Self {
let client = marker_workspace_client(workspace_id.as_ref(), "local-filesystem");
// This constructor explicitly selects legacy standalone activation only
// without Workspace identity; it never authorizes protected operations.
let client = Arc::new(MarkerWorkspaceClient {
workspace_id: workspace_id.as_ref().map(|id| id.as_str().to_string()),
kind: "local-filesystem".into(),
available: true,
legacy_local_activation: workspace_id.is_none(),
reason: "local filesystem context does not expose Workspace operations".into(),
});
Self {
workspace_id,
client,
@@ -3844,6 +4189,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
registry: FeatureRegistryBuilder,
) -> FeatureRegistryInstallReport {
self.backend_job.get_or_init(|| None);
let client = self.workspace_client_handle();
let manifest = self.manifest().clone();
let registry = registry.with_activation_guard(move || {
crate::feature::authorize_requested_manifest(client.as_ref(), &manifest)
.map_err(|error| error.to_string())
});
let worker = self.engine.as_mut().expect("worker taken during run");
let report = registry.install_into_engine(worker, &mut self.hook_builder);
if report.has_errors() {
@@ -10552,6 +10903,12 @@ mod build_summary_prompt_tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
let request: server_api::MemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
@@ -10613,6 +10970,12 @@ mod build_summary_prompt_tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
let request: server_api::SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
@@ -10953,6 +11316,12 @@ mod build_summary_prompt_tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
self.requests
.lock()
.expect("recorded workspace requests lock")
@@ -10985,6 +11354,12 @@ mod build_summary_prompt_tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
self.requests
.lock()
.expect("Flow source request lock")
+20 -2
View File
@@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
Err(WorkspaceClientError::Unavailable("test".to_string()))
}
}
@@ -249,8 +255,14 @@ impl WorkspaceClient for NoopWorkspaceClient {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
Err(WorkspaceClientError::Unavailable(
"test client does not execute requests".to_string(),
))
@@ -912,6 +924,12 @@ impl WorkspaceClient for JournalWorkspaceClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
self.requests.lock().unwrap().push(request.clone());
let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") {
(
+8
View File
@@ -567,6 +567,10 @@ pub struct WorkerSpawnRequest {
/// Trusted Backend operation identity used to make Worker-owned spawns replay-safe.
#[serde(skip, default)]
pub resolved_control_operation: Option<WorkerControlOperation>,
/// Host-derived child execution ceiling, persisted against the ordinary
/// Worker reservation before Runtime activation. Not accepted from JSON.
#[serde(skip, default)]
pub resolved_execution_grant: Option<server_api::authorization::WorkerExecutionGrant>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -7892,6 +7896,7 @@ mod tests {
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
@@ -8182,6 +8187,7 @@ mod tests {
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
@@ -8294,6 +8300,7 @@ mod tests {
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
@@ -8335,6 +8342,7 @@ mod tests {
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
+297 -2
View File
@@ -26,10 +26,12 @@
use subjektiv::api::*;
mod backend_job_profile;
mod subject_authorization;
mod subjektiv_job;
mod subjektiv_journal;
mod subjektiv_journal_browser;
mod subjektiv_memory_journal;
mod workdir_authorization;
mod worker_operations;
mod worker_protocol;
pub(crate) use worker_operations::{
@@ -4378,6 +4380,7 @@ impl WorkspaceApi {
// main-Subject/Memory attachment or its resident/extraction policy.
resolved_subjektiv_attached: request.grants.subjektiv_consolidation.is_some(),
resolved_control_operation: Some(control_operation),
resolved_execution_grant: None,
},
) {
Ok(spawned) => spawned,
@@ -5428,6 +5431,21 @@ impl WorkspaceApi {
crate::store::validate_worker_singleton_key(singleton_key)?;
}
self.validate_worker_spawn_repository_scope(runtime_id, &mut request)?;
if let Some(grant) = request.resolved_execution_grant.as_ref() {
for claim in &mut request.resolved_workdir_attachments {
claim.capabilities = workdir_authorization::intersect_capabilities(
&grant.permissions,
&claim.working_directory_id,
claim.capabilities,
);
if claim.capabilities == workdir::WorkdirSessionCapabilities::EMPTY {
return Err(Error::WorkspacePermissionDenied(
"attachment has no operation within the delegated execution grant".into(),
)
.into());
}
}
}
let workspace_api = self.workspace_api_ref(runtime_id);
request.resolved_workspace_api = Some(workspace_api.clone());
for access in take_new_workdir_repository_access(&mut request)? {
@@ -5527,6 +5545,9 @@ impl WorkspaceApi {
};
drop(subject_guard);
let worker_id = reservation.worker_id;
if let Some(grant) = request.resolved_execution_grant.as_ref() {
subject_authorization::bind_reserved_grant(self, grant, &worker_id.to_string())?;
}
if let Some(assignment) = request.ticket_assignment.as_ref() {
self.store.bind_ticket_assignment_operation_worker(
&self.config.workspace_id,
@@ -7034,6 +7055,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::memory_consolidation(
service.clone(),
))
.merge(server_api::server_api_axum::operation_authorize(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_permissions_get(service.clone()))
.merge(server_api::server_api_axum::subjektiv_subject_permissions_set(service.clone()))
.merge(server_api::server_api_axum::subjektiv_subject_create(
service.clone(),
))
@@ -7616,6 +7642,7 @@ fn worker_spawn_request_from_api(
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
})
}
@@ -8985,6 +9012,114 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn operation_authorize(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::authorization::OperationAuthorizationRequest,
) -> std::result::Result<
server_api::authorization::OperationAuthorizationResponse,
server_api::RepositoryApiError,
> {
let api = self.workspace_api()?.clone();
// Runtime startup/restore can hold Host while asking this callback. The
// authority query uses only current stores, never Runtime or Host reentry.
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let worker = subject_authorization::source_from_context(&context)?;
let scope = subject_authorization::authorize_scope(&api, &worker, &request)?;
Ok(server_api::authorization::OperationAuthorizationResponse { scope })
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_permissions_get(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
) -> std::result::Result<server_api::SubjectPermissionsResponse, server_api::RepositoryApiError>
{
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)?;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
Ok(open_subjektiv_store(&api)?
.subject_permissions(&subject_id)
.map(|permissions| server_api::SubjectPermissionsResponse { permissions })
.map_err(subjektiv_store_error)?)
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_permissions_set(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
request: server_api::authorization::SubjectPermissionsUpdate,
) -> std::result::Result<
server_api::SubjectPermissionsMutationResponse,
server_api::RepositoryApiError,
> {
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)?;
let mutation_api = api.clone();
let mutation_subject = subject_id.clone();
let (permissions, changed) = api
.store
.db_executor()
.run(move || -> ApiResult<_> {
let store = open_subjektiv_store(&mutation_api)?;
let before = store
.subject_permissions(&mutation_subject)
.map_err(subjektiv_store_error)?;
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)
.map_err(subjektiv_store_error)?;
let changed = was_current
&& before.as_ref().map(|source| &source.change_id)
!= Some(&permissions.change_id);
Ok((permissions, changed))
})
.await
.map_err(ApiError::into_repository_api_error)?;
let interruptions = if changed {
subject_authorization::interrupt_subject_workers(&api, &subject_id).await
} else {
Vec::new()
};
Ok(server_api::SubjectPermissionsMutationResponse {
permissions,
interruptions,
})
}
async fn subjektiv_subject_create(
&self,
workspace_id: String,
@@ -16821,6 +16956,18 @@ fn execute_ticket_rest_operation_on_host(
let (result, created_ticket) = match operation {
TicketBackendOperation::Create { input } => {
if let Some(source) = source.as_ref() {
subject_authorization::authorize(
api,
&RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id),
&server_api::authorization::OperationAuthorizationRequest {
operation: "ticket.create".into(),
resource: server_api::authorization::PermissionResource::Workspace {
workspace_id: workspace_id.into(),
},
},
)?;
}
let (reference, ticket) = backend.create_with_snapshot(input).map_err(Error::from)?;
(
TicketBackendOperationResult::TicketRef(reference),
@@ -20810,6 +20957,11 @@ async fn scoped_execute_current_worker_workdir_operation(
context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id));
context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id));
let target_workdir = request.target_workdir;
context.stage = "attachment_validation";
let permission_link = validated_current_worker_attachment_async(&api, &worker, &target_workdir, None).await?;
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&permission_link.workdir_id));
context.stage = "operation_authorization";
workdir_authorization::authorize(&api, &worker, &permission_link, &request.operation)?;
let result = match request.operation {
WorkdirSessionOperation::CommandStart(command) => {
let session_lock = current_worker_session_lock(&api, &worker);
@@ -20901,6 +21053,7 @@ async fn scoped_execute_current_worker_workdir_operation(
context.stage = "session_open";
let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?;
context.stage = "provider_dispatch";
let source = worker::authorized_workdir::authorized_workdir_session(source, subject_authorization::bind_operation_client(&api, &worker), link.workdir_id.clone());
execute_workdir_session_operation(&source, operation)
.await
.map_err(workdir_operation_transport_error)?
@@ -23887,6 +24040,14 @@ async fn scoped_subjektiv_memory_backend(
fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
match error {
crate::subjektiv::SubjektivError::AuthorizationDenied(_) => {
Error::WorkspacePermissionDenied(error.to_string())
}
crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_)
| crate::subjektiv::SubjektivError::PermissionOperationConflict(_)
| crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => {
Error::RepositoryConflict(error.to_string())
}
crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => {
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
}
@@ -24139,6 +24300,7 @@ fn start_memory_staging_consolidation(
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)?;
if result.state != InternalWorkerOperationState::Accepted {
@@ -25512,6 +25674,38 @@ fn authorize_known_worker_permission(
subject: &RuntimeWorkerRef,
permission: &str,
) -> Result<WorkerControlGrantRecord> {
// Child result return is deliberately narrower than control or public chat
// posting. The execution grant names only this origin Worker and only notify.
if permission == "notify" {
if let Some(permissions) = subject_authorization::worker_permissions(api, controller)
.map_err(|error| error.error)?
{
let requested = server_api::authorization::ScopedPermission {
operation: "worker.result".into(),
resource: server_api::authorization::PermissionResource::Worker {
runtime_id: subject.runtime_id.clone(),
worker_id: subject.worker_id.clone(),
},
};
if permissions.iter().any(|granted| granted.covers(&requested)) {
return Ok(WorkerControlGrantRecord {
workspace_id: workspace_id.into(),
grant_id: format!(
"execution-result:{}/{}",
controller.runtime_id, controller.worker_id
),
controller: controller.clone(),
subject: subject.clone(),
relation: "result_return".into(),
origin: "execution_grant".into(),
permissions: vec!["notify".into()],
operation_id: "execution-result".into(),
created_at: String::new(),
revoked_at: None,
});
}
}
}
let grant = api
.store
.get_active_worker_control_grant(workspace_id, controller, subject)?
@@ -25899,6 +26093,7 @@ async fn scoped_start_workspace_orchestrator(
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)?;
if result.state != InternalWorkerOperationState::Accepted || result.worker.is_none() {
@@ -31769,6 +31964,7 @@ async fn create_workspace_worker_authenticated_on_host(
initial_submit,
workdir_attachments,
feature_connections,
execution_grant,
control_operation_id: _,
} = request;
let delegation = ticket_assignment
@@ -31828,6 +32024,38 @@ async fn create_workspace_worker_authenticated_on_host(
&profile,
prompt_catalog.as_ref(),
)?;
let resolved_execution_grant = if let Some(source) = source.as_ref() {
let resolved = resolved_config_bundle
.as_ref()
.ok_or_else(|| Error::Config("resolved Worker Profile bundle is missing".into()))?;
let profile_digest = format!(
"sha256:{}",
Sha256::digest(
serde_json::to_vec(resolved).map_err(|error| Error::Config(error.to_string()))?
)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
subject_authorization::prepare_delegation(
&api,
&RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id),
execution_grant.as_ref(),
&runtime_id,
&profile,
&profile_digest,
singleton_key.as_deref(),
feature_connections.subjektiv.is_some(),
)?
} else {
if execution_grant.is_some() {
return Err(Error::WorkspacePermissionDenied(
"execution grants require an authenticated delegation origin".into(),
)
.into());
}
None
};
let resolved_subjektiv_attached = match feature_connections.subjektiv {
None => {
validate_caller_worker_singleton_key(singleton_key.as_deref())?;
@@ -31907,6 +32135,7 @@ async fn create_workspace_worker_authenticated_on_host(
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation,
resolved_execution_grant,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached,
@@ -37024,6 +37253,7 @@ mod tests {
mod external_checkout_tests;
mod protocol_backpressure_tests;
mod restored_parent_control_tests;
mod subject_authorization_tests;
mod subject_spawn_tests;
mod subjektiv_jobs_tests;
mod subjektiv_journal_tests;
@@ -38111,6 +38341,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -38699,6 +38930,7 @@ mod tests {
],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -38938,6 +39170,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -42195,6 +42428,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
assert!(
api.validate_worker_spawn_repository_scope("runtime-1", &mut workdir_flow_launch)
@@ -42454,6 +42688,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
assert!(
@@ -42548,6 +42783,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await;
@@ -42583,6 +42819,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -42619,6 +42856,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -42710,6 +42948,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -42776,7 +43015,9 @@ mod tests {
async fn production_profile_backend_restores_zero_input_orchestrator() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let config = test_server_config(workspace.path());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = test_server_config(workspace.path());
config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap()));
let store =
Arc::new(SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap());
seed_test_registered_workspace(store.as_ref(), &config)
@@ -42784,6 +43025,8 @@ mod tests {
.unwrap();
let api = WorkspaceApi::new_for_test(config, store).await.unwrap();
let workspace_id = api.config.workspace_id.clone();
let _callback_server =
AuthorizationCallbackServer::start(listener, build_router(api.clone()));
let result = scoped_start_workspace_orchestrator(
State(api.clone()),
@@ -42970,6 +43213,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -43465,6 +43709,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -43766,6 +44011,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -43799,6 +44045,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: Some("control-spawn-retry".to_string()),
execution_grant: None,
};
let Json(first) = spawn_known_worker(
@@ -43875,6 +44122,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
};
let Json(created) =
@@ -44039,6 +44287,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -44138,6 +44387,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
};
let Json(controller) = create_workspace_worker(
State(api.clone()),
@@ -44352,6 +44602,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -45374,6 +45625,7 @@ mod tests {
}),
},
control_operation_id: None,
execution_grant: None,
}
};
@@ -45506,6 +45758,7 @@ mod tests {
}),
},
control_operation_id: None,
execution_grant: None,
};
let Json(created) = scoped_create_workspace_worker(
State(api.clone()),
@@ -46693,6 +46946,7 @@ mod tests {
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_execution_grant: None,
resolved_control_operation: Some(WorkerControlOperation {
operation_id: operation_id.to_string(),
input_fingerprint: format!("sha256:{operation_id}"),
@@ -46936,6 +47190,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -46956,6 +47211,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -46998,6 +47254,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -47041,6 +47298,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -48526,6 +48784,22 @@ mod tests {
);
}
struct AuthorizationCallbackServer(tokio::task::JoinHandle<()>);
impl AuthorizationCallbackServer {
fn start(listener: tokio::net::TcpListener, router: Router) -> Self {
Self(tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
}))
}
}
impl Drop for AuthorizationCallbackServer {
fn drop(&mut self) {
self.0.abort();
}
}
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
let workspace_root = workspace_root.into();
let store_root = workspace_root.join(".test-embedded-runtime-store");
@@ -48605,6 +48879,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -50022,6 +50297,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -50705,13 +50981,16 @@ mod tests {
#[tokio::test]
async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() {
let temp = tempfile::tempdir().unwrap();
let config = test_server_config(temp.path());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = test_server_config(temp.path());
config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap()));
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
let owner = seed_test_api_token(store.as_ref(), "empty-owner");
let foreign = seed_test_api_token(store.as_ref(), "empty-foreign");
let app = build_workspace_server_router(config.clone(), store.clone())
.await
.unwrap();
let _callback_server = AuthorizationCallbackServer::start(listener, app.clone());
let wire =
json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"});
request_json(
@@ -54061,6 +54340,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -54457,6 +54737,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -54674,6 +54955,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -55136,6 +55418,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -55828,6 +56111,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -55931,6 +56215,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
let source_worker = api
.runtime
@@ -56486,6 +56771,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -56554,6 +56840,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -58286,6 +58573,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -58367,6 +58655,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -58507,6 +58796,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -61701,6 +61991,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -64376,6 +64667,7 @@ mod tests {
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
},
None,
)
@@ -65161,6 +65453,7 @@ mod tests {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.expect("spawn worker");
@@ -66612,6 +66905,7 @@ mod tests {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
let spawned = api
.spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn_request)
@@ -67479,6 +67773,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
let dedicated_key = "subjektiv-consolidation:dedicated-subject";
@@ -0,0 +1,484 @@
//! Host binding for cooperative Feature authorization. Profile configuration requests
//! capabilities; it never issues them. Non-Subject Workers retain their existing
//! Backend/provider contracts. Subject bodies with no assignment deny by default.
//!
//! Bindings here are execution grants, not another Worker registry. Current body
//! ownership remains the singleton store. Replacing a permission assignment fences
//! all children issued from it; restoring a Worker cannot refresh that assignment.
//! Checks refuse new effects; they do not undo effects already committed. Trusted
//! Features must call the operation boundary themselves, including alternate surfaces.
use super::*;
use server_api::authorization::{
OperationAuthorizationRequest, PermissionResource, ScopedPermission, SubjectPermissions,
WorkerDelegationRequest, WorkerExecutionGrant,
};
use std::collections::BTreeSet;
fn denied(message: impl Into<String>) -> ApiError {
Error::WorkspacePermissionDenied(message.into()).into()
}
fn policy(api: &WorkspaceApi, subject_id: &str) -> ApiResult<Option<SubjectPermissions>> {
let store = open_subjektiv_store(api)?;
let subject = store
.subject(subject_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| denied("Subject permission source no longer exists"))?;
if subject.state != crate::subjektiv::SubjectState::Active {
return Err(denied("Subject permission source is retired"));
}
Ok(store
.subject_permissions(subject_id)
.map_err(subjektiv_store_error)?)
}
fn current_body_subject(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> ApiResult<Option<String>> {
let retained = api
.store
.retained_worker_singleton_lease(&api.config.workspace_id, worker)?;
let Some(retained) = retained else {
return Ok(None);
};
let Some(subject) = retained.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else {
return Ok(None);
};
// This also rejects a retained lease from a released/replaced body. Never
// fall through to non-Subject compatibility after ownership has ended.
let current = api
.store
.require_current_worker_singleton_owner(&api.config.workspace_id, worker)?
.ok_or_else(|| denied("Subject body no longer owns its singleton"))?;
if current.key != retained.key {
return Err(denied("Subject body ownership changed"));
}
Ok(Some(subject.to_owned()))
}
fn live_child_grant(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> ApiResult<Option<WorkerExecutionGrant>> {
let store = open_subjektiv_store(api)?;
let Some(grant) = store
.worker_execution_grant(&worker.runtime_id, &worker.worker_id)
.map_err(subjektiv_store_error)?
else {
return Ok(None);
};
let source =
policy(api, &grant.subject_id)?.ok_or_else(|| denied("Subject permissions are unset"))?;
if source.change_id != grant.source_change_id {
return Err(denied(
"delegated execution grant was revoked by permission replacement",
));
}
let mut current = grant.clone();
let mut visited = HashSet::new();
for _ in 0..64 {
if !visited.insert((current.runtime_id.clone(), current.worker_id.clone())) {
return Err(denied("cyclic execution grant lineage"));
}
if current.subject_id != grant.subject_id
|| current.source_change_id != source.change_id
|| current
.permissions
.iter()
.chain(current.redelegation.iter())
.any(|permission| {
!source
.delegation
.iter()
.any(|ceiling| ceiling.covers(permission))
})
{
return Err(denied(
"delegated execution exceeds the current Subject ceiling",
));
}
let origin = RuntimeWorkerRef::new(&current.origin_runtime_id, &current.origin_worker_id);
match store
.worker_execution_grant(&origin.runtime_id, &origin.worker_id)
.map_err(subjektiv_store_error)?
{
Some(parent) => {
if current
.permissions
.iter()
.chain(current.redelegation.iter())
.any(|permission| {
!parent
.redelegation
.iter()
.any(|ceiling| ceiling.covers(permission))
})
{
return Err(denied(
"execution grant exceeds its immediate parent's redelegation ceiling",
));
}
current = parent;
}
None => {
if current_body_subject(api, &origin)?.as_deref() != Some(grant.subject_id.as_str())
{
return Err(denied(
"delegation origin is no longer the current Subject body",
));
}
return Ok(Some(grant));
}
}
}
Err(denied(
"execution grant lineage exceeds the bounded delegation depth",
))
}
/// None is explicitly the preexisting non-Subject contract, not an all-powerful
/// grant. Existing Workdir/provider/Ticket checks still apply to those Workers.
pub(super) fn worker_permissions(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> ApiResult<Option<Vec<ScopedPermission>>> {
if let Some(grant) = live_child_grant(api, worker)? {
return Ok(Some(grant.permissions));
}
let Some(subject) = current_body_subject(api, worker)? else {
return Ok(None);
};
Ok(Some(
policy(api, &subject)?
.map(|policy| policy.execution)
.unwrap_or_default(),
))
}
pub(super) fn authorize(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
request: &OperationAuthorizationRequest,
) -> ApiResult<()> {
authorize_scope(api, worker, request).map(|_| ())
}
pub(super) fn authorize_scope(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
request: &OperationAuthorizationRequest,
) -> ApiResult<Option<PermissionResource>> {
request.validate().map_err(denied)?;
if let PermissionResource::Workspace { workspace_id } = &request.resource {
if workspace_id != &api.config.workspace_id {
return Err(denied("resource belongs to another Workspace"));
}
}
let needed = ScopedPermission {
operation: request.operation.clone(),
resource: request.resource.clone(),
};
let Some(permissions) = worker_permissions(api, worker)? else {
return Ok(None);
};
if let Some(permission) = permissions
.iter()
.find(|permission| permission.covers(&needed))
{
return Ok(Some(permission.resource.clone()));
}
Err(denied(format!(
"operation `{}` is not granted for the requested resource",
request.operation
)))
}
pub(super) fn source_from_context(
context: &server_api::ServerRequestContext,
) -> ApiResult<RuntimeWorkerRef> {
let source = context
.runtime_source
.as_ref()
.ok_or_else(|| denied("authorization requires a verified Runtime source"))?;
let worker_id = source
.worker_id
.as_deref()
.ok_or_else(|| denied("authorization requires Runtime-bound Worker identity"))?;
Ok(RuntimeWorkerRef::new(&source.runtime_id, worker_id))
}
/// Resolves authority from the authenticated origin, independently of its enabled
/// Feature/tool set. A model cannot nominate a Subject or inherit body identity.
pub(super) fn prepare_delegation(
api: &WorkspaceApi,
source: &RuntimeWorkerRef,
requested: Option<&WorkerDelegationRequest>,
runtime_id: &str,
profile: &str,
profile_digest: &str,
singleton_key: Option<&str>,
subject_connection: bool,
) -> ApiResult<Option<WorkerExecutionGrant>> {
let child = live_child_grant(api, source)?;
let body = if child.is_none() {
current_body_subject(api, source)?
} else {
None
};
if child.is_none() && body.is_none() {
if requested.is_some() {
return Err(denied(
"only an authenticated Subject delegation source can issue an execution grant",
));
}
return Ok(None);
}
if singleton_key.is_some() || subject_connection {
return Err(denied(
"delegated Workers cannot acquire a Subject body or singleton identity",
));
}
let requested = requested.ok_or_else(|| {
denied("Subject delegation requires explicit least-authority permissions")
})?;
requested.validate().map_err(denied)?;
let (subject_id, origin_runtime_id, origin_worker_id, source_change_id, ceiling) = match child {
Some(grant) => (
grant.subject_id,
source.runtime_id.clone(),
source.worker_id.clone(),
grant.source_change_id,
grant.redelegation,
),
None => {
let subject = body.expect("checked Subject body");
let source_policy = policy(api, &subject)?.ok_or_else(|| {
denied(
"Subject permissions are unset; an authorized administrator must assign them",
)
})?;
(
subject,
source.runtime_id.clone(),
source.worker_id.clone(),
source_policy.change_id,
source_policy.delegation,
)
}
};
if requested
.permissions
.iter()
.chain(requested.redelegation.iter())
.any(|permission| !ceiling.iter().any(|allowed| allowed.covers(permission)))
{
return Err(denied(
"requested delegation exceeds explicit operations, resources or redelegation ceiling",
));
}
if requested.redelegation.iter().any(|permission| {
!requested
.permissions
.iter()
.any(|allowed| allowed.covers(permission))
}) {
return Err(denied(
"redelegation must be within this Worker's effective grant",
));
}
Ok(Some(WorkerExecutionGrant {
subject_id,
origin_runtime_id,
origin_worker_id,
source_change_id,
runtime_id: runtime_id.to_owned(),
worker_id: String::new(),
profile: profile.to_owned(),
profile_digest: profile_digest.to_owned(),
permissions: requested.permissions.clone(),
redelegation: requested.redelegation.clone(),
}))
}
pub(super) fn bind_reserved_grant(
api: &WorkspaceApi,
grant: &WorkerExecutionGrant,
worker_id: &str,
) -> ApiResult<()> {
let mut bound = grant.clone();
bound.worker_id = worker_id.to_owned();
Ok(open_subjektiv_store(api)?
.bind_worker_execution_grant(&bound)
.map_err(subjektiv_store_error)?)
}
/// Host-owned binding for an already authenticated Backend operation. No model
/// input can choose the captured Worker or turn a shared client into a grant.
#[derive(Clone)]
struct BoundOperationClient {
api: WorkspaceApi,
worker: RuntimeWorkerRef,
}
impl std::fmt::Debug for BoundOperationClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoundOperationClient")
.field("worker", &self.worker)
.finish_non_exhaustive()
}
}
impl worker::WorkspaceClient for BoundOperationClient {
fn workspace_id(&self) -> Option<&str> {
Some(&self.api.config.workspace_id)
}
fn kind(&self) -> &str {
"backend-bound-operation"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
_: worker::WorkspaceRequest,
) -> std::result::Result<worker::WorkspaceResponse, worker::WorkspaceClientError> {
Err(worker::WorkspaceClientError::Unavailable(
"this binding provides authorization only".into(),
))
}
fn authorize_operation(
&self,
operation: &str,
resource: &PermissionResource,
) -> std::result::Result<(), worker::WorkspaceClientError> {
authorize(
&self.api,
&self.worker,
&OperationAuthorizationRequest {
operation: operation.into(),
resource: resource.clone(),
},
)
.map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string()))
}
fn authorize_operation_scope(
&self,
operation: &str,
resource: &PermissionResource,
) -> std::result::Result<Option<PermissionResource>, worker::WorkspaceClientError> {
authorize_scope(
&self.api,
&self.worker,
&OperationAuthorizationRequest {
operation: operation.into(),
resource: resource.clone(),
},
)
.map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string()))
}
}
pub(super) fn bind_operation_client(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> Arc<dyn worker::WorkspaceClient> {
Arc::new(BoundOperationClient {
api: api.clone(),
worker: worker.clone(),
})
}
pub(super) async fn interrupt_subject_workers(
api: &WorkspaceApi,
subject_id: &str,
) -> Vec<server_api::SubjectPermissionInterruption> {
let mut workers = BTreeSet::new();
let collected = (|| -> ApiResult<()> {
for grant in open_subjektiv_store(api)?
.list_subject_worker_execution_grants(subject_id)
.map_err(subjektiv_store_error)?
{
workers.insert((grant.runtime_id, grant.worker_id));
}
let key = crate::subjektiv::subject_worker_singleton_key(subject_id)
.map_err(subjektiv_store_error)?;
if let Some(body) = api
.store
.current_worker_singleton_owner(&api.config.workspace_id, &key)?
{
workers.insert((body.worker.runtime_id, body.worker.worker_id));
}
Ok(())
})();
if let Err(error) = collected {
return vec![server_api::SubjectPermissionInterruption {
runtime_id: String::new(),
worker_id: String::new(),
cancellation_accepted: false,
backend_command_sessions_closed: false,
diagnostics: vec![format!(
"permissions committed; interruption targets could not be fully collected: {}",
error.error
)],
}];
}
let mut results = Vec::new();
for (runtime_id, worker_id) in workers {
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
let mut evidence = server_api::SubjectPermissionInterruption {
runtime_id,
worker_id,
cancellation_accepted: false,
backend_command_sessions_closed: false,
diagnostics: Vec::new(),
};
let cancellation_api = api.clone();
let cancellation_worker = worker.clone();
let cancellation = match api.host_executors.host() {
Ok(host) => host.run(move || -> ApiResult<_> {
Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest {
reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None,
}).map_err(RuntimeRegistryError::into_error)?)
}).await,
Err(error) => Err(error.into()),
};
match cancellation {
Ok(result) => {
evidence.cancellation_accepted =
result.state == InternalWorkerOperationState::Accepted;
evidence.diagnostics.extend(
result
.diagnostics
.into_iter()
.map(|diagnostic| diagnostic.message),
);
}
Err(error) => evidence.diagnostics.push(format!(
"Runtime cancellation acceptance unknown or rejected: {}",
error.error
)),
}
match close_worker_workdir_sessions(&api.workdir_sessions, &worker).await {
Ok(()) => evidence.backend_command_sessions_closed = true,
Err(error) => evidence
.diagnostics
.push(format!("Backend command cleanup not confirmed: {error}")),
}
results.push(evidence);
}
results
}
pub(super) fn administration_actor(
context: &server_api::ServerRequestContext,
) -> ApiResult<&RequestActor> {
if context.runtime_source.is_some() || context.worker_source.is_some() {
return Err(denied(
"Subject permission administration is not a Worker capability",
));
}
context
.actor
.as_ref()
.ok_or_else(|| denied("Subject permission administration requires an authenticated owner"))
}
@@ -105,6 +105,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() {
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap();
@@ -279,6 +280,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -682,13 +684,24 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
let mut fixture = manual_worker_assignment_fixture().await;
let root = tempfile::tempdir().unwrap();
let provider_root = root.path().to_path_buf();
// Use an attachment-relative path so Host resource validation succeeds;
// the provider's narrower scope still owns this read refusal.
let provider_scope = manifest::Scope::writable(&provider_root)
.unwrap()
.with_added_deny_rules([manifest::ScopeRule {
target: provider_root.join("private"),
permission: manifest::Permission::Read,
recursive: true,
symlink_policy: manifest::SymlinkPolicy::Resolved,
}])
.unwrap();
// Fresh sessions exercise the real opening/broker/dispatch path on each request.
fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| {
Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound(
workdir::Workdir::new(id),
provider_root.clone(),
provider_root.clone(),
manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()),
manifest::SharedScope::new(provider_scope.clone()),
Caps::READ_ONLY,
)) as WorkdirSessionHandle)
}));
@@ -708,7 +721,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
);
let cases = [
(
json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
json!({"operation": "read", "request": {"path": "private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
"read",
"out_of_scope",
Reason::PathOutOfScope,
@@ -325,6 +325,7 @@ async fn workers(api: &WorkspaceApi) -> (WorkdirlessFixtureRuntime, Vec<RuntimeW
workdir_attachments: vec![],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -183,6 +183,7 @@ impl RealDriveFixture {
workdir_attachments: vec![],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -153,6 +153,7 @@ async fn spawn_pair_member(api: &WorkspaceApi, name: &str) -> RuntimeWorkerRef {
workdir_attachments: vec![],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
File diff suppressed because it is too large Load Diff
@@ -527,6 +527,7 @@ async fn subjektiv_job_legacy_cutover_waits_foreground_then_removes_only_owned_c
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: vec![],
resolved_control_operation: None,
resolved_execution_grant: None,
},
)
.unwrap()
@@ -260,7 +260,7 @@ async fn connected_create(api: &WorkspaceApi, subject: &str, name: &str) -> Runt
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { subject_id: subject.into() }),
}, control_operation_id: None,
}, control_operation_id: None, execution_grant: None,
}),
).await.unwrap();
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
@@ -386,6 +386,56 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
let subject = store
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
.unwrap();
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await;
// This fixture exercises Journal continuity, not unset-policy refusal. Assign
// the companion's exact activation keys through authenticated administration;
// no filesystem, shell or delegation permission is granted to its body.
let update = server_api::authorization::SubjectPermissionsUpdate {
operation_id: "connected-journal-activation".into(),
expected_change_id: None,
execution: [
"task",
"subjektiv",
"web",
"image",
"sub_worker",
"worker",
"workspace_worker_discovery",
"objective",
"manage_workdir",
"workdir_catalog",
"drive",
"ticket",
]
.into_iter()
.map(|name| server_api::authorization::ScopedPermission {
operation: name.into(),
resource: server_api::authorization::PermissionResource::Feature,
})
.collect(),
delegation: Vec::new(),
};
let response = build_router(api.clone())
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
subject.id
))
.header("authorization", format!("Bearer {owner}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&update).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 2 * 1024 * 1024)
.await
.unwrap();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let old = connected_create(&api, &subject.id, "connected-old-body").await;
let old_attr = store
.subject_session_attributions(&subject.id)
@@ -1686,6 +1686,7 @@ impl SubjectRemovalFixture {
}),
},
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -1032,6 +1032,7 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
};
api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request)
.unwrap();
@@ -1580,6 +1581,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() {
}],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -200,6 +200,7 @@ impl Fixture {
workdir_attachments: vec![],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -767,6 +768,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
workdir_attachments: vec![],
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -0,0 +1,537 @@
//! Subject resource checks at the Workdir provider boundary, in addition to existing ceilings.
//!
//! Paths here are attachment-relative lexical resources. Providers still own resolved-path,
//! symlink, capability and checkout validation; this helper never substitutes for those checks.
use server_api::authorization::{PermissionResource, ScopedPermission};
use workdir::{
WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopePermission,
http::WorkdirSessionOperation,
};
use super::{ApiError, ApiResult, Error, RuntimeWorkerRef, WorkspaceApi};
use crate::store::WorkerWorkdirLinkRecord;
const READ: &str = "filesystem.read";
const WRITE: &str = "filesystem.write";
const COMMAND: &str = "bash.execute";
pub(super) fn authorize(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
link: &WorkerWorkdirLinkRecord,
operation: &WorkdirSessionOperation,
) -> ApiResult<()> {
// These are reconciliation/termination of an already registered effect, not execution.
// Keep the existing worker/attachment/opaque-handle ownership checks on these routes.
if is_command_reconciliation(operation) {
return Ok(());
}
let permissions = super::subject_authorization::worker_permissions(api, worker)?;
authorize_permissions(permissions.as_deref(), &link.workdir_id, operation)
}
fn is_command_reconciliation(operation: &WorkdirSessionOperation) -> bool {
matches!(
operation,
WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_)
)
}
fn denied(message: &str) -> ApiError {
Error::WorkspacePermissionDenied(format!("Workdir Subject authorization: {message}")).into()
}
fn resource(
working_directory_id: &str,
operation: &str,
path: &WorkdirPath,
recursive: bool,
) -> ApiResult<ScopedPermission> {
// FsPath also accepts explicitly scoped absolute paths. Subject resources here are
// attachment-relative only, even if the provider separately supports absolute grants.
let normalized = WorkdirPath::new(path.as_str())
.map_err(|_| denied("operation path must be attachment-relative without escape"))?;
let permission = ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Workdir {
working_directory_id: working_directory_id.into(),
path: if normalized.is_root() {
".".into()
} else {
normalized.as_str().into()
},
recursive,
},
};
permission
.validate()
.map_err(|_| denied("invalid normalized operation resource"))?;
Ok(permission)
}
fn require(
permissions: &[ScopedPermission],
working_directory_id: &str,
operation: &str,
path: &WorkdirPath,
recursive: bool,
) -> ApiResult<()> {
let requested = resource(working_directory_id, operation, path, recursive)?;
if permissions.iter().any(|granted| granted.covers(&requested)) {
Ok(())
} else {
Err(denied("operation or resource is outside the current grant"))
}
}
fn authorize_permissions(
permissions: Option<&[ScopedPermission]>,
working_directory_id: &str,
operation: &WorkdirSessionOperation,
) -> ApiResult<()> {
if is_command_reconciliation(operation) {
return Ok(());
}
let Some(permissions) = permissions else {
// Non-Subject Workers retain the existing provider/Profile/attachment ceilings.
return Ok(());
};
let check = |operation, path: &WorkdirPath, recursive| {
require(
permissions,
working_directory_id,
operation,
path,
recursive,
)
};
match operation {
WorkdirSessionOperation::Stat(request) => check(READ, &request.path, false),
WorkdirSessionOperation::Read(request) => check(READ, &request.path, false),
WorkdirSessionOperation::ReadBytes(request) => check(READ, &request.path, false),
WorkdirSessionOperation::Write(request) => check(WRITE, &request.path, false),
WorkdirSessionOperation::Edit(request) => check(WRITE, &request.path, false),
WorkdirSessionOperation::List(request) => {
// Directory enumeration accesses children, not just the directory inode.
check(READ, &request.path, true)?;
if let Some(cursor) = &request.after {
check(READ, &cursor.path, false)?;
}
Ok(())
}
WorkdirSessionOperation::Glob(request) => check(READ, &request.path, true),
WorkdirSessionOperation::Grep(request) => check(READ, &request.path, true),
WorkdirSessionOperation::AuthorizeScope(request) => check(
match request.permission {
WorkdirToolScopePermission::Read => READ,
WorkdirToolScopePermission::Write => WRITE,
},
&request.path,
false,
),
WorkdirSessionOperation::CommandStart(request) => {
// Validating cwd is not a shell confinement mechanism. The command can cd elsewhere,
// so a prefix grant (even one covering cwd recursively) is never enough.
resource(working_directory_id, COMMAND, &request.cwd, false)?;
if request.spill_dir.is_some() {
// This is a provider-local PathBuf, not an attachment-relative WorkdirPath.
// Do not authorize an unrepresentable write destination as attachment scope.
return Err(denied(
"caller-selected provider-local command spill paths are unsupported",
));
}
check(COMMAND, &WorkdirPath::root(), true)
}
WorkdirSessionOperation::CommandOutput(_) => {
// Output may contain anything the original shell read. Unlike status/cancel,
// retrieving its contents requires a current whole-attachment command grant.
check(COMMAND, &WorkdirPath::root(), true)
}
WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) => {
Ok(())
}
// These contracts have additional targets, scopes or provider-resolved metadata.
// Deny explicitly until all their accesses can be mapped to scoped resources.
WorkdirSessionOperation::ScopeRulesOverlap(_)
| WorkdirSessionOperation::CheckoutObserve(_)
| WorkdirSessionOperation::CheckoutExecute(_)
| WorkdirSessionOperation::CheckoutSearch(_) => {
Err(denied("operation has no scoped Subject resource mapping"))
}
}
}
/// Attenuate coarse attachment capabilities, without converting a path grant into root scope.
/// Per-operation authorization above remains required, including for recursive searches.
pub(super) fn intersect_capabilities(
permissions: &[ScopedPermission],
working_directory_id: &str,
ceiling: WorkdirSessionCapabilities,
) -> WorkdirSessionCapabilities {
let has_path_grant = |operation: &str| {
permissions.iter().any(|grant| {
grant.operation == operation
&& grant.validate().is_ok()
&& matches!(&grant.resource, PermissionResource::Workdir {
working_directory_id: id, ..
} if id == working_directory_id)
})
};
let mut allowed = WorkdirSessionCapabilities::EMPTY;
if has_path_grant(READ) {
allowed = allowed.union(WorkdirSessionCapabilities::READ_ONLY);
}
if has_path_grant(WRITE) {
allowed = allowed.union(WorkdirSessionCapabilities::WRITE_ONLY);
}
if require(
permissions,
working_directory_id,
COMMAND,
&WorkdirPath::root(),
true,
)
.is_ok()
{
allowed = allowed.union(WorkdirSessionCapabilities::COMMAND_ONLY);
}
ceiling.intersection(allowed)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use workdir::{
CommandHandle, CommandOutputRequest, CommandRequest, EditRequest, GlobRequest, GrepRequest,
ListRequest, ReadBytesRequest, ReadRequest, StatRequest, WriteRequest,
};
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn grant(operation: &str, target: &str, recursive: bool) -> ScopedPermission {
resource("wd", operation, &path(target), recursive).unwrap()
}
fn allowed(grants: &[ScopedPermission], operation: &WorkdirSessionOperation) -> bool {
authorize_permissions(Some(grants), "wd", operation).is_ok()
}
fn read(target: &str) -> WorkdirSessionOperation {
WorkdirSessionOperation::Read(ReadRequest {
path: path(target),
offset: 0,
limit: 10,
max_bytes: 1024,
})
}
fn command(cwd: &str) -> WorkdirSessionOperation {
WorkdirSessionOperation::CommandStart(CommandRequest {
command: "cd ..; cat secret".into(),
timeout_secs: 1,
output_limit: 1024,
cwd: path(cwd),
spill_dir: None,
tool_call_id: None,
})
}
#[test]
fn real_filesystem_operations_require_exact_operation_and_attachment_scope() {
let read_grant = grant(READ, "src", true);
let write_grant = grant(WRITE, "src", true);
let reads = [
read("src/a"),
WorkdirSessionOperation::Stat(StatRequest {
path: path("src/a"),
}),
WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
path: path("src/a"),
offset: 0,
max_bytes: 10,
expected_hash: None,
}),
WorkdirSessionOperation::List(ListRequest {
path: path("src"),
limit: 10,
after: None,
}),
WorkdirSessionOperation::Glob(GlobRequest {
pattern: "**/*".into(),
path: path("src"),
limit: 10,
}),
WorkdirSessionOperation::Grep(GrepRequest {
pattern: "needle".into(),
path: path("src"),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: Default::default(),
limit: 10,
offset: 0,
}),
];
for operation in reads {
assert!(
allowed(std::slice::from_ref(&read_grant), &operation),
"{operation:?}"
);
assert!(
!allowed(std::slice::from_ref(&write_grant), &operation),
"{operation:?}"
);
assert!(
authorize_permissions(
Some(std::slice::from_ref(&read_grant)),
"other-wd",
&operation
)
.is_err()
);
}
let writes = [
WorkdirSessionOperation::Write(WriteRequest {
path: path("src/a"),
content: vec![],
expected_hash: None,
}),
WorkdirSessionOperation::Edit(EditRequest {
path: path("src/a"),
old_string: "a".into(),
new_string: "b".into(),
replace_all: false,
expected_hash: [0; 32],
}),
];
for operation in writes {
assert!(allowed(std::slice::from_ref(&write_grant), &operation));
assert!(!allowed(std::slice::from_ref(&read_grant), &operation));
}
assert!(!allowed(&[read_grant], &read("src-other/a")));
}
#[test]
fn activation_workspace_and_parent_operation_names_do_not_grant_file_access() {
for permission in [
ScopedPermission {
operation: "filesystem".into(),
resource: PermissionResource::Feature,
},
grant("filesystem", ".", true),
grant("filesystem.read.extra", ".", true),
ScopedPermission {
operation: READ.into(),
resource: PermissionResource::Workspace {
workspace_id: "ws".into(),
},
},
] {
assert!(!allowed(&[permission], &read("src/a")));
}
assert!(!allowed(&[], &read("src/a")));
assert!(authorize_permissions(None, "wd", &read("src/a")).is_ok());
}
#[test]
fn nonrecursive_grant_cannot_enumerate_descendants_or_search() {
let grants = [grant(READ, "src", false)];
assert!(allowed(&grants, &read("src")));
assert!(!allowed(&grants, &read("src/a")));
assert!(!allowed(
&grants,
&WorkdirSessionOperation::List(ListRequest {
path: path("src"),
limit: 10,
after: None
})
));
assert!(!allowed(
&grants,
&WorkdirSessionOperation::Glob(GlobRequest {
pattern: "a".into(),
path: path("src"),
limit: 10
})
));
}
#[test]
fn wire_paths_are_normalized_and_escape_is_denied_before_provider_dispatch() {
let grants = [grant(READ, "src", true)];
for bad in [
"/src/a",
"../src/a",
"src/../secret",
"src\\a",
"C:/src/a",
"src/\u{0}a",
] {
let operation = serde_json::from_value::<WorkdirSessionOperation>(json!({
"operation": "stat", "request": {"path": bad}
}));
// Some escapes are already rejected by FsPath's wire boundary. Absolute
// provider paths and malformed resource names must also fail this boundary.
if let Ok(operation) = operation {
assert!(!allowed(&grants, &operation), "{bad:?}");
}
}
let operation: WorkdirSessionOperation = serde_json::from_value(json!({
"operation": "stat", "request": {"path": "./src//a"}
}))
.unwrap();
assert!(allowed(&grants, &operation));
}
#[test]
fn scope_probes_and_list_cursors_cannot_access_outside_the_granted_resource() {
let grants = [grant(READ, "src", true)];
for (target, permission, expected) in [
("src/a", WorkdirToolScopePermission::Read, true),
("private/a", WorkdirToolScopePermission::Read, false),
("src/a", WorkdirToolScopePermission::Write, false),
] {
let operation = WorkdirSessionOperation::AuthorizeScope(
workdir::WorkdirScopeAuthorizationRequest {
rules: vec![],
path: path(target),
permission,
},
);
assert_eq!(allowed(&grants, &operation), expected);
}
let list = |cursor| {
WorkdirSessionOperation::List(ListRequest {
path: path("src"),
limit: 10,
after: Some(workdir::ListCursor {
kind: workdir::EntryKind::File,
path: path(cursor),
}),
})
};
assert!(allowed(&grants, &list("src/a")));
assert!(!allowed(&grants, &list("private/a")));
}
#[test]
fn arbitrary_shell_requires_recursive_attachment_root_not_cwd_scope() {
assert!(!allowed(&[grant(COMMAND, "src", true)], &command("src")));
assert!(!allowed(&[grant(COMMAND, ".", false)], &command("src")));
assert!(!allowed(&[grant(WRITE, ".", true)], &command("src")));
assert!(allowed(&[grant(COMMAND, ".", true)], &command("src")));
let WorkdirSessionOperation::CommandStart(mut request) = command("src") else {
unreachable!()
};
request.spill_dir = Some("/provider/private".into());
assert!(!allowed(
&[grant(COMMAND, ".", true)],
&WorkdirSessionOperation::CommandStart(request)
));
}
#[test]
fn revocation_denies_new_execute_and_output_but_preserves_cancel_and_status() {
let handle = CommandHandle("owned-opaque-handle".into());
assert!(allowed(
&[],
&WorkdirSessionOperation::CommandCancel(handle.clone())
));
assert!(allowed(
&[],
&WorkdirSessionOperation::CommandStatus(handle.clone())
));
assert!(!allowed(&[], &command(".")));
let output = WorkdirSessionOperation::CommandOutput(CommandOutputRequest {
handle,
cursor: 0,
limit: 10,
wait: false,
});
assert!(!allowed(&[], &output));
assert!(allowed(&[grant(COMMAND, ".", true)], &output));
}
#[test]
fn unsupported_checkout_and_scope_comparison_fail_closed_for_scoped_workers() {
let grants = [
grant(READ, ".", true),
grant(WRITE, ".", true),
grant(COMMAND, ".", true),
];
let operations = [
serde_json::from_value::<WorkdirSessionOperation>(json!({
"operation": "scope_rules_overlap",
"request": {
"left": {"target": "src", "permission": "read", "recursive": true},
"right": {"target": "private", "permission": "read", "recursive": true}
}
}))
.unwrap(),
WorkdirSessionOperation::CheckoutObserve(path("src")),
WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest {
target: path("src"),
validator: vec![],
operation: workdir::CheckoutOperation::Create {
path: path("outside/a"),
content: vec![],
},
}),
WorkdirSessionOperation::CheckoutSearch(workdir::CheckoutSearchRequest::new(
workdir::CheckoutSearchOperation::List(ListRequest {
path: path("src"),
limit: 10,
after: None,
}),
)),
];
for operation in operations {
assert!(!allowed(&grants, &operation));
assert!(authorize_permissions(None, "wd", &operation).is_ok());
}
}
#[test]
fn readonly_delegation_attenuates_edit_capable_profile_without_losing_narrow_read_scope() {
let grants = [grant(READ, "src", true)];
assert_eq!(
intersect_capabilities(&grants, "wd", WorkdirSessionCapabilities::ALL),
WorkdirSessionCapabilities::READ_ONLY
);
assert_eq!(
intersect_capabilities(&grants, "other", WorkdirSessionCapabilities::ALL),
WorkdirSessionCapabilities::EMPTY
);
assert_eq!(
intersect_capabilities(
&[grant(WRITE, ".", true)],
"wd",
WorkdirSessionCapabilities::READ_ONLY
),
WorkdirSessionCapabilities::EMPTY
);
assert_eq!(
intersect_capabilities(
&[grant(COMMAND, "src", true)],
"wd",
WorkdirSessionCapabilities::ALL
),
WorkdirSessionCapabilities::EMPTY
);
assert_eq!(
intersect_capabilities(
&[grant(COMMAND, ".", true)],
"wd",
WorkdirSessionCapabilities::READ_WRITE
),
WorkdirSessionCapabilities::EMPTY
);
}
}
@@ -53,6 +53,7 @@ async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntim
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
);
summary.tags = vec!["orchestrator".into(), "singleton".into()];
@@ -59,6 +59,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
@@ -794,6 +795,7 @@ async fn tracking_fixture(
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
);
summary.tags = vec!["orchestrator".into(), "singleton".into()];
@@ -14,7 +14,7 @@ use worker::{
struct TicketRouterClient {
api: WorkspaceApi,
identity: Arc<RuntimeIdentityMaterial>,
source: RuntimeWorkerRef,
source: Mutex<RuntimeWorkerRef>,
runtime: tokio::runtime::Handle,
lose_next_response: AtomicBool,
requests: Mutex<Vec<(String, u16, String)>>,
@@ -45,9 +45,10 @@ impl WorkspaceClient for TicketRouterClient {
WorkspaceRequestMethod::Patch => "PATCH",
WorkspaceRequestMethod::Delete => "DELETE",
};
let source = self.source.lock().unwrap().clone();
let signed = runtime_source_request(
&self.identity,
Some(&self.source.worker_id),
Some(&source.worker_id),
method,
&request.path,
request.body.unwrap_or_default().into_bytes(),
@@ -225,7 +226,7 @@ fn ticket_client(
Arc::new(TicketRouterClient {
api: api.clone(),
identity: Arc::new(identity),
source: source.clone(),
source: Mutex::new(source.clone()),
runtime: tokio::runtime::Handle::current(),
lose_next_response: AtomicBool::new(false),
requests: Mutex::new(Vec::new()),
@@ -307,18 +308,16 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin
assert!(error.to_string().contains("409"), "{error}");
// A valid Runtime signature is not a Worker membership grant. The
// Backend must reject this unregistered source through the same tools.
let denied = Arc::new(TicketRouterClient {
api: api.clone(),
identity: client.identity.clone(),
source: RuntimeWorkerRef::new(
// Activate the real Feature while the source is registered, then lose
// membership before dispatch. Otherwise activation itself correctly rejects
// the fixture before it can exercise the operation's authentication check.
let denied = ticket_client(&api, (*client.identity).clone(), &source);
let denied_surface =
TicketSurface::new(&dir.path().join("denied"), denied.clone(), wip).await;
*denied.source.lock().unwrap() = RuntimeWorkerRef::new(
source.runtime_id.clone(),
"00000000-0000-7000-8000-000000000099",
),
runtime: tokio::runtime::Handle::current(),
lose_next_response: AtomicBool::new(false),
requests: Mutex::new(Vec::new()),
});
let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await;
);
let error = denied_surface
.call("TicketAssignmentAnswer", input.clone(), context("denied"))
.await
@@ -856,6 +856,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
+1
View File
@@ -21014,6 +21014,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
+462 -1
View File
@@ -1589,6 +1589,18 @@
"display_name": {
"type": "string"
},
"execution_grant": {
"anyOf": [
{
"$ref": "#/components/schemas/WorkerDelegationRequest"
},
{
"type": "null"
}
],
"default": null,
"description": "Requested least-authority child permissions. Host derives the origin; this\ninput cannot assert Subject identity or create a body/singleton attachment."
},
"feature_connections": {
"$ref": "#/components/schemas/WorkspaceWorkerFeatureConnectionsRequest",
"description": "Explicit product Feature connections. Backend validation turns these into\ntrusted host attachments; Runtime and Profile inputs cannot self-attest them."
@@ -6830,6 +6842,86 @@
],
"type": "object"
},
"PermissionResource": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "feature",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "workspace",
"type": "string"
},
"workspace_id": {
"type": "string"
}
},
"required": [
"kind",
"workspace_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "workdir",
"type": "string"
},
"path": {
"type": "string"
},
"recursive": {
"type": "boolean"
},
"working_directory_id": {
"type": "string"
}
},
"required": [
"kind",
"working_directory_id",
"path",
"recursive"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "worker",
"type": "string"
},
"runtime_id": {
"type": "string"
},
"worker_id": {
"type": "string"
}
},
"required": [
"kind",
"runtime_id",
"worker_id"
],
"type": "object"
}
]
},
"ProfileSettingsResponse": {
"additionalProperties": false,
"description": "Read-only Profile catalog projected from one active Workspace config tree.",
@@ -9527,6 +9619,22 @@
],
"type": "object"
},
"ScopedPermission": {
"additionalProperties": false,
"properties": {
"operation": {
"type": "string"
},
"resource": {
"$ref": "#/components/schemas/PermissionResource"
}
},
"required": [
"operation",
"resource"
],
"type": "object"
},
"Segment": {
"oneOf": [
{
@@ -10175,6 +10283,131 @@
],
"type": "object"
},
"SubjectPermissionInterruption": {
"additionalProperties": false,
"properties": {
"backend_command_sessions_closed": {
"type": "boolean"
},
"cancellation_accepted": {
"type": "boolean"
},
"diagnostics": {
"items": {
"type": "string"
},
"type": "array"
},
"runtime_id": {
"type": "string"
},
"worker_id": {
"type": "string"
}
},
"required": [
"runtime_id",
"worker_id",
"cancellation_accepted",
"backend_command_sessions_closed",
"diagnostics"
],
"type": "object"
},
"SubjectPermissions": {
"additionalProperties": false,
"properties": {
"change_id": {
"type": "string"
},
"delegation": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
},
"execution": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
}
},
"required": [
"change_id",
"execution",
"delegation"
],
"type": "object"
},
"SubjectPermissionsMutationResponse": {
"additionalProperties": false,
"properties": {
"interruptions": {
"description": "New operations are already fenced. These are cancellation requests and\nBackend command cleanup evidence, not proof that a Runtime turn stopped.",
"items": {
"$ref": "#/components/schemas/SubjectPermissionInterruption"
},
"type": "array"
},
"permissions": {
"$ref": "#/components/schemas/SubjectPermissions"
}
},
"required": [
"permissions",
"interruptions"
],
"type": "object"
},
"SubjectPermissionsResponse": {
"additionalProperties": false,
"properties": {
"permissions": {
"anyOf": [
{
"$ref": "#/components/schemas/SubjectPermissions"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"SubjectPermissionsUpdate": {
"additionalProperties": false,
"properties": {
"delegation": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
},
"execution": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
},
"expected_change_id": {
"type": [
"string",
"null"
]
},
"operation_id": {
"type": "string"
}
},
"required": [
"operation_id",
"execution",
"delegation"
],
"type": "object"
},
"SubjektivMemoryChangeItem": {
"additionalProperties": false,
"properties": {
@@ -14899,6 +15132,28 @@
}
]
},
"WorkerDelegationRequest": {
"additionalProperties": false,
"properties": {
"permissions": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
},
"redelegation": {
"items": {
"$ref": "#/components/schemas/ScopedPermission"
},
"type": "array"
}
},
"required": [
"permissions",
"redelegation"
],
"type": "object"
},
"WorkerFileDeleteResponse": {
"additionalProperties": false,
"properties": {
@@ -17844,7 +18099,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb"
"x-yoi-source-digest": "sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01"
},
"openapi": "3.1.0",
"paths": {
@@ -35000,6 +35255,212 @@
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions": {
"get": {
"operationId": "subjektiv_subject_permissions_get",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectPermissionsResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"browserSession": []
}
]
},
"post": {
"operationId": "subjektiv_subject_permissions_set",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectPermissionsUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectPermissionsMutationResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": {
"post": {
"operationId": "subjektiv_subject_retire",
@@ -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:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// 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
@@ -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:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// 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:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// 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:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// 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-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_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:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
// 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
@@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c
export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnostic>; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; };
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; execution_grant?: WorkerDelegationRequest | null; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; };
@@ -35,6 +35,8 @@ export type PasteArtifactMediaType = "text_plain_utf8";
export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; };
export type PermissionResource = { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; };
@@ -43,6 +45,8 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id
export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type ScopedPermission = { operation: string; resource: PermissionResource; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
@@ -55,6 +59,8 @@ export type UploadedFileRef = { artifact_id: string; availability: UploadedFileA
export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; };
export type WorkerDelegationRequest = { permissions: Array<ScopedPermission>; redelegation: Array<ScopedPermission>; };
export type WorkerImplementationSummary = { display_hint: string; kind: string; };
export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; };