Separate Worker operation authorization from directional control grants

This commit is contained in:
2026-10-29 22:26:35 +09:00
parent 7ad6030498
commit 74ddd568eb
15 changed files with 2650 additions and 464 deletions
+283 -3
View File
@@ -42,8 +42,10 @@
//! 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-post authority. `worker.messaging` permits the messaging operation;
//! the Worker Feature's Backend control grant separately limits its target and direction.
//! Stored `worker.result` and `worker.notify` names are read compatibility only, not
//! result publication, arbitrary control or current request names. 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.
@@ -115,6 +117,11 @@ pub enum PermissionResource {
path: String,
recursive: bool,
},
/// Authorizes operation use, not a target Worker or a Workspace collection.
/// The Worker Feature must still enforce its directional target control grant.
WorkerOperation {
workspace_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
@@ -186,6 +193,9 @@ impl<'de> Deserialize<'de> for PermissionResource {
path: String,
recursive: bool,
},
WorkerOperation {
workspace_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
@@ -285,6 +295,7 @@ impl<'de> Deserialize<'de> for PermissionResource {
path,
recursive,
},
Wire::WorkerOperation { workspace_id } => Self::WorkerOperation { workspace_id },
Wire::Worker {
runtime_id,
worker_id,
@@ -310,6 +321,7 @@ impl PermissionResource {
| Self::Repository { workspace_id, .. }
| Self::WorkdirAttachment { workspace_id, .. }
| Self::WorkspaceConfig { workspace_id, .. }
| Self::WorkerOperation { workspace_id }
| Self::Workspace { workspace_id } => Some(workspace_id),
_ => None,
}
@@ -414,7 +426,9 @@ impl PermissionResource {
validate_authorization_identifier(name)
}
Self::Feature => Ok(()),
Self::Workspace { workspace_id } => validate_authorization_identifier(workspace_id),
Self::Workspace { workspace_id } | Self::WorkerOperation { workspace_id } => {
validate_authorization_identifier(workspace_id)
}
Self::Workdir {
working_directory_id,
path,
@@ -567,6 +581,10 @@ impl PermissionResource {
}
(Self::Feature, Self::Feature) => true,
(Self::Workspace { workspace_id: a }, Self::Workspace { workspace_id: b }) => a == b,
(
Self::WorkerOperation { workspace_id: a },
Self::WorkerOperation { workspace_id: b },
) => a == b,
(
Self::Worker {
runtime_id: ar,
@@ -605,6 +623,14 @@ impl PermissionResource {
}
}
/// Stored scopes retain their original resources and exact operation names.
///
/// A legacy Worker read adapter may use [`canonical_worker_operation`] to project
/// operation use, but must retain the original target separately for the Worker
/// Feature's control-grant check, including direction, revocation and delegation
/// limits. Replacing an old Worker resource with WorkerOperation and discarding
/// that target would broaden authority. Neither validation nor `covers` performs
/// that conversion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -620,6 +646,14 @@ impl ScopedPermission {
if matches!(self.resource, PermissionResource::Feature) && self.operation.contains('.') {
return Err("Feature resources authorize activation names only, not operations".into());
}
if matches!(self.resource, PermissionResource::WorkerOperation { .. })
&& canonical_worker_operation(&self.operation) != Some(self.operation.as_str())
{
return Err(
"WorkerOperation resources authorize only exact current Worker control operations"
.into(),
);
}
Ok(())
}
@@ -691,6 +725,18 @@ pub struct OperationAuthorizationRequest {
impl OperationAuthorizationRequest {
pub fn validate(&self) -> Result<(), String> {
if let Some(operation) = canonical_worker_operation(&self.operation) {
if operation != self.operation {
return Err(
"legacy Worker message names are storage-only; request worker.messaging".into(),
);
}
if !matches!(self.resource, PermissionResource::WorkerOperation { .. }) {
return Err(
"current Worker control requests require a WorkerOperation resource".into(),
);
}
}
ScopedPermission {
operation: self.operation.clone(),
resource: self.resource.clone(),
@@ -736,6 +782,26 @@ pub struct BoundOperationAuthorizationRequest {
pub target: BoundAuthorizationTarget,
}
/// Maps exact runtime Worker control names to the current operation-use contract.
///
/// `worker.notify` and `worker.result` are accepted here only to read saved
/// policy/grants; current requests must use `worker.messaging`. This helper does
/// not grant authority or convert resources: adapters must preserve legacy Worker
/// target restrictions separately. Spawn, discovery/list and Internal SubWorker
/// operations are not part of this contract. Unknown names return `None`.
pub fn canonical_worker_operation(operation: &str) -> Option<&'static str> {
match operation {
"worker.messaging" | "worker.notify" | "worker.result" => Some("worker.messaging"),
"worker.send_input" => Some("worker.send_input"),
"worker.cancel" => Some("worker.cancel"),
"worker.stop" => Some("worker.stop"),
"worker.restore" => Some("worker.restore"),
"worker.remove" => Some("worker.remove"),
"worker.observe" => Some("worker.observe"),
_ => None,
}
}
pub fn validate_operation_name(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 256
@@ -898,6 +964,220 @@ mod tests {
);
}
#[test]
fn worker_operation_wire_and_schema_require_only_workspace_identity() {
let wire = serde_json::json!({"kind":"worker_operation","workspace_id":"ws"});
let resource: PermissionResource = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
assert_eq!(resource.workspace_id(), Some("ws"));
assert!(resource.validate().is_ok());
for invalid in [
serde_json::json!({"kind":"worker_operation"}),
serde_json::json!({"kind":"worker_operation","workspace_id":"ws","worker_id":"w"}),
serde_json::json!({"kind":"worker_operation","workspace_id":"ws","runtime_id":"r"}),
] {
assert!(serde_json::from_value::<PermissionResource>(invalid).is_err());
}
for workspace_id in ["", " ws", "ws\n", "*", &"x".repeat(257)] {
let resource: PermissionResource = serde_json::from_value(
serde_json::json!({"kind":"worker_operation","workspace_id":workspace_id}),
)
.unwrap();
assert!(resource.validate().is_err(), "{workspace_id:?}");
assert!(!resource.covers(&resource));
}
let schema = serde_json::to_value(schemars::schema_for!(PermissionResource)).unwrap();
let variant = schema["oneOf"]
.as_array()
.unwrap()
.iter()
.find(|variant| variant["properties"]["kind"]["const"] == "worker_operation")
.unwrap();
assert_eq!(variant["properties"]["workspace_id"]["type"], "string");
assert_eq!(
variant["required"],
serde_json::json!(["kind", "workspace_id"])
);
assert_eq!(variant["additionalProperties"], false);
}
#[test]
fn worker_operation_containment_never_crosses_workspace_or_resource_kind() {
let resource = PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
};
assert!(resource.covers(&resource));
for other in [
PermissionResource::WorkerOperation {
workspace_id: "other".into(),
},
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "w".into(),
},
PermissionResource::SubWorker {
runtime_id: "r".into(),
worker_id: "w".into(),
name: "child".into(),
},
] {
assert!(!resource.covers(&other), "{other:?}");
assert!(!other.covers(&resource), "{other:?}");
}
}
#[test]
fn worker_operation_scopes_accept_only_seven_exact_current_operations() {
let resource = PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
};
for operation in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
assert_eq!(canonical_worker_operation(operation), Some(operation));
let permission = ScopedPermission {
operation: operation.into(),
resource: resource.clone(),
};
assert!(permission.validate().is_ok(), "{operation}");
assert!(permission.covers(&permission));
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource: resource.clone(),
}
.validate()
.is_ok()
);
for old_resource in [
PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "w".into(),
},
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
PermissionResource::SubWorker {
runtime_id: "r".into(),
worker_id: "w".into(),
name: "child".into(),
},
] {
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource: old_resource,
}
.validate()
.is_err()
);
}
let descendant = format!("{operation}.extra");
assert_eq!(canonical_worker_operation(&descendant), None);
assert!(
ScopedPermission {
operation: descendant,
resource: resource.clone(),
}
.validate()
.is_err()
);
}
for operation in [
"worker",
"worker.spawn",
"worker.lifecycle",
"worker.list",
"worker.input",
"subworker.stop",
"sub_worker.stop",
"ticket.read",
"worker.notify",
"worker.result",
] {
assert!(
ScopedPermission {
operation: operation.into(),
resource: resource.clone(),
}
.validate()
.is_err(),
"{operation}"
);
if !matches!(operation, "worker.notify" | "worker.result") {
assert_eq!(canonical_worker_operation(operation), None);
}
}
}
#[test]
fn legacy_worker_message_storage_stays_exact_and_cannot_authorize_current_requests() {
let current = ScopedPermission {
operation: "worker.messaging".into(),
resource: PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
},
};
for operation in ["worker.notify", "worker.result"] {
assert_eq!(
canonical_worker_operation(operation),
Some("worker.messaging")
);
let wire = serde_json::json!({
"operation":operation,
"resource":{"kind":"worker","runtime_id":"r","worker_id":"w"}
});
let stored: ScopedPermission = serde_json::from_value(wire.clone()).unwrap();
assert!(stored.validate().is_ok());
assert_eq!(serde_json::to_value(&stored).unwrap(), wire);
assert!(stored.covers(&stored));
assert!(!stored.covers(&current));
assert!(!current.covers(&stored));
let renamed = ScopedPermission {
operation: "worker.messaging".into(),
resource: stored.resource.clone(),
};
assert!(!stored.covers(&renamed));
assert!(!renamed.covers(&stored));
for resource in [
stored.resource,
current.resource.clone(),
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
] {
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource,
}
.validate()
.is_err()
);
}
}
// Creation and discovery remain outside runtime control operation-use scopes.
assert!(
OperationAuthorizationRequest {
operation: "worker.lifecycle".into(),
resource: PermissionResource::Workspace {
workspace_id: "ws".into()
},
}
.validate()
.is_ok()
);
}
#[test]
fn authorization_response_carries_matching_scope_and_defaults_legacy_absence() {
let legacy: OperationAuthorizationResponse =