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
+253 -16
View File
@@ -42,7 +42,11 @@ pub enum RuntimeAuthError {
#[error("runtime request proof contains an invalid `{0}` claim")] #[error("runtime request proof contains an invalid `{0}` claim")]
InvalidClaim(&'static str), InvalidClaim(&'static str),
#[error("runtime request proof does not match the HTTP request")] #[error("runtime request proof does not match the HTTP request")]
ClaimMismatch, ClaimMismatch(RuntimeRequestClaim),
#[error("runtime request proof was issued in the future")]
RequestIssuedInFuture { iat: i64, exp: i64, now: i64 },
#[error("runtime request proof has expired")]
RequestExpired { iat: i64, exp: i64, now: i64 },
#[error("unknown token issuer `{0}`")] #[error("unknown token issuer `{0}`")]
UnknownIssuer(String), UnknownIssuer(String),
#[error("invalid token signature")] #[error("invalid token signature")]
@@ -171,6 +175,19 @@ pub struct RuntimeAuthContext {
pub expires_at: u64, pub expires_at: u64,
} }
/// The first mismatching signed claim, in verification order. No input values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuntimeRequestClaim {
Issuer,
Audience,
Workspace,
Worker,
Permission,
Method,
Path,
BodyDigest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims { pub struct RuntimeRequestSourceClaims {
pub iss: String, pub iss: String,
@@ -293,19 +310,54 @@ pub fn verify_runtime_request_source(
public_key, public_key,
)?; )?;
let claims = signed.claims; let claims = signed.claims;
if claims.iss != expected.identity_id for (matches, claim) in [
|| claims.aud != expected.audience (
|| claims.workspace_id != expected.workspace_id claims.iss == expected.identity_id,
|| claims.worker_id.as_deref() != expected.worker_id RuntimeRequestClaim::Issuer,
|| claims.permission != expected.permission ),
|| claims.method != expected.method (
|| claims.path != expected.path claims.aud == expected.audience,
|| claims.body_digest != expected.body_digest RuntimeRequestClaim::Audience,
{ ),
return Err(RuntimeAuthError::ClaimMismatch); (
claims.workspace_id == expected.workspace_id,
RuntimeRequestClaim::Workspace,
),
(
claims.worker_id.as_deref() == expected.worker_id,
RuntimeRequestClaim::Worker,
),
(
claims.permission == expected.permission,
RuntimeRequestClaim::Permission,
),
(
claims.method == expected.method,
RuntimeRequestClaim::Method,
),
(claims.path == expected.path, RuntimeRequestClaim::Path),
(
claims.body_digest == expected.body_digest,
RuntimeRequestClaim::BodyDigest,
),
] {
if !matches {
return Err(RuntimeAuthError::ClaimMismatch(claim));
}
} }
if claims.iat > expected.now_unix || claims.exp < expected.now_unix { if claims.iat > expected.now_unix {
return Err(RuntimeAuthError::Expired); return Err(RuntimeAuthError::RequestIssuedInFuture {
iat: claims.iat,
exp: claims.exp,
now: expected.now_unix,
});
}
if claims.exp < expected.now_unix {
return Err(RuntimeAuthError::RequestExpired {
iat: claims.iat,
exp: claims.exp,
now: expected.now_unix,
});
} }
Ok(claims) Ok(claims)
} }
@@ -705,7 +757,9 @@ mod tests {
}; };
assert!(matches!( assert!(matches!(
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body), verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
Err(RuntimeAuthError::ClaimMismatch) Err(RuntimeAuthError::ClaimMismatch(
RuntimeRequestClaim::BodyDigest
))
)); ));
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
assert!(matches!( assert!(matches!(
@@ -714,6 +768,183 @@ mod tests {
)); ));
} }
#[test]
fn runtime_request_proof_preserves_inclusive_time_bounds_and_reports_exact_rejections() {
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let signer = RuntimeRequestSourceSigner::from_identity(&runtime);
let proof = signer
.issue(
"server",
"workspace",
None,
"permission",
"POST",
"/request",
b"{}",
90,
10,
)
.unwrap();
let digest = request_body_digest(b"{}");
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-main",
audience: "server",
workspace_id: "workspace",
worker_id: None,
permission: "permission",
method: "POST",
path: "/request",
body_digest: &digest,
now_unix: 90,
};
for now in [90, 99, 100] {
assert!(
verify_runtime_request_source(
&proof,
&runtime.public_key,
&RuntimeRequestSourceExpectation {
now_unix: now,
..expected.clone()
}
)
.is_ok()
);
}
for (now, future) in [(89, true), (101, false)] {
let error = verify_runtime_request_source(
&proof,
&runtime.public_key,
&RuntimeRequestSourceExpectation {
now_unix: now,
..expected.clone()
},
)
.unwrap_err();
match error {
RuntimeAuthError::RequestIssuedInFuture {
iat,
exp,
now: verified,
} if future => assert_eq!((iat, exp, verified), (90, 100, now)),
RuntimeAuthError::RequestExpired {
iat,
exp,
now: verified,
} if !future => assert_eq!((iat, exp, verified), (90, 100, now)),
other => panic!("unexpected rejection: {other:?}"),
}
}
for (claim, expectation) in [
(
RuntimeRequestClaim::Issuer,
RuntimeRequestSourceExpectation {
identity_id: "other",
..expected.clone()
},
),
(
RuntimeRequestClaim::Audience,
RuntimeRequestSourceExpectation {
audience: "other",
..expected.clone()
},
),
(
RuntimeRequestClaim::Workspace,
RuntimeRequestSourceExpectation {
workspace_id: "other",
..expected.clone()
},
),
(
RuntimeRequestClaim::Worker,
RuntimeRequestSourceExpectation {
worker_id: Some("other"),
..expected.clone()
},
),
(
RuntimeRequestClaim::Permission,
RuntimeRequestSourceExpectation {
permission: "other",
..expected.clone()
},
),
(
RuntimeRequestClaim::Method,
RuntimeRequestSourceExpectation {
method: "GET",
..expected.clone()
},
),
(
RuntimeRequestClaim::Path,
RuntimeRequestSourceExpectation {
path: "/other",
..expected.clone()
},
),
(
RuntimeRequestClaim::BodyDigest,
RuntimeRequestSourceExpectation {
body_digest: "other",
..expected.clone()
},
),
] {
assert!(
matches!(verify_runtime_request_source(&proof, &runtime.public_key, &expectation), Err(RuntimeAuthError::ClaimMismatch(actual)) if actual == claim)
);
}
assert!(matches!(
verify_runtime_request_source(
&proof,
&runtime.public_key,
&RuntimeRequestSourceExpectation {
audience: "other",
now_unix: 101,
..expected.clone()
}
),
Err(RuntimeAuthError::ClaimMismatch(
RuntimeRequestClaim::Audience
))
));
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
assert!(matches!(
verify_runtime_request_source(
&proof,
&spoofed.public_key,
&RuntimeRequestSourceExpectation {
now_unix: 101,
..expected.clone()
}
),
Err(RuntimeAuthError::InvalidSignature)
));
let mut claims = decode_runtime_request_source_claims(&proof).unwrap();
claims.iat = 101;
claims.exp = 89;
let contradictory = sign_json_token(
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
&runtime.signing_key().unwrap(),
&claims,
)
.unwrap();
assert!(matches!(
verify_runtime_request_source(
&contradictory,
&runtime.public_key,
&RuntimeRequestSourceExpectation {
now_unix: 100,
..expected.clone()
}
),
Err(RuntimeAuthError::RequestIssuedInFuture { .. })
));
}
#[test] #[test]
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() { fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
@@ -749,7 +980,9 @@ mod tests {
}; };
assert!(matches!( assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace), verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
Err(RuntimeAuthError::ClaimMismatch) Err(RuntimeAuthError::ClaimMismatch(
RuntimeRequestClaim::Workspace
))
)); ));
let expired = RuntimeRequestSourceExpectation { let expired = RuntimeRequestSourceExpectation {
now_unix: 101, now_unix: 101,
@@ -757,7 +990,11 @@ mod tests {
}; };
assert!(matches!( assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &expired), verify_runtime_request_source(&proof, &runtime.public_key, &expired),
Err(RuntimeAuthError::Expired) Err(RuntimeAuthError::RequestExpired {
iat: 90,
exp: 100,
now: 101
})
)); ));
} }
} }
+37 -24
View File
@@ -2632,34 +2632,30 @@ fn runtime_request_proof_rejection(
method: &str, method: &str,
path: &str, path: &str,
workspace_id: &str, workspace_id: &str,
error: &crate::worker_source::WorkerMutationSourceProofError, error: &crate::worker_source::RuntimeRequestProofError,
) -> Response { ) -> Response {
use crate::worker_source::WorkerMutationSourceProofError; // Never format an error or raw claims. IDs are fixed-length fingerprints;
// verification labels describe proof-check progress, not authorization.
// 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",
};
let path = path.split('?').next().unwrap_or(path); 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!( tracing::warn!(
target: "yoi::auth", target: "yoi::auth",
event = "runtime_request_proof_rejected", event = "runtime_request_proof_rejected",
method, method, path = log_path, workspace_id, request_id,
path,
workspace_id,
status = 401, 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", "Runtime request proof rejected",
); );
repository_api_rejection( 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 /// Authored configuration editors are user-facing authority, not a generic
/// Runtime workspace.request capability. Worker configuration access is only /// Runtime workspace.request capability. Worker configuration access is only
/// through the grant- and connection-bound WIP adapter. Keep runtime projections /// through the grant- and connection-bound WIP adapter. Keep runtime projections
@@ -55527,7 +55534,10 @@ mod tests {
.await; .await;
assert!(matches!( assert!(matches!(
result, 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; .await;
assert!(matches!( assert!(matches!(
result, 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); self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len()) Ok(bytes.len())
} }
fn flush(&mut self) -> std::io::Result<()> { fn flush(&mut self) -> std::io::Result<()> {
Ok(()) Ok(())
} }
} }
#[test] impl CapturedAuthLog {
fn runtime_proof_diagnostics_redact_error_payloads() { fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync + use<> {
use crate::worker_source::WorkerMutationSourceProofError; let writer = self.clone();
tracing_subscriber::fmt()
for (error, reason) in [ .with_max_level(tracing::Level::WARN)
(
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()
.without_time() .without_time()
.with_ansi(false) .with_ansi(false)
.json() .json()
.flatten_event(true) .flatten_event(true)
.with_writer(move || writer.clone()) .with_writer(move || writer.clone())
.finish(); .finish()
tracing::subscriber::with_default(subscriber, || { }
let response = runtime_request_proof_rejection( fn event(&self) -> (Value, String) {
"POST", let text = String::from_utf8(self.0.lock().unwrap().clone()).unwrap();
"/api/w/workspace/workers/self/workdir-session/operations?secret=query-secret", let events: Vec<Value> = text
"workspace", .lines()
&error, .map(|line| serde_json::from_str::<Value>(line).unwrap())
); .filter(|event| event["event"] == "runtime_request_proof_rejected")
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); .collect();
}); assert_eq!(
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); events.len(),
let event: Value = serde_json::from_str(text.trim()).unwrap(); 1,
assert_eq!(event["reason"], reason); "missing or duplicated auth diagnostic: {text}"
assert!(!text.contains("private-"), "auth log leaked error details");
assert!(
!text.contains("query-secret"),
"auth log leaked query values"
); );
(events[0].clone(), text)
} }
} }
#[tokio::test] #[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 workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await; let mut api = test_api(workspace.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-test"); 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 path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let target = format!("{path}?access_token=query-secret"); let target = format!("{path}?access_token=query-secret");
let body = "body-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 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 now = i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap();
let proof = if reason == "invalid" { let signing_identity = match case {
"invalid-proof-secret".to_string() "invalid_signature" => &spoofed,
} else { "revoked_runtime" => &revoked,
signer "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( .issue(
"server-test", if reason == "audience_mismatch" {
TEST_WORKSPACE_ID, "audience-secret"
None, } else {
WORKSPACE_REQUEST_PERMISSION, "server-test"
"POST", },
&target, if reason == "workspace_mismatch" {
body.as_bytes(), "workspace-secret"
if reason == "expired" { now - 120 } else { now }, } else {
30, 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" { if reason == "replay" {
crate::worker_source::verify_runtime_request_source_proof( 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(); .unwrap();
} }
let logs = CapturedAuthLog::default(); 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 let response = app
.clone() .clone()
.oneshot( .oneshot(
@@ -135,39 +192,206 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
.body(Body::from(body)) .body(Body::from(body))
.unwrap(), .unwrap(),
) )
.with_subscriber(subscriber) .with_subscriber(logs.subscriber())
.await .await
.unwrap(); .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(); let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(response_body.is_empty(), "{response_body:?}"); assert!(response_body.is_empty(), "{response_body:?}");
let (event, text) = logs.event();
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];
assert_eq!(event["reason"], reason); assert_eq!(event["reason"], reason);
assert_eq!(event["proof_verification"], verification);
assert_eq!(event["status"], 401); assert_eq!(event["status"], 401);
assert_eq!(event["method"], "POST"); assert_eq!(event["method"], "POST");
assert_eq!(event["path"], path); assert_eq!(event["path"], path);
assert_eq!(event["workspace_id"], TEST_WORKSPACE_ID); 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 [ for secret in [
&proof, &proof,
&identity.private_key,
&spoofed.private_key,
&unbound_id,
"access_token", "access_token",
"query-secret", "query-secret",
body, body,
"bearer-secret", "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) Ok(response)
} }
} }
fn config_interface(path: &str) -> wip_protocol::InterfaceReference { fn config_interface(path: &str) -> Value {
// Use the published structured Interface identity, not a model-managed validator. // Exact contextual reference published by the current config Object.
wip_protocol::InterfaceReference { json!({"scope": path, "name": "yoi.workspace-config/node/v1"})
scope: path.into(),
name: "yoi.workspace-config/node/v1".into(),
}
} }
async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) { async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) {
let output = runtime.inspect(path.into(), true).await.unwrap(); inspect_published_interface(runtime, path, &config_interface(path)).await;
let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); }
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!( assert!(
observed["interfaces"] inspected["interfaces"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|interface| { .any(|interface| &interface["reference"] == expected),
interface["reference"] "interface must come from live path-centered Inspect: {inspected}"
== json!({"scope": path, "name": config_interface(path).name})
}),
"Interface must come from live Object inspection: {observed}"
); );
} }
async fn config_call( async fn config_call(
@@ -170,7 +177,10 @@ async fn config_call(
runtime runtime
.invoke( .invoke(
path.into(), path.into(),
config_interface(path), wip_protocol::InterfaceReference {
scope: path.into(),
name: "yoi.workspace-config/node/v1".into(),
},
operation.into(), operation.into(),
args, args,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
@@ -184,17 +194,9 @@ async fn catalog_call(
base: &str, base: &str,
operation: &str, operation: &str,
) -> Value { ) -> Value {
let interface = json!({"scope": path, "name": base}); // Live catalog publication contextualizes the Interface to this Object.
let inspected = runtime.inspect(path.into(), true).await.unwrap(); let reference = json!({"scope": path, "name": base});
let observed: Value = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); inspect_published_interface(runtime, path, &reference).await;
assert!(
observed["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|published| published["reference"] == interface),
"{observed}"
);
let output = runtime let output = runtime
.invoke( .invoke(
path.into(), path.into(),
@@ -380,7 +382,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.connection_id, .connection_id,
attached.connection_id attached.connection_id
); );
config_inspect(&wip, CONTENT_ROOT).await; config_discover(&wip, CONTENT_ROOT).await;
config_call( config_call(
&wip, &wip,
CONTENT_ROOT, CONTENT_ROOT,
@@ -390,7 +392,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.await .await
.unwrap(); .unwrap();
let path = "/workspace-config/notes/wip.md"; let path = "/workspace-config/notes/wip.md";
config_inspect(&wip, path).await; config_discover(&wip, path).await;
assert!( assert!(
config_call(&wip, path, "read", json!({})) config_call(&wip, path, "read", json!({}))
.await .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"})) config_call(&wip, path, "write", json!({"content":"replacement note"}))
.await .await
.unwrap(); .unwrap();
config_inspect(&wip, CONTENT_ROOT).await; config_discover(&wip, CONTENT_ROOT).await;
config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[ 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":"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"} {"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(); .unwrap();
let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap(); let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap();
assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E"); 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(); config_call(&wip, path, "delete", json!({})).await.unwrap();
assert!( assert!(
fixture 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()) .get(&config_source::VirtualPath::parse("notes/wip.md").unwrap())
.is_none() .is_none()
); );
config_inspect(&wip, CONTENT_ROOT).await; config_discover(&wip, CONTENT_ROOT).await;
let before = fixture let before = fixture
.api .api
.config_store .config_store
@@ -494,7 +496,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
dispatched + 1, dispatched + 1,
"a lost response cannot trigger automatic retry" "a lost response cannot trigger automatic retry"
); );
config_inspect(&wip, CONTENT_ROOT).await; config_discover(&wip, CONTENT_ROOT).await;
let readonly = workspace_config::attach( let readonly = workspace_config::attach(
&fixture.api, &fixture.api,
&fixture.worker, &fixture.worker,
@@ -635,7 +637,7 @@ async fn assert_config_production_http_adapter(
) )
.unwrap(); .unwrap();
assert_config_catalog(&wip, &attached).await; 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 let main_interface = wip
.inspect("/workspace-config/main.dcdl".into(), true) .inspect("/workspace-config/main.dcdl".into(), true)
.await .await
@@ -654,7 +656,7 @@ async fn assert_config_production_http_adapter(
.unwrap() .unwrap()
.contains("WorkspaceConfigSchema") .contains("WorkspaceConfigSchema")
); );
config_inspect(&wip, "/workspace-config").await; config_discover(&wip, "/workspace-config").await;
config_call( config_call(
&wip, &wip,
"/workspace-config", "/workspace-config",
+8 -109
View File
@@ -3,120 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use worker_runtime::auth::{ use worker_runtime::auth::{
RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
WorkerMutationSourceClaims, WorkerMutationSourceExpectation, WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
decode_runtime_request_source_claims, decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
verify_runtime_request_source, verify_worker_mutation_source_proof,
}; };
use worker_runtime::worker_source::InProcessWorkerMutationProof; use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::server::{ServerConfig, WorkspaceApi}; use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
#[derive(Clone, Debug, PartialEq, Eq)] mod runtime_request;
pub struct VerifiedRuntimeRequestSource { pub use runtime_request::{
pub runtime_id: String, RuntimeRequestProofError, VerifiedRuntimeRequestSource, diagnostic_id_hash,
pub worker_id: Option<String>, verify_runtime_request_source_proof, verify_runtime_request_source_proof_with_store,
} };
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,
})
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresentedWorkerMutationSourceProof<'a> { 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,
})
}
@@ -0,0 +1,83 @@
# Runtime request proof rejection diagnostics
`runtime_request_proof_rejected` (`yoi::auth`, WARN) is an internal operational
record. HTTP responses are unchanged: 401 with no proof details (repository
routes retain their existing generic API error envelope). It does **not** establish
why the reported same-machine intermittent 401 occurred. No reproduction or
causal measurement of that incident has been obtained.
## Verification and correlation
- `request_id`: Server-generated UUID identifying this rejection event. It is not
a caller-supplied ID or a response header, and is not a cross-service trace ID.
- `claimed_runtime_id_hash`, `claimed_worker_id_hash`, `claimed_token_id_hash`:
lowercase 64-character hex SHA-256 of
`b"yoi-runtime-proof-diagnostic-id-v1\0" + id.as_bytes()`.
`worker_source::diagnostic_id_hash` exposes this correlation function. Hash a
known Runtime/Worker ID to find its failures; token ID hashes correlate replay
attempts without logging the proof or JTI itself. These are fingerprints, not
encryption: low-entropy IDs can be guessed. Do not place secrets in IDs.
- All claims-derived fields are prefixed `claimed_`, even after signature checks.
`proof_verification` describes check progress, **not an authorized principal**:
- `unverified`: decoding may have succeeded, but signature verification has not.
- `signature_verified`: signature passed; request binding or time failed.
- `request_verified`: signature, request bindings and time passed, but replay,
membership or authority failed.
- When decoding fails, there are no claims fingerprints. Do not infer a Runtime
from a malformed proof. No raw claims, proof, key, header, body or raw error is
logged. The body digest and claim mismatch values are also deliberately absent.
- Method, query-free path, and requested workspace are request metadata, not
authenticated claims. They are bounded to 32, 512 and 128 UTF-8 bytes, with
control characters removed. Truncated paths are not unique identifiers. Query
names and values are never included. Paths are operational metadata, not a
place to put secrets.
## Reasons and their limits
Decoding distinguishes `invalid_format`, `invalid_encoding`, and
`malformed_claims`; no parser error text is included. Signature checks distinguish
`invalid_runtime_public_key` from `invalid_signature` (which cannot tell whether
an attacker, wrong key, corruption, or configuration mismatch caused the failure).
Request binding reports the first mismatch, in the existing check order:
`issuer_mismatch`, `audience_mismatch`, `workspace_mismatch`, `worker_mismatch`,
`permission_mismatch`, `method_mismatch`, `path_mismatch`, `body_digest_mismatch`.
The server selects the trusted key by the unverified issuer and expects that
same issuer/worker; issuer and worker mismatches can be diagnosed by the lower
verifier but are not independently detectable by the current server caller.
These diagnostics do not add new identity checks or change authorization.
`backend_audience_unavailable` denotes missing Backend public URL configuration.
`runtime_trust_missing_or_revoked` deliberately covers both absence and revocation,
as the existing store lookup/filter does not preserve that distinction.
`replay` means JTI consumption returned false. `worker_catalog_membership` means
neither registry membership nor an active create reservation was found.
Store failures identify their check stage without exposing raw errors:
`runtime_trust_authority_unavailable`, `replay_authority_unavailable`,
`worker_catalog_authority_unavailable`, `worker_reservation_authority_unavailable`,
`worker_singleton_authority_denied`. The last can represent either an owner
conflict or an authority failure: the store currently returns a shared error,
so the diagnostic does not guess which. `verification_time_out_of_range` means
that conversion to the JTI consumption timestamp failed; it is not proof expiry.
`invalid` is a fallback for lower verifier errors not classified for this path.
## Time contract and T-728 coordination
This change preserves strict integer-second checks, inclusive bounds, and
verification order: signature, request binding, then `iat > now`, then `exp < now`.
No skew tolerance is added. If both time predicates fail, future issuance wins.
- `issued_in_future`: `time_delta_seconds = iat - now` (positive).
- `expired`: `time_delta_seconds = now - exp` (positive).
- Only these reasons include `issued_at_unix`, `expires_at_unix`,
`verified_at_unix` and `time_tolerance_seconds` (currently zero).
- Deltas use `i64::abs_diff`, preserving the full positive `u64` range without
overflow. All times come from the actual signature-verified rejection and
the exact clock sample used for that decision, not a second log-time sample.
T-728 owns any change to tolerance/validity policy. If it changes these checks,
the error time data, logged effective tolerance, and boundary tests must change
together. Do not label future issuance as expiry or independently recompute the
rejection using a different clock sample. This is instrumentation for the next
incident, not evidence that the original incident has been resolved.
@@ -0,0 +1,33 @@
# T-729: restored base test-only compile blocker
The assigned checkout starts at `f9d4c97f` (WIP 0.2 integration). On 2026-10-08,
`cargo test -p yoi-workspace-server --lib auth_logging_tests` could not compile
because `server_workspace_config_wip_tests.rs` still calls `WipRuntime::discover`
(lines 152 and 192), which no longer exists, and external `WipRuntime::call`
(lines 170 and 200), which is now crate-private. At the initial failure those
files were unchanged by the T-729 diagnostic work.
A narrow test filter still builds all lib test modules, so it cannot isolate the
new authentication boundary tests from this unrelated test API mismatch. This
was escalated on T-729 instead of silently disabling those tests or widening the
Ticket to migrate unrelated configuration tests. Future API migration work should
check affected test-only consumers as well as the normal compilation closure.
The additional no-default-features check also fails in unchanged Runtime modules:
`resource.rs`, `worker_backend.rs` and `worker_source.rs` import feature-gated
`workspace_request`, `reqwest` and `futures` unconditionally. The no-default lib
test build additionally calls feature-gated filesystem constructors. This is not
an auth diagnostic regression and was not repaired by broadening T-729. Default
Runtime crate tests pass (345 library tests, 17 tests in each of the two binaries);
root `cargo check` passes. After correcting the T-729 SHA formatting, the Server
lib test build reports only the four existing WIP API errors above.
## Resolution of the Server prerequisite
Ticket T-729 seq7 explicitly authorized a narrow test-only migration. Commit
`717ff255` replaces the helpers with public Tree, path-centered Inspect and
structured InterfaceReference Invoke. All existing Backend authority, attachment,
mutation, stale observation and unknown-outcome assertions are retained. All 12
`workspace_config_integration` tests pass, and the authentication logging tests
now run (3 passed). No production API was exposed or legacy API revived. The
no-default-features issue remains separate and unresolved.