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")]
|
#[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 {
|
}
|
||||||
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)
|
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
|
||||||
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_discover(runtime: &worker::wip::WipRuntime, path: &str) {
|
||||||
|
inspect_published_interface(runtime, path, &config_interface(path)).await;
|
||||||
}
|
}
|
||||||
async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) {
|
async fn inspect_published_interface(
|
||||||
let output = runtime.inspect(path.into(), true).await.unwrap();
|
runtime: &worker::wip::WipRuntime,
|
||||||
let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
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",
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user