feat: enforce explicit Subject permissions and delegated Worker grants
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, ®istration).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, ¤t.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, ®istration).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();
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod authorized_workdir;
|
||||
mod backend_job;
|
||||
pub use backend_job::BackendJobExecutionBinding;
|
||||
pub mod bootstrap;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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") {
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¤t.origin_runtime_id, ¤t.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
|
||||
|
||||
@@ -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
@@ -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; };
|
||||
|
||||
Reference in New Issue
Block a user