chore(ticket): preserve integrated authentication and WIP test fixes

# Conflicts:
#	crates/workspace-server/src/server_workspace_config_wip_tests.rs
This commit is contained in:
2026-10-09 03:10:39 +09:00
8 changed files with 1000 additions and 261 deletions
+37 -24
View File
@@ -2632,34 +2632,30 @@ fn runtime_request_proof_rejection(
method: &str,
path: &str,
workspace_id: &str,
error: &crate::worker_source::WorkerMutationSourceProofError,
error: &crate::worker_source::RuntimeRequestProofError,
) -> Response {
use crate::worker_source::WorkerMutationSourceProofError;
// Do not format the error itself: Authority and MissingPermission can carry
// internal or request-derived strings. Only emit allowlisted reason codes.
let reason = match error {
WorkerMutationSourceProofError::Missing => "missing",
WorkerMutationSourceProofError::Invalid => "invalid",
WorkerMutationSourceProofError::WrongAudience => "wrong_audience",
WorkerMutationSourceProofError::WrongWorkspace => "wrong_workspace",
WorkerMutationSourceProofError::WrongActor => "wrong_actor",
WorkerMutationSourceProofError::MissingPermission(_) => "missing_permission",
WorkerMutationSourceProofError::Expired => "expired",
WorkerMutationSourceProofError::RevokedRuntimeTrust => "runtime_trust_missing_or_revoked",
WorkerMutationSourceProofError::Replay => "replay",
WorkerMutationSourceProofError::WorkerCatalogMembership => "worker_catalog_membership",
WorkerMutationSourceProofError::Authority(_) => "authority_unavailable",
};
// Never format an error or raw claims. IDs are fixed-length fingerprints;
// verification labels describe proof-check progress, not authorization.
let path = path.split('?').next().unwrap_or(path);
let log_path = bounded_auth_log_value(path, 512);
let method = bounded_auth_log_value(method, 32);
let workspace_id = bounded_auth_log_value(workspace_id, 128);
let request_id = uuid::Uuid::now_v7().to_string();
tracing::warn!(
target: "yoi::auth",
event = "runtime_request_proof_rejected",
method,
path,
workspace_id,
method, path = log_path, workspace_id, request_id,
status = 401,
reason,
reason = error.reason,
proof_verification = error.verification,
claimed_runtime_id_hash = error.runtime_id_hash.as_deref(),
claimed_worker_id_hash = error.worker_id_hash.as_deref(),
claimed_token_id_hash = error.token_id_hash.as_deref(),
issued_at_unix = error.iat,
expires_at_unix = error.exp,
verified_at_unix = error.now,
time_delta_seconds = error.time_delta_seconds,
time_tolerance_seconds = error.time_delta_seconds.map(|_| 0_u64),
"Runtime request proof rejected",
);
repository_api_rejection(
@@ -2669,6 +2665,17 @@ fn runtime_request_proof_rejection(
)
}
fn bounded_auth_log_value(value: &str, max_bytes: usize) -> String {
let mut result = String::new();
for ch in value.chars().filter(|ch| !ch.is_control()) {
if result.len() + ch.len_utf8() > max_bytes {
break;
}
result.push(ch);
}
result
}
/// Authored configuration editors are user-facing authority, not a generic
/// Runtime workspace.request capability. Worker configuration access is only
/// through the grant- and connection-bound WIP adapter. Keep runtime projections
@@ -55527,7 +55534,10 @@ mod tests {
.await;
assert!(matches!(
result,
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
Err(crate::worker_source::RuntimeRequestProofError {
reason: "runtime_trust_missing_or_revoked",
..
})
));
}
@@ -55603,7 +55613,10 @@ mod tests {
.await;
assert!(matches!(
result,
Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership)
Err(crate::worker_source::RuntimeRequestProofError {
reason: "worker_catalog_membership",
..
})
));
}
@@ -13,62 +13,58 @@ impl Write for CapturedAuthLog {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn runtime_proof_diagnostics_redact_error_payloads() {
use crate::worker_source::WorkerMutationSourceProofError;
for (error, reason) in [
(
WorkerMutationSourceProofError::Authority("private-database-path".to_string()),
"authority_unavailable",
),
(
WorkerMutationSourceProofError::MissingPermission("private-permission".to_string()),
"missing_permission",
),
] {
let logs = CapturedAuthLog::default();
let writer = logs.clone();
let subscriber = tracing_subscriber::fmt()
impl CapturedAuthLog {
fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync + use<> {
let writer = self.clone();
tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.without_time()
.with_ansi(false)
.json()
.flatten_event(true)
.with_writer(move || writer.clone())
.finish();
tracing::subscriber::with_default(subscriber, || {
let response = runtime_request_proof_rejection(
"POST",
"/api/w/workspace/workers/self/workdir-session/operations?secret=query-secret",
"workspace",
&error,
);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
});
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
let event: Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(event["reason"], reason);
assert!(!text.contains("private-"), "auth log leaked error details");
assert!(
!text.contains("query-secret"),
"auth log leaked query values"
.finish()
}
fn event(&self) -> (Value, String) {
let text = String::from_utf8(self.0.lock().unwrap().clone()).unwrap();
let events: Vec<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|event| event["event"] == "runtime_request_proof_rejected")
.collect();
assert_eq!(
events.len(),
1,
"missing or duplicated auth diagnostic: {text}"
);
(events[0].clone(), text)
}
}
#[tokio::test]
async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_routers() {
async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_both_routers() {
let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
let signer = RuntimeRequestSourceSigner::from_identity(&identity);
let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap();
configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret");
SqliteWorkspaceStore::open(&api.config.database_path)
.unwrap()
.revoke_workspace_runtime_binding(
TEST_WORKSPACE_ID,
"revoked-runtime-secret",
"2026-10-08T00:00:00Z",
)
.unwrap();
let spoofed = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let unbound_id = "unverified-runtime-secret".repeat(1000);
let unbound = RuntimeIdentityMaterial::generate(&unbound_id).unwrap();
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let target = format!("{path}?access_token=query-secret");
let body = "body-secret";
@@ -81,24 +77,94 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
];
for app in apps {
for reason in ["expired", "replay", "invalid"] {
for (case, verification) in [
("expired", "signature_verified"),
("issued_in_future", "signature_verified"),
("replay", "request_verified"),
("invalid_format", "unverified"),
("invalid_encoding", "unverified"),
("malformed_claims", "unverified"),
("invalid_signature", "unverified"),
("tampered_signature", "unverified"),
("revoked_runtime", "unverified"),
("runtime_trust_missing_or_revoked", "unverified"),
("audience_mismatch", "signature_verified"),
("workspace_mismatch", "signature_verified"),
("permission_mismatch", "signature_verified"),
("method_mismatch", "signature_verified"),
("path_mismatch", "signature_verified"),
("body_digest_mismatch", "signature_verified"),
("worker_catalog_membership", "request_verified"),
] {
let reason = match case {
"tampered_signature" => "invalid_signature",
"revoked_runtime" => "runtime_trust_missing_or_revoked",
other => other,
};
let now = i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap();
let proof = if reason == "invalid" {
"invalid-proof-secret".to_string()
} else {
signer
let signing_identity = match case {
"invalid_signature" => &spoofed,
"revoked_runtime" => &revoked,
"runtime_trust_missing_or_revoked" => &unbound,
_ => &identity,
};
let proof = match reason {
"invalid_format" => "invalid-proof-secret".repeat(1000),
"invalid_encoding" => "yoi-runtime-request-v1.!.!".to_string(),
"malformed_claims" => "yoi-runtime-request-v1.e30.AA".to_string(), // {} claims
_ => RuntimeRequestSourceSigner::from_identity(signing_identity)
.issue(
"server-test",
TEST_WORKSPACE_ID,
None,
WORKSPACE_REQUEST_PERMISSION,
"POST",
&target,
body.as_bytes(),
if reason == "expired" { now - 120 } else { now },
30,
if reason == "audience_mismatch" {
"audience-secret"
} else {
"server-test"
},
if reason == "workspace_mismatch" {
"workspace-secret"
} else {
TEST_WORKSPACE_ID
},
(reason == "worker_catalog_membership").then_some("worker-secret"),
if reason == "permission_mismatch" {
"permission-secret"
} else {
WORKSPACE_REQUEST_PERMISSION
},
if reason == "method_mismatch" {
"GET"
} else {
"POST"
},
if reason == "path_mismatch" {
"/path-secret"
} else {
&target
},
if reason == "body_digest_mismatch" {
b"different-body-secret"
} else {
body.as_bytes()
},
match reason {
"expired" => i64::MIN,
"issued_in_future" => i64::MAX,
_ => now,
},
if matches!(reason, "expired" | "issued_in_future") {
0
} else {
30
},
)
.unwrap()
.unwrap(),
};
let proof = if case == "tampered_signature" {
let (signed, signature) = proof.rsplit_once('.').unwrap();
// Flip a significant signature byte, retaining valid base64url.
let first = if signature.starts_with('A') { "B" } else { "A" };
format!("{signed}.{first}{}", &signature[1..])
} else {
proof
};
if reason == "replay" {
crate::worker_source::verify_runtime_request_source_proof(
@@ -114,15 +180,6 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
.unwrap();
}
let logs = CapturedAuthLog::default();
let writer = logs.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.without_time()
.with_ansi(false)
.json()
.flatten_event(true)
.with_writer(move || writer.clone())
.finish();
let response = app
.clone()
.oneshot(
@@ -135,39 +192,206 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
.body(Body::from(body))
.unwrap(),
)
.with_subscriber(subscriber)
.with_subscriber(logs.subscriber())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{reason}");
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(response_body.is_empty(), "{response_body:?}");
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
let events: Vec<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|event| event["event"] == "runtime_request_proof_rejected")
.collect();
assert_eq!(
events.len(),
1,
"missing or duplicated auth diagnostic: {text}"
);
let event = &events[0];
let (event, text) = logs.event();
assert_eq!(event["reason"], reason);
assert_eq!(event["proof_verification"], verification);
assert_eq!(event["status"], 401);
assert_eq!(event["method"], "POST");
assert_eq!(event["path"], path);
assert_eq!(event["workspace_id"], TEST_WORKSPACE_ID);
assert!(uuid::Uuid::parse_str(event["request_id"].as_str().unwrap()).is_ok());
if verification == "unverified"
&& matches!(
reason,
"invalid_format" | "invalid_encoding" | "malformed_claims"
)
{
assert!(event.get("claimed_runtime_id_hash").is_none());
assert!(event.get("claimed_token_id_hash").is_none());
} else {
let claims =
worker_runtime::auth::decode_runtime_request_source_claims(&proof).unwrap();
assert_eq!(
event["claimed_runtime_id_hash"],
crate::worker_source::diagnostic_id_hash(&claims.iss)
);
assert_eq!(
event["claimed_token_id_hash"],
crate::worker_source::diagnostic_id_hash(&claims.jti)
);
if let Some(worker) = &claims.worker_id {
assert_eq!(
event["claimed_worker_id_hash"],
crate::worker_source::diagnostic_id_hash(worker)
);
}
}
if matches!(reason, "expired" | "issued_in_future") {
let bound = if reason == "expired" {
i64::MIN
} else {
i64::MAX
};
assert_eq!(event["issued_at_unix"], bound);
assert_eq!(event["expires_at_unix"], bound);
let verified = event["verified_at_unix"].as_i64().unwrap();
assert_eq!(
event["time_delta_seconds"].as_u64(),
Some(bound.abs_diff(verified))
);
assert_eq!(event["time_tolerance_seconds"], 0);
} else {
assert!(event.get("time_delta_seconds").is_none());
assert!(event.get("issued_at_unix").is_none());
}
for secret in [
&proof,
&identity.private_key,
&spoofed.private_key,
&unbound_id,
"access_token",
"query-secret",
body,
"bearer-secret",
"audience-secret",
"workspace-secret",
"permission-secret",
"path-secret",
"worker-secret",
"revoked-runtime-secret",
&revoked.private_key,
] {
assert!(!text.contains(secret), "auth log leaked request data");
assert!(
!text.contains(secret),
"auth log leaked request data: {reason}"
);
}
assert!(text.len() < 2200, "unbounded diagnostic: {}", text.len());
}
}
}
#[tokio::test]
async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_metadata() {
let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
api.config.backend_base_url = None;
let proof = RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-test",
TEST_WORKSPACE_ID,
None,
WORKSPACE_REQUEST_PERMISSION,
"POST",
"/request",
b"{}",
90,
10,
)
.unwrap();
let error = crate::worker_source::verify_runtime_request_source_proof(
&api,
&proof,
"private-workspace-secret",
WORKSPACE_REQUEST_PERMISSION,
"POST",
"/request",
"digest",
)
.await
.unwrap_err();
let logs = CapturedAuthLog::default();
let long_path = format!("/{}?secret=query-secret", "é\n".repeat(1000));
let response = tracing::subscriber::with_default(logs.subscriber(), || {
runtime_request_proof_rejection(
&"M\n".repeat(1000),
&long_path,
&"w\n".repeat(1000),
&error,
)
});
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(
to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.is_empty()
);
let (event, text) = logs.event();
assert_eq!(event["reason"], "backend_audience_unavailable");
assert_eq!(event["proof_verification"], "unverified");
for (field, limit) in [("method", 32), ("path", 512), ("workspace_id", 128)] {
let value = event[field].as_str().unwrap();
assert!(value.len() <= limit);
assert!(!value.chars().any(char::is_control));
}
for secret in [&proof, "private-workspace-secret", "query-secret"] {
assert!(!text.contains(secret));
}
}
#[tokio::test]
async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() {
let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories");
for app in [
build_router(api.clone()),
workspace_server_router(WorkspaceServerApi::new(
api.config.clone(),
api.store.clone(),
)),
] {
for issued_at in [i64::MIN, i64::MAX] {
let proof = RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-test",
TEST_WORKSPACE_ID,
None,
WORKSPACE_REQUEST_PERMISSION,
"GET",
&path,
b"",
issued_at,
0,
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(&path)
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, &proof)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let actual: Value = serde_json::from_slice(&body).unwrap();
let expected = serde_json::to_value(server_api::RepositoryApiError::new(
401,
"Unauthorized",
"invalid runtime request proof",
Vec::new(),
))
.unwrap();
assert_eq!(
actual, expected,
"auth internals must not expand the external envelope"
);
}
}
}
@@ -139,26 +139,33 @@ impl worker::WorkspaceClient for ConfigRouterClient {
Ok(response)
}
}
fn config_interface(path: &str) -> wip_protocol::InterfaceReference {
// Use the published structured Interface identity, not a model-managed validator.
wip_protocol::InterfaceReference {
scope: path.into(),
name: "yoi.workspace-config/node/v1".into(),
}
fn config_interface(path: &str) -> Value {
// Exact contextual reference published by the current config Object.
json!({"scope": path, "name": "yoi.workspace-config/node/v1"})
}
async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) {
let output = runtime.inspect(path.into(), true).await.unwrap();
let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) {
inspect_published_interface(runtime, path, &config_interface(path)).await;
}
async fn inspect_published_interface(
runtime: &worker::wip::WipRuntime,
path: &str,
expected: &Value,
) {
let tree = runtime.tree(path.into(), 0, true).await.unwrap();
let tree: Value = serde_json::from_str(tree.content.as_deref().unwrap()).unwrap();
assert_eq!(
tree["path"], path,
"Object must come from live Tree: {tree}"
);
let inspected = runtime.inspect(path.into(), true).await.unwrap();
let inspected: Value = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap();
assert!(
observed["interfaces"]
inspected["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|interface| {
interface["reference"]
== json!({"scope": path, "name": config_interface(path).name})
}),
"Interface must come from live Object inspection: {observed}"
.any(|interface| &interface["reference"] == expected),
"interface must come from live path-centered Inspect: {inspected}"
);
}
async fn config_call(
@@ -170,7 +177,10 @@ async fn config_call(
runtime
.invoke(
path.into(),
config_interface(path),
wip_protocol::InterfaceReference {
scope: path.into(),
name: "yoi.workspace-config/node/v1".into(),
},
operation.into(),
args,
agen::tool::ToolExecutionContext::direct(),
@@ -184,17 +194,9 @@ async fn catalog_call(
base: &str,
operation: &str,
) -> Value {
let interface = json!({"scope": path, "name": base});
let inspected = runtime.inspect(path.into(), true).await.unwrap();
let observed: Value = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap();
assert!(
observed["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|published| published["reference"] == interface),
"{observed}"
);
// Live catalog publication contextualizes the Interface to this Object.
let reference = json!({"scope": path, "name": base});
inspect_published_interface(runtime, path, &reference).await;
let output = runtime
.invoke(
path.into(),
@@ -380,7 +382,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.connection_id,
attached.connection_id
);
config_inspect(&wip, CONTENT_ROOT).await;
config_discover(&wip, CONTENT_ROOT).await;
config_call(
&wip,
CONTENT_ROOT,
@@ -390,7 +392,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.await
.unwrap();
let path = "/workspace-config/notes/wip.md";
config_inspect(&wip, path).await;
config_discover(&wip, path).await;
assert!(
config_call(&wip, path, "read", json!({}))
.await
@@ -424,7 +426,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
config_call(&wip, path, "write", json!({"content":"replacement note"}))
.await
.unwrap();
config_inspect(&wip, CONTENT_ROOT).await;
config_discover(&wip, CONTENT_ROOT).await;
config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[
{"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"},
{"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"}
@@ -438,7 +440,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.unwrap();
let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap();
assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E");
config_inspect(&wip, path).await;
config_discover(&wip, path).await;
config_call(&wip, path, "delete", json!({})).await.unwrap();
assert!(
fixture
@@ -451,7 +453,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.get(&config_source::VirtualPath::parse("notes/wip.md").unwrap())
.is_none()
);
config_inspect(&wip, CONTENT_ROOT).await;
config_discover(&wip, CONTENT_ROOT).await;
let before = fixture
.api
.config_store
@@ -494,7 +496,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
dispatched + 1,
"a lost response cannot trigger automatic retry"
);
config_inspect(&wip, CONTENT_ROOT).await;
config_discover(&wip, CONTENT_ROOT).await;
let readonly = workspace_config::attach(
&fixture.api,
&fixture.worker,
@@ -635,7 +637,7 @@ async fn assert_config_production_http_adapter(
)
.unwrap();
assert_config_catalog(&wip, &attached).await;
config_inspect(&wip, "/workspace-config/main.dcdl").await;
config_discover(&wip, "/workspace-config/main.dcdl").await;
let main_interface = wip
.inspect("/workspace-config/main.dcdl".into(), true)
.await
@@ -654,7 +656,7 @@ async fn assert_config_production_http_adapter(
.unwrap()
.contains("WorkspaceConfigSchema")
);
config_inspect(&wip, "/workspace-config").await;
config_discover(&wip, "/workspace-config").await;
config_call(
&wip,
"/workspace-config",
+8 -109
View File
@@ -3,120 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap;
use worker_runtime::auth::{
RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceClaims, WorkerMutationSourceExpectation,
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_runtime_request_source, verify_worker_mutation_source_proof,
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof,
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: String,
pub worker_id: Option<String>,
}
pub async fn verify_runtime_request_source_proof(
api: &WorkspaceApi,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
verify_runtime_request_source_proof_with_store(
api.store.as_ref(),
&api.config,
proof,
workspace_id,
permission,
method,
path,
body_digest,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn verify_runtime_request_source_proof_with_store(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
let public_key = trusted.public_key.as_str();
let expected = RuntimeRequestSourceExpectation {
identity_id: &unverified.iss,
audience: audience.as_ref(),
workspace_id,
worker_id: unverified.worker_id.as_deref(),
permission,
method,
path,
body_digest,
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
};
let claims =
verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?;
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
.ok_or(WorkerMutationSourceProofError::Expired)?
.to_rfc3339();
if !store
.consume_worker_mutation_source_jti(
workspace_id,
&claims.iss,
&claims.jti,
expires_at,
now_seconds,
&consumed_at,
)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
{
return Err(WorkerMutationSourceProofError::Replay);
}
if let Some(worker_id) = claims.worker_id.as_deref() {
let worker = worker_runtime::identity::RuntimeWorkerRef {
runtime_id: claims.iss.clone(),
worker_id: worker_id.to_owned(),
};
let member = store
.get_worker_registry(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
let reserved = store
.has_active_worker_create_reservation(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if member.is_none() && !reserved {
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
}
store
.require_current_worker_singleton_owner(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
}
Ok(VerifiedRuntimeRequestSource {
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
}
mod runtime_request;
pub use runtime_request::{
RuntimeRequestProofError, VerifiedRuntimeRequestSource, diagnostic_id_hash,
verify_runtime_request_source_proof, verify_runtime_request_source_proof_with_store,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresentedWorkerMutationSourceProof<'a> {
@@ -0,0 +1,248 @@
use worker_runtime::auth::{
RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims,
RuntimeRequestSourceExpectation, decode_runtime_request_source_claims,
verify_runtime_request_source,
};
use super::{remote_audience, unix_now_seconds};
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: String,
pub worker_id: Option<String>,
}
/// Safe diagnostics only: never stores proof, raw claims, keys or store errors.
/// Verification is progress through proof checks, NOT an authenticated principal.
#[derive(Clone, Debug, thiserror::Error)]
#[error("invalid runtime request proof")]
pub struct RuntimeRequestProofError {
pub(crate) reason: &'static str,
pub(crate) verification: &'static str,
pub(crate) runtime_id_hash: Option<String>,
pub(crate) worker_id_hash: Option<String>,
pub(crate) token_id_hash: Option<String>,
pub(crate) iat: Option<i64>,
pub(crate) exp: Option<i64>,
pub(crate) now: Option<i64>,
pub(crate) time_delta_seconds: Option<u64>,
}
impl RuntimeRequestProofError {
fn undecoded() -> Self {
Self {
reason: "invalid",
verification: "unverified",
runtime_id_hash: None,
worker_id_hash: None,
token_id_hash: None,
iat: None,
exp: None,
now: None,
time_delta_seconds: None,
}
}
fn decoded(claims: &RuntimeRequestSourceClaims) -> Self {
Self {
runtime_id_hash: Some(diagnostic_id_hash(&claims.iss)),
worker_id_hash: claims.worker_id.as_deref().map(diagnostic_id_hash),
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
..Self::undecoded()
}
}
fn reason(mut self, reason: &'static str) -> Self {
self.reason = reason;
self
}
fn auth_error(mut self, error: RuntimeAuthError) -> Self {
self.reason = match error {
RuntimeAuthError::InvalidTokenFormat => "invalid_format",
RuntimeAuthError::InvalidBase64(_) => "invalid_encoding",
RuntimeAuthError::MalformedClaims(_) => "malformed_claims",
RuntimeAuthError::InvalidPublicKeyFormat => "invalid_runtime_public_key",
RuntimeAuthError::InvalidSignature => "invalid_signature",
RuntimeAuthError::ClaimMismatch(claim) => {
self.verification = "signature_verified";
match claim {
RuntimeRequestClaim::Issuer => "issuer_mismatch",
RuntimeRequestClaim::Audience => "audience_mismatch",
RuntimeRequestClaim::Workspace => "workspace_mismatch",
RuntimeRequestClaim::Worker => "worker_mismatch",
RuntimeRequestClaim::Permission => "permission_mismatch",
RuntimeRequestClaim::Method => "method_mismatch",
RuntimeRequestClaim::Path => "path_mismatch",
RuntimeRequestClaim::BodyDigest => "body_digest_mismatch",
}
}
RuntimeAuthError::RequestIssuedInFuture { iat, exp, now } => {
self.time(iat, exp, now, iat.abs_diff(now));
"issued_in_future"
}
RuntimeAuthError::RequestExpired { iat, exp, now } => {
self.time(iat, exp, now, now.abs_diff(exp));
"expired"
}
_ => "invalid",
};
self
}
fn time(&mut self, iat: i64, exp: i64, now: i64, delta: u64) {
self.verification = "signature_verified";
self.iat = Some(iat);
self.exp = Some(exp);
self.now = Some(now);
self.time_delta_seconds = Some(delta);
}
}
/// Domain-separated fixed-length fingerprints, not raw request-derived IDs.
/// Operators can correlate a known ID by applying this same function.
pub fn diagnostic_id_hash(id: &str) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(b"yoi-runtime-proof-diagnostic-id-v1\0");
hash.update(id.as_bytes());
hash.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub async fn verify_runtime_request_source_proof(
api: &WorkspaceApi,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
verify_runtime_request_source_proof_with_store(
api.store.as_ref(),
&api.config,
proof,
workspace_id,
permission,
method,
path,
body_digest,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn verify_runtime_request_source_proof_with_store(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|error| RuntimeRequestProofError::undecoded().auth_error(error))?;
let diagnostic = RuntimeRequestProofError::decoded(&unverified);
let audience = remote_audience(config, workspace_id)
.map_err(|_| diagnostic.clone().reason("backend_audience_unavailable"))?;
let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
.map_err(|_| {
diagnostic
.clone()
.reason("runtime_trust_authority_unavailable")
})?
.filter(|record| record.revoked_at.is_none())
.ok_or_else(|| {
diagnostic
.clone()
.reason("runtime_trust_missing_or_revoked")
})?;
let expected = RuntimeRequestSourceExpectation {
identity_id: &unverified.iss,
audience,
workspace_id,
worker_id: unverified.worker_id.as_deref(),
permission,
method,
path,
body_digest,
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
};
let claims =
verify_runtime_request_source(proof, &trusted.public_key, &expected).map_err(|error| {
// Proof encoding was already decoded successfully. A base64 error
// at this stage is in the configured public key, not the proof.
let error = match error {
RuntimeAuthError::InvalidBase64(_) => RuntimeAuthError::InvalidPublicKeyFormat,
other => other,
};
diagnostic.clone().auth_error(error)
})?;
let diagnostic = RuntimeRequestProofError {
verification: "request_verified",
..diagnostic
};
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
.ok_or_else(|| diagnostic.clone().reason("verification_time_out_of_range"))?
.to_rfc3339();
if !store
.consume_worker_mutation_source_jti(
workspace_id,
&claims.iss,
&claims.jti,
expires_at,
now_seconds,
&consumed_at,
)
.await
.map_err(|_| diagnostic.clone().reason("replay_authority_unavailable"))?
{
return Err(diagnostic.reason("replay"));
}
if let Some(worker_id) = claims.worker_id.as_deref() {
let worker = worker_runtime::identity::RuntimeWorkerRef {
runtime_id: claims.iss.clone(),
worker_id: worker_id.to_owned(),
};
let member = store
.get_worker_registry(workspace_id, &worker)
.map_err(|_| {
diagnostic
.clone()
.reason("worker_catalog_authority_unavailable")
})?;
let reserved = store
.has_active_worker_create_reservation(workspace_id, &worker)
.map_err(|_| {
diagnostic
.clone()
.reason("worker_reservation_authority_unavailable")
})?;
if member.is_none() && !reserved {
return Err(diagnostic.reason("worker_catalog_membership"));
}
store
.require_current_worker_singleton_owner(workspace_id, &worker)
.map_err(|_| {
diagnostic
.clone()
.reason("worker_singleton_authority_denied")
})?;
}
Ok(VerifiedRuntimeRequestSource {
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
}