Authorize known Worker observations without collection discovery

This commit is contained in:
2026-10-30 00:37:56 +09:00
parent 74ddd568eb
commit 51f0c7e019
19 changed files with 406 additions and 88 deletions
+43
View File
@@ -7808,6 +7808,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::worker_control_list(
service.clone(),
))
.merge(server_api::server_api_axum::worker_control_authorize(
service.clone(),
))
.merge(server_api::server_api_axum::worker_control_spawn(
service.clone(),
))
@@ -10295,6 +10298,46 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(response)
}
async fn worker_control_authorize(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
runtime_id: String,
worker_id: String,
permission: String,
) -> std::result::Result<(), server_api::RepositoryApiError> {
let api = self.workspace_api()?.clone();
let headers = current_worker_contract_headers(&context)?;
api.host_executors
.host()
.map_err(|error| ApiError::from(error).into_repository_api_error())?
.run(move || -> ApiResult<()> {
validate_workspace_scope(&api, &workspace_id)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
if !matches!(
permission.as_str(),
"observe" | "notify" | "send_input" | "cancel" | "stop" | "restore" | "remove"
) {
return Err(
Error::InvalidInput("unknown Worker control permission".into()).into(),
);
}
// This is a current, target-only preflight for providers that read
// locally. It discloses no registry/session data and issues no
// reusable receipt. Actual Backend effects still recheck authority.
authorize_known_worker_permission(
&api,
&workspace_id,
&RuntimeWorkerRef::new(source.runtime_id, source.worker_id),
&RuntimeWorkerRef::new(runtime_id, worker_id),
&permission,
)?;
Ok(())
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn worker_control_spawn(
&self,
context: server_api::ServerRequestContext,
@@ -2753,7 +2753,9 @@ impl ConnectedWorkerHarness {
let errors = captured_errors.clone();
async move {
let response = next.run(request).await;
if response.status().is_success() {
if !response.status().is_client_error()
&& !response.status().is_server_error()
{
return response;
}
let (parts, body) = response.into_parts();
@@ -139,11 +139,21 @@ impl agen::llm_client::LlmClient for MessagingModel {
.expect("child public reply result");
done()
} else if let Some(child) = result(&request, "roundtrip-spawn") {
call(
"roundtrip-ping",
"WorkerNotify",
json!({"subject":{"kind":"runtime_worker","runtime_id":child["runtime_id"],"worker_id":child["worker_id"]},"content":PING}),
)
if result(&request, "roundtrip-observe").is_some() {
call(
"roundtrip-ping",
"WorkerNotify",
json!({"subject":{"kind":"runtime_worker","runtime_id":child["runtime_id"],"worker_id":child["worker_id"]},"content":PING}),
)
} else {
// The origin has observe, not worker.list. A known target must
// remain readable without collection discovery authority.
call(
"roundtrip-observe",
"ViewSessionOverview",
json!({"subject":{"kind":"runtime_worker","runtime_id":child["runtime_id"],"worker_id":child["worker_id"]}}),
)
}
} else {
let call_id = if let Some(repair) = &self.repair {
if let Some((is_error, summary)) =
@@ -373,6 +383,7 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) {
collection("worker.create"),
operation("worker.messaging"),
operation("worker.stop"),
operation("worker.observe"),
]);
let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":null,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await;
assert_eq!(status, StatusCode::OK, "{assigned}");
@@ -582,7 +593,12 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) {
.contains("MUST_NOT_ACCEPT_REVOKED_CONTROL")
);
let calls = harness.authorizations.lock().unwrap();
for op in ["worker.create", "worker.messaging", "worker.stop"] {
for op in [
"worker.create",
"worker.messaging",
"worker.stop",
"worker.observe",
] {
assert!(
calls
.iter()
@@ -238,6 +238,14 @@ async fn assert_backend_denied(
async fn worker_tool_host(
root: &Path,
client: Arc<dyn WorkspaceClient>,
) -> worker::Worker<UnusedModel, session_store::FsStore> {
worker_tool_host_with_observation(root, client, None).await
}
async fn worker_tool_host_with_observation(
root: &Path,
client: Arc<dyn WorkspaceClient>,
observation: Option<Arc<dyn worker::feature::contracts::WorkerObservationProvider>>,
) -> worker::Worker<UnusedModel, session_store::FsStore> {
let manifest = worker::WorkerManifest::from_toml(
r#"
@@ -273,10 +281,14 @@ async fn worker_tool_host(
)
.await
.unwrap();
let report = host.install_features(
worker::feature::FeatureRegistryBuilder::new()
.with_module(manage_worker_feature(client, None, true)),
);
let mut features = worker::feature::FeatureRegistryBuilder::new()
.with_module(manage_worker_feature(client, None, true));
if let Some(provider) = observation {
features = features.with_module(
worker::feature::builtin::worker_observation::WorkerObservationFeature::new(provider),
);
}
let report = host.install_features(features);
assert!(
!report.has_errors(),
"Worker Feature must install without granting operation use"
@@ -332,6 +344,151 @@ fn tool_arguments(name: &str, target: &RuntimeWorkerRef) -> Value {
args
}
// Only the protected Session read is substituted here. Installed observation /
// control Features and signed Host / Backend target checks remain production code.
struct CountedObservation(std::sync::atomic::AtomicUsize);
#[async_trait::async_trait]
impl worker::feature::contracts::WorkerObservationProvider for CountedObservation {
async fn list_worker_sessions(
&self,
) -> std::result::Result<
Vec<worker::feature::contracts::WorkerObservationSubject>,
worker::feature::contracts::WorkerObservationError,
> {
panic!("known-target capture must not discover the collection")
}
async fn capture_worker_session(
&self,
_: &worker::feature::contracts::WorkerObservationSubjectRef,
) -> std::result::Result<
worker::feature::contracts::WorkerSessionCapture,
worker::feature::contracts::WorkerObservationError,
> {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(worker::feature::contracts::WorkerSessionCapture {
segment_id: "target-session".into(),
session: protocol::SessionSnapshot {
pending_submissions: Default::default(),
entries: vec![protocol::SessionSnapshotEntry {
use_policy: None,
entry_id: "observed-entry".into(),
timestamp: 1,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
derived_from: vec![],
data: protocol::SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content: vec![protocol::SessionContentPart::Text {
text: "observed input".into(),
}],
},
}],
},
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn installed_observation_needs_only_observe_and_target_grant_not_collection_discovery() {
let f = OperationFixture::new().await;
f.assign(vec![workspace_permission("worker.create")], vec![])
.await;
let target = f.child("observed-target", vec![]).await;
f.assign(vec![operation_permission("worker.observe")], vec![])
.await;
let provider = Arc::new(CountedObservation(std::sync::atomic::AtomicUsize::new(0)));
let client = f.client(&f.source);
let host = worker_tool_host_with_observation(
&f._dir.path().join("observation-tools"),
client.clone(),
Some(provider.clone()),
)
.await;
assert!(
execute_tool_result(&host, "WorkerList", json!({})).is_err(),
"observe never grants collection discovery"
);
let overview = execute_tool(
&host,
"ViewSessionOverview",
json!({"subject":subject_value(&target)}),
)
.await;
let entry_ref = overview["entries"][0]["entry_ref"].as_str().unwrap();
let tools = [
(
"ViewSessionOverview",
json!({"subject":subject_value(&target)}),
),
(
"SearchSessionEntries",
json!({"subject":subject_value(&target), "query":"observed"}),
),
(
"ReadSessionEntry",
json!({"subject":subject_value(&target), "entry_ref":entry_ref}),
),
];
client.requests.lock().unwrap().clear();
for (name, input) in &tools {
execute_tool(&host, name, input.clone()).await;
}
assert_eq!(provider.0.load(std::sync::atomic::Ordering::SeqCst), 4);
assert!(
client
.requests
.lock()
.unwrap()
.iter()
.all(|(_, path)| !path.ends_with("/worker-control/workers")),
"known-target public observation must not invoke WorkerList"
);
let grant = f
.api
.store
.get_active_worker_control_grant(TEST_WORKSPACE_ID, &f.source, &target)
.unwrap()
.unwrap();
for missing in ["operation", "target", "revoked"] {
match missing {
"operation" => {
f.assign(vec![], vec![]).await;
}
"target" => {
f.assign(vec![operation_permission("worker.observe")], vec![])
.await;
f.api
.store
.revoke_worker_control_grant(
TEST_WORKSPACE_ID,
&grant.grant_id,
&now_registry_timestamp(),
)
.unwrap();
}
"revoked" => {}
_ => unreachable!(),
}
for (name, input) in &tools {
let mut input = input.clone();
if missing == "target" {
input["subject"]["worker_id"] = json!("unknown-target");
}
let error = execute_tool_result(&host, name, input)
.unwrap_err()
.to_string();
assert!(
error.contains("not found or is not accessible"),
"{missing} {name}: {error}"
);
}
assert_eq!(
provider.0.load(std::sync::atomic::Ordering::SeqCst),
4,
"denied {missing} must not read or supply protected Session data"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn backend_control_grant_does_not_replace_exact_operation_use_permission() {
let f = OperationFixture::new().await;