fix: require replay-safe control lifecycle for Subject delegation
This commit is contained in:
@@ -32338,6 +32338,15 @@ async fn create_workspace_worker_authenticated_on_host(
|
||||
}
|
||||
None
|
||||
};
|
||||
// Generic creation has no authenticated-origin operation reservation or
|
||||
// control-grant recovery. A supplied ID alone must not make that path a
|
||||
// delegation API; require the normal worker-control lifecycle instead.
|
||||
if resolved_execution_grant.is_some() && resolved_control_operation.is_none() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Subject delegation requires worker-control creation with control_operation_id; generic Worker creation is not supported".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let resolved_subjektiv_attached = match feature_connections.subjektiv {
|
||||
None => {
|
||||
validate_caller_worker_singleton_key(singleton_key.as_deref())?;
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Subject delegation uses the normal Worker control lifecycle, which binds stable
|
||||
//! operation identity to the authenticated origin and recovers control-grant writes.
|
||||
//! Generic Worker creation rejects Subject delegation before reservation, even if
|
||||
//! the payload supplies an operation ID; its manual allocations are not replay-safe.
|
||||
|
||||
use super::*;
|
||||
use server_api::authorization::{
|
||||
|
||||
@@ -816,6 +816,218 @@ async fn worker_feature_activation_is_distinct_from_ticket_create_and_revocation
|
||||
assert_eq!(backend.list(Default::default()).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn creation_record_counts(f: &Fixture) -> [i64; 3] {
|
||||
let connection = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||
[
|
||||
"worker_create_reservations",
|
||||
"worker_registry",
|
||||
"worker_control_grants",
|
||||
]
|
||||
.map(|table| {
|
||||
connection
|
||||
.query_row(
|
||||
&format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"),
|
||||
[TEST_WORKSPACE_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
|
||||
let f = Fixture::new().await;
|
||||
let permissions = vec![activation("ticket"), create_permission()];
|
||||
f.assign(vec![], permissions.clone()).await;
|
||||
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
|
||||
child_request["execution_grant"]["redelegation"] = json!(permissions);
|
||||
let (status, child) = f.spawn(&f.source, child_request).await;
|
||||
assert_eq!(status, StatusCode::OK, "{child}");
|
||||
let child = RuntimeWorkerRef::new(
|
||||
child["runtime_id"].as_str().unwrap(),
|
||||
child["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let store = open_subjektiv_store(&f.api).unwrap();
|
||||
let grants = store
|
||||
.list_subject_worker_execution_grants(&f.subject)
|
||||
.unwrap();
|
||||
let records = creation_record_counts(&f);
|
||||
let runtime_calls = f.runtime.spawn_requests().len();
|
||||
for source in [&f.source, &child] {
|
||||
for path in [
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
"/api/workers".into(),
|
||||
] {
|
||||
let exact = f.child_request("generic-must-not-allocate", permissions.clone());
|
||||
let mut missing_id = exact.clone();
|
||||
missing_id
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("control_operation_id");
|
||||
let mut blank_id = exact.clone();
|
||||
blank_id["control_operation_id"] = json!(" ");
|
||||
let mut conflicting = exact.clone();
|
||||
conflicting["display_name"] = json!("Different payload, same operation ID");
|
||||
for request in [exact.clone(), exact, missing_id, blank_id, conflicting] {
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
runtime_source_request(
|
||||
&f.identity,
|
||||
Some(&source.worker_id),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(&request).unwrap(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: {body}");
|
||||
assert!(
|
||||
body["message"].as_str().unwrap().contains("worker-control"),
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(
|
||||
creation_record_counts(&f),
|
||||
records,
|
||||
"{path}: no reservation, registry or control grant"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_subject_worker_execution_grants(&f.subject)
|
||||
.unwrap(),
|
||||
grants
|
||||
);
|
||||
assert_eq!(
|
||||
f.runtime.spawn_requests().len(),
|
||||
runtime_calls,
|
||||
"{path}: no Runtime creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn generic_create_routes_preserve_human_and_non_subject_worker_creation() {
|
||||
let f = Fixture::new().await;
|
||||
let mut request = f.child_request("unused-on-generic-route", vec![]);
|
||||
request.as_object_mut().unwrap().remove("execution_grant");
|
||||
request
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("control_operation_id");
|
||||
for path in [
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
"/api/workers".into(),
|
||||
] {
|
||||
let (status, human_child) = response(
|
||||
&f.api,
|
||||
human_request(&f.owner, "POST", &path, Some(request.clone())),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "human {path}: {human_child}");
|
||||
let (status, worker_child) = response(
|
||||
&f.api,
|
||||
runtime_source_request(
|
||||
&f.identity,
|
||||
Some(human_child["worker_id"].as_str().unwrap()),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(&request).unwrap(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "non-Subject {path}: {worker_child}");
|
||||
assert!(
|
||||
open_subjektiv_store(&f.api)
|
||||
.unwrap()
|
||||
.worker_execution_grant(
|
||||
worker_child["runtime_id"].as_str().unwrap(),
|
||||
worker_child["worker_id"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let permissions = vec![activation("ticket"), create_permission()];
|
||||
f.assign(vec![], permissions.clone()).await;
|
||||
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
|
||||
missing_id
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("control_operation_id");
|
||||
let records = creation_record_counts(&f);
|
||||
let (status, body) = f.spawn(&f.source, missing_id).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
|
||||
assert_eq!(creation_record_counts(&f), records);
|
||||
let connection = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||
connection.execute_batch("CREATE TRIGGER fail_control_grant BEFORE INSERT ON worker_control_grants BEGIN SELECT RAISE(ABORT, 'injected control grant failure'); END;").unwrap();
|
||||
let request = f.child_request("control-grant-recovery", permissions);
|
||||
let (status, body) = f.spawn(&f.source, request.clone()).await;
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}");
|
||||
let store = open_subjektiv_store(&f.api).unwrap();
|
||||
let grants = store
|
||||
.list_subject_worker_execution_grants(&f.subject)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
grants.len(),
|
||||
1,
|
||||
"Worker exists before control grant persistence fails"
|
||||
);
|
||||
let child = RuntimeWorkerRef::new(&grants[0].runtime_id, &grants[0].worker_id);
|
||||
assert_eq!(
|
||||
f.runtime.workers.lock().unwrap().len(),
|
||||
2,
|
||||
"one source and one child"
|
||||
);
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.list_active_worker_control_grants(TEST_WORKSPACE_ID, &f.source, 10)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER fail_control_grant;")
|
||||
.unwrap();
|
||||
let runtime_calls = f.runtime.spawn_requests().len();
|
||||
for _ in 0..2 {
|
||||
// The first success is deliberately not used as a retry credential.
|
||||
let (status, body) = f.spawn(&f.source, request.clone()).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(body["worker_id"], child.worker_id);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_subject_worker_execution_grants(&f.subject)
|
||||
.unwrap(),
|
||||
grants
|
||||
);
|
||||
let control = f
|
||||
.api
|
||||
.store
|
||||
.list_active_worker_control_grants(TEST_WORKSPACE_ID, &f.source, 10)
|
||||
.unwrap();
|
||||
assert_eq!(control.len(), 1);
|
||||
assert_eq!(control[0].subject, child);
|
||||
assert_eq!(
|
||||
f.runtime.spawn_requests().len(),
|
||||
runtime_calls,
|
||||
"completed creation must not resubmit to Runtime"
|
||||
);
|
||||
}
|
||||
let records = creation_record_counts(&f);
|
||||
let mut conflict = request;
|
||||
conflict["display_name"] = json!("Changed control retry");
|
||||
assert!(!f.spawn(&f.source, conflict).await.0.is_success());
|
||||
assert_eq!(creation_record_counts(&f), records);
|
||||
assert_eq!(f.runtime.spawn_requests().len(), runtime_calls);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_one_worker_and_grant()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user