Authorize known Worker observations without collection discovery
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user