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:
@@ -42,7 +42,11 @@ pub enum RuntimeAuthError {
|
||||
#[error("runtime request proof contains an invalid `{0}` claim")]
|
||||
InvalidClaim(&'static str),
|
||||
#[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}`")]
|
||||
UnknownIssuer(String),
|
||||
#[error("invalid token signature")]
|
||||
@@ -171,6 +175,19 @@ pub struct RuntimeAuthContext {
|
||||
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)]
|
||||
pub struct RuntimeRequestSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -293,19 +310,54 @@ pub fn verify_runtime_request_source(
|
||||
public_key,
|
||||
)?;
|
||||
let claims = signed.claims;
|
||||
if claims.iss != expected.identity_id
|
||||
|| claims.aud != expected.audience
|
||||
|| claims.workspace_id != expected.workspace_id
|
||||
|| claims.worker_id.as_deref() != expected.worker_id
|
||||
|| claims.permission != expected.permission
|
||||
|| claims.method != expected.method
|
||||
|| claims.path != expected.path
|
||||
|| claims.body_digest != expected.body_digest
|
||||
{
|
||||
return Err(RuntimeAuthError::ClaimMismatch);
|
||||
for (matches, claim) in [
|
||||
(
|
||||
claims.iss == expected.identity_id,
|
||||
RuntimeRequestClaim::Issuer,
|
||||
),
|
||||
(
|
||||
claims.aud == expected.audience,
|
||||
RuntimeRequestClaim::Audience,
|
||||
),
|
||||
(
|
||||
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 {
|
||||
return Err(RuntimeAuthError::Expired);
|
||||
if claims.iat > expected.now_unix {
|
||||
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)
|
||||
}
|
||||
@@ -705,7 +757,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
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();
|
||||
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]
|
||||
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
|
||||
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
@@ -749,7 +980,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
|
||||
Err(RuntimeAuthError::ClaimMismatch)
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::Workspace
|
||||
))
|
||||
));
|
||||
let expired = RuntimeRequestSourceExpectation {
|
||||
now_unix: 101,
|
||||
@@ -757,7 +990,11 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
|
||||
Err(RuntimeAuthError::Expired)
|
||||
Err(RuntimeAuthError::RequestExpired {
|
||||
iat: 90,
|
||||
exp: 100,
|
||||
now: 101
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user