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:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user