fix: authenticate Runtime requests without persisted verification
Use the current Workspace signing identity for each request and remove binding key copies and verification handshake gates. Preserve explicit trust and revocation, migrate historical evidence, and align Runtime settings and connection tests. Retain request scope and durable replay protection with regression coverage.
This commit is contained in:
@@ -1139,10 +1139,6 @@ pub struct RemainingRuntimeRoute {
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const RUNTIME_ROUTE_VERIFICATION_CHALLENGE: &str =
|
||||
"/v1/workspace-runtime-verification/challenge";
|
||||
pub const RUNTIME_ROUTE_VERIFICATION_ACK: &str =
|
||||
"/v1/workspace-runtime-verification/acknowledgement";
|
||||
pub const RUNTIME_ROUTE_CONFIG_BUNDLES: &str = "/v1/config-bundles";
|
||||
pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str =
|
||||
"/v1/config-bundles/{bundle_id}/availability";
|
||||
@@ -1169,16 +1165,6 @@ pub const RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT: &str =
|
||||
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
||||
/// than letting them be mistaken for management-contract omissions.
|
||||
pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
||||
RemainingRuntimeRoute {
|
||||
method: "POST",
|
||||
path: RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
|
||||
reason: "Workspace verification handshake",
|
||||
},
|
||||
RemainingRuntimeRoute {
|
||||
method: "POST",
|
||||
path: RUNTIME_ROUTE_VERIFICATION_ACK,
|
||||
reason: "Workspace verification handshake",
|
||||
},
|
||||
RemainingRuntimeRoute {
|
||||
method: "GET",
|
||||
path: RUNTIME_ROUTE_CONFIG_BUNDLES,
|
||||
@@ -1625,7 +1611,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remaining_route_inventory_does_not_overlap_contract() {
|
||||
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 21);
|
||||
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 19);
|
||||
for remaining in REMAINING_RUNTIME_ROUTES {
|
||||
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
||||
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
||||
|
||||
@@ -8837,62 +8837,14 @@ pub struct RuntimeSummary {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceRuntimeBindingState {
|
||||
Configured,
|
||||
Verified,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeConnectionDisplayState {
|
||||
Configured,
|
||||
Verified,
|
||||
Unavailable,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeVerificationOutcome {
|
||||
Verified,
|
||||
ChallengeIssued,
|
||||
VerificationFailed,
|
||||
ConnectivityFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeVerificationEvidenceSummary {
|
||||
pub verified_at: Option<String>,
|
||||
pub last_checked_at: String,
|
||||
pub last_outcome: RuntimeVerificationOutcome,
|
||||
pub binding_id: String,
|
||||
pub workspace_key_id: String,
|
||||
pub workspace_public_key_fingerprint: String,
|
||||
pub workspace_trust_id: String,
|
||||
pub runtime_public_key_fingerprint: String,
|
||||
}
|
||||
|
||||
/// Binding identity and explicit revocation only. Each signed request authenticates
|
||||
/// using the current Workspace signing identity, not a stored approval.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRuntimeBindingSummary {
|
||||
pub state: WorkspaceRuntimeBindingState,
|
||||
pub connection_state: RuntimeConnectionDisplayState,
|
||||
pub binding_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_key_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_trust_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -9076,9 +9028,6 @@ pub struct RuntimePublicIdentityBundle {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
pub public_bundle: RuntimePublicIdentityBundle,
|
||||
/// Exact Runtime-issued enrollment identity from `trust-workspace add/show`.
|
||||
/// A key fingerprint alone cannot distinguish revocation and re-enrollment.
|
||||
pub workspace_trust_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub endpoint: String,
|
||||
@@ -9121,12 +9070,11 @@ pub enum RuntimeConnectionTestFailureKind {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
/// Observation of an actual signed ping, not a grant or cached approval.
|
||||
pub struct RuntimeConnectionTestResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub binding_id: String,
|
||||
pub connection_state: RuntimeConnectionDisplayState,
|
||||
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||
pub checked_at: String,
|
||||
pub status: RuntimeConnectionTestStatus,
|
||||
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
||||
@@ -12626,18 +12574,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_registration_requires_explicit_runtime_issued_trust_identity() {
|
||||
let mut value = serde_json::json!({
|
||||
fn runtime_registration_uses_runtime_bundle_without_workspace_identity_copies() {
|
||||
let value = serde_json::json!({
|
||||
"public_bundle": {"identity_id": "runtime", "public_key": "public-key"},
|
||||
"endpoint": "https://runtime.example.test",
|
||||
"workspace_trust_id": "runtime-issued-enrollment"
|
||||
"endpoint": "https://runtime.example.test"
|
||||
});
|
||||
let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(request.workspace_trust_id, "runtime-issued-enrollment");
|
||||
value.as_object_mut().unwrap().remove("workspace_trust_id");
|
||||
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value.clone()).is_err());
|
||||
value["workspace_key_generation"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value).is_err());
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), value);
|
||||
for field in [
|
||||
"workspace_trust_id",
|
||||
"workspace_key_id",
|
||||
"workspace_key_generation",
|
||||
] {
|
||||
let mut obsolete = value.clone();
|
||||
obsolete[field] = serde_json::json!("obsolete");
|
||||
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(obsolete).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12646,8 +12598,6 @@ mod tests {
|
||||
"workspace_id": "workspace-test",
|
||||
"runtime_id": "runtime-test",
|
||||
"binding_id": "binding-test",
|
||||
"connection_state": "verified",
|
||||
"verification": null,
|
||||
"checked_at": "2026-09-01T12:00:00Z",
|
||||
"status": "compatible",
|
||||
"failure_kind": null,
|
||||
@@ -12665,21 +12615,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_trust_contract_binds_concrete_keys_and_binding_lifetimes() {
|
||||
let evidence = RuntimeVerificationEvidenceSummary {
|
||||
verified_at: Some("2026-10-06T00:00:00Z".into()),
|
||||
last_checked_at: "2026-10-06T00:00:00Z".into(),
|
||||
last_outcome: RuntimeVerificationOutcome::Verified,
|
||||
binding_id: "binding-new".into(),
|
||||
workspace_key_id: "workspace-key".into(),
|
||||
workspace_public_key_fingerprint: "sha256:workspace-key".into(),
|
||||
workspace_trust_id: "trust-new".into(),
|
||||
runtime_public_key_fingerprint: "sha256:runtime-key".into(),
|
||||
};
|
||||
round_trip(evidence.clone());
|
||||
let mut stale = serde_json::to_value(evidence).unwrap();
|
||||
stale["unexpected_state_guard"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
|
||||
fn runtime_binding_rejects_cached_authentication_and_requires_observed_identity_for_mutation() {
|
||||
let binding = serde_json::json!({"binding_id": "binding-new", "revoked_at": null});
|
||||
let parsed: WorkspaceRuntimeBindingSummary =
|
||||
serde_json::from_value(binding.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), binding);
|
||||
for field in [
|
||||
"state",
|
||||
"connection_state",
|
||||
"workspace_key_id",
|
||||
"workspace_trust_id",
|
||||
"verification",
|
||||
] {
|
||||
let mut obsolete = binding.clone();
|
||||
obsolete[field] = serde_json::json!("obsolete");
|
||||
assert!(serde_json::from_value::<WorkspaceRuntimeBindingSummary>(obsolete).is_err());
|
||||
}
|
||||
round_trip(RevokeRuntimeTrustKeyRequest {
|
||||
expected_binding_id: "binding-observed".into(),
|
||||
});
|
||||
|
||||
@@ -27,4 +27,19 @@ cargo run -p worker-runtime \
|
||||
```
|
||||
|
||||
The REST server is intended for a trusted Backend/proxy, not direct browser access.
|
||||
Workspace requests authenticate the actual HTTP request with the Workspace's current
|
||||
signing identity. The Runtime verifies signatures against its explicitly enrolled
|
||||
Workspace public key and binds the Runtime audience, Workspace/Worker scope,
|
||||
operation, method, path/query, body digest, expiry, and single-use token ID. A
|
||||
connection test is an ordinary signed `/v1/ping`; no prior handshake or saved
|
||||
verification evidence is required or consulted.
|
||||
|
||||
Keep `trust-workspace add`, `replace`, and `revoke` as explicit operator actions.
|
||||
A self-reported public key cannot grant trust. Trust record IDs are administrative
|
||||
only and must not be copied into Backend registration or request claims. Replay
|
||||
protection uses Workspace plus the trusted key fingerprint, so re-enrolling the
|
||||
same key or rotating away and back cannot reset consumed requests. Reaccepting a
|
||||
key does permit fresh, unconsumed requests signed by that key until their short
|
||||
expiry; it does not create a separate sender-controlled enrollment namespace.
|
||||
|
||||
For authenticated remote Runtime setup, see [`../../docs/development/server-runtime-auth.md`](../../docs/development/server-runtime-auth.md).
|
||||
|
||||
@@ -31,11 +31,7 @@ use crate::ssh_host_key_probe::{
|
||||
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
|
||||
};
|
||||
use crate::workspace_issuer::{
|
||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION,
|
||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
|
||||
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||
WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse,
|
||||
inspect_workspace_capability_claims, workspace_request_body_digest,
|
||||
};
|
||||
use crate::{Runtime, RuntimeWorkspaceScope};
|
||||
@@ -256,14 +252,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, &["POST"]),
|
||||
post(post_workspace_verification_challenge),
|
||||
)
|
||||
.route(
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, &["POST"]),
|
||||
post(post_workspace_verification_acknowledgement),
|
||||
)
|
||||
.route(
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
|
||||
get(list_config_bundles).post(store_config_bundle),
|
||||
@@ -377,8 +365,7 @@ struct RuntimeHttpState {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceRuntimeHttpAuth {
|
||||
pub verifier: WorkspaceCapabilityVerifier,
|
||||
pub signer: RuntimeVerificationSigner,
|
||||
pub verifications: Arc<dyn WorkspaceRuntimeVerificationAuthority>,
|
||||
pub runtime_id: String,
|
||||
}
|
||||
|
||||
struct RuntimeHttpWorkdirSession {
|
||||
@@ -563,172 +550,6 @@ fn unix_now_i64() -> i64 {
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
async fn post_workspace_verification_challenge(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(verified): Extension<VerifiedWorkspaceCapability>,
|
||||
Json(challenge): Json<WorkspaceRuntimeVerificationChallenge>,
|
||||
) -> RestResult<WorkspaceRuntimeVerificationResponse> {
|
||||
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
"Workspace Runtime verification is not configured",
|
||||
)
|
||||
})?;
|
||||
if challenge.workspace_id != verified.workspace_id
|
||||
|| challenge.runtime_id != verified.runtime_id
|
||||
|| challenge.binding_id != verified.binding_id
|
||||
|| challenge.workspace_key_id != verified.issuer_key_id
|
||||
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|
||||
|| challenge.workspace_trust_id != verified.trust_id
|
||||
|| challenge.runtime_id != auth.signer.runtime_id()
|
||||
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification challenge does not match authenticated authority",
|
||||
));
|
||||
}
|
||||
let response = auth
|
||||
.signer
|
||||
.sign_response(&challenge, uuid::Uuid::now_v7().to_string(), unix_now_i64())
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
auth.verifications.begin(challenge).map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn post_workspace_verification_acknowledgement(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(verified): Extension<VerifiedWorkspaceCapability>,
|
||||
Json(acknowledgement): Json<WorkspaceRuntimeVerificationAcknowledgement>,
|
||||
) -> RestResult<WorkspaceRuntimeVerificationReceipt> {
|
||||
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
"Workspace Runtime verification is not configured",
|
||||
)
|
||||
})?;
|
||||
if acknowledgement.workspace_id != verified.workspace_id
|
||||
|| acknowledgement.runtime_id != verified.runtime_id
|
||||
|| acknowledgement.binding_id != verified.binding_id
|
||||
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|
||||
|| acknowledgement.workspace_public_key_fingerprint
|
||||
!= verified.issuer_public_key_fingerprint
|
||||
|| acknowledgement.workspace_trust_id != verified.trust_id
|
||||
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|
||||
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
|| acknowledgement.expires_at <= unix_now_i64()
|
||||
|| acknowledgement.binding_id.is_empty()
|
||||
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|
||||
|| acknowledgement.workspace_trust_id.is_empty()
|
||||
|| acknowledgement.workspace_nonce.is_empty()
|
||||
|| acknowledgement.runtime_nonce.is_empty()
|
||||
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement is invalid",
|
||||
));
|
||||
}
|
||||
let challenge = WorkspaceRuntimeVerificationChallenge {
|
||||
challenge_id: acknowledgement.challenge_id.clone(),
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
|
||||
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
workspace_nonce: acknowledgement.workspace_nonce.clone(),
|
||||
expires_at: acknowledgement.expires_at,
|
||||
};
|
||||
let response = acknowledgement.response.clone();
|
||||
if response.runtime_nonce != acknowledgement.runtime_nonce
|
||||
|| response.workspace_nonce != acknowledgement.workspace_nonce
|
||||
|| response.response_proof.is_empty()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement does not match its response",
|
||||
));
|
||||
}
|
||||
let response_bytes = serde_json::to_vec(&response).map_err(|_| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification response could not be canonicalized",
|
||||
)
|
||||
})?;
|
||||
if workspace_request_body_digest(&response_bytes) != acknowledgement.response_digest {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement has the wrong response digest",
|
||||
));
|
||||
}
|
||||
crate::workspace_issuer::verify_runtime_verification_response(
|
||||
&response,
|
||||
&challenge,
|
||||
auth.signer.public_key(),
|
||||
unix_now_i64(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
auth.verifications
|
||||
.acknowledge(
|
||||
&acknowledgement.challenge_id,
|
||||
WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_public_key_fingerprint: acknowledgement
|
||||
.workspace_public_key_fingerprint
|
||||
.clone(),
|
||||
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: acknowledgement
|
||||
.runtime_public_key_fingerprint
|
||||
.clone(),
|
||||
verified_at: unix_now_i64(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(WorkspaceRuntimeVerificationReceipt {
|
||||
challenge_id: acknowledgement.challenge_id,
|
||||
workspace_id: acknowledgement.workspace_id,
|
||||
runtime_id: acknowledgement.runtime_id,
|
||||
binding_id: acknowledgement.binding_id.clone(),
|
||||
accepted_at: unix_now_i64(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn get_runtime_ping(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
@@ -757,7 +578,7 @@ async fn get_runtime_ping(
|
||||
let runtime_id = state
|
||||
.workspace_auth
|
||||
.as_ref()
|
||||
.map(|auth| auth.signer.runtime_id().trim())
|
||||
.map(|auth| auth.runtime_id.trim())
|
||||
.filter(|runtime_id| !runtime_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
@@ -2123,8 +1944,7 @@ async fn require_runtime_auth(
|
||||
let body_digest = workspace_request_body_digest(&body);
|
||||
let expected = WorkspaceCapabilityExpectation {
|
||||
workspace_id: &claims.issuer_workspace_id,
|
||||
binding_id: &claims.binding_id,
|
||||
runtime_id: workspace_auth.signer.runtime_id(),
|
||||
runtime_id: &workspace_auth.runtime_id,
|
||||
worker_id: expected_worker_id.as_deref(),
|
||||
operation: required_permission,
|
||||
method: &method,
|
||||
@@ -2134,48 +1954,6 @@ async fn require_runtime_auth(
|
||||
};
|
||||
match workspace_auth.verifier.verify(token, &expected) {
|
||||
Ok(verified) => {
|
||||
let is_verification = path_and_query
|
||||
== runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
|| path_and_query == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK;
|
||||
if !is_verification {
|
||||
let record = match workspace_auth
|
||||
.verifications
|
||||
.get(&verified.workspace_id, workspace_auth.signer.runtime_id())
|
||||
{
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_runtime_verification_required",
|
||||
"Workspace Runtime binding has not completed signed verification",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(error) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if record.binding_id != verified.binding_id
|
||||
|| record.workspace_key_id != verified.issuer_key_id
|
||||
|| record.workspace_public_key_fingerprint
|
||||
!= verified.issuer_public_key_fingerprint
|
||||
|| record.workspace_trust_id != verified.trust_id
|
||||
|| record.runtime_public_key_fingerprint
|
||||
!= workspace_auth.signer.public_key_fingerprint()
|
||||
{
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_runtime_verification_stale",
|
||||
"Workspace Runtime verification does not match current request authority",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
request = Request::from_parts(parts, Body::from(body));
|
||||
request.extensions_mut().insert(verified.clone());
|
||||
request.extensions_mut().insert(RuntimeAuthContext {
|
||||
@@ -2199,13 +1977,7 @@ async fn require_runtime_auth(
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_bootstrap_request = request.method() == Method::POST
|
||||
&& matches!(
|
||||
request.uri().path(),
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
| runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK
|
||||
);
|
||||
if state.workspace_auth.is_some() && !workspace_bootstrap_request {
|
||||
if state.workspace_auth.is_some() {
|
||||
let local_token_matches = state
|
||||
.local_token
|
||||
.as_deref()
|
||||
@@ -2275,12 +2047,6 @@ fn auth_workspace_scope(
|
||||
}
|
||||
|
||||
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
|
||||
if (path == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
|| path == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
|
||||
&& *method == Method::POST
|
||||
{
|
||||
return WORKSPACE_VERIFICATION_OPERATION;
|
||||
}
|
||||
required_runtime_permission(method, path).unwrap_or("runtime:read")
|
||||
}
|
||||
|
||||
@@ -2661,12 +2427,10 @@ mod tests {
|
||||
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::management::RuntimeOptions;
|
||||
use crate::retention::{DiagnosticsDisposition, SessionDisposition};
|
||||
use crate::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
||||
WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
||||
verify_runtime_verification_response,
|
||||
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityClaims,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
issue_workspace_capability_token,
|
||||
};
|
||||
use axum::body::to_bytes;
|
||||
use axum::http::Method;
|
||||
@@ -2741,8 +2505,6 @@ mod tests {
|
||||
.map(|route| route.path)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for path in [
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK,
|
||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES,
|
||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
|
||||
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
|
||||
@@ -2766,402 +2528,286 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
runtime
|
||||
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
|
||||
"builtin:coder".to_string(),
|
||||
)))
|
||||
.unwrap();
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let workspace_public_key =
|
||||
crate::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
||||
let workspace_fingerprint = format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(workspace_public_key))
|
||||
);
|
||||
let runtime_public_key =
|
||||
crate::auth::decode_public_key(&runtime_identity.public_key).unwrap();
|
||||
let runtime_fingerprint = format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(runtime_public_key))
|
||||
);
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
vec![WorkspaceIssuerTrustRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
backend_url: "https://backend.test".to_string(),
|
||||
key_id: "workspace-key".to_string(),
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: workspace_identity.public_key.clone(),
|
||||
public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
trust_id: "trust-test".into(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
}],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap();
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
runtime.clone(),
|
||||
None,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
|
||||
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
|
||||
},
|
||||
);
|
||||
let challenge = WorkspaceRuntimeVerificationChallenge {
|
||||
challenge_id: "challenge-1".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
binding_id: "binding-test".into(),
|
||||
workspace_key_id: "workspace-key".to_string(),
|
||||
workspace_public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
workspace_trust_id: "trust-test".into(),
|
||||
runtime_public_key_fingerprint: runtime_fingerprint,
|
||||
workspace_nonce: "workspace-nonce".to_string(),
|
||||
expires_at: unix_now_i64() + 60,
|
||||
};
|
||||
let body = serde_json::to_vec(&challenge).unwrap();
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: "https://backend.test".to_string(),
|
||||
issuer_workspace_id: "workspace-a".to_string(),
|
||||
issuer_key_id: "workspace-key".to_string(),
|
||||
issuer_public_key_fingerprint: workspace_fingerprint.clone(),
|
||||
trust_id: "trust-test".into(),
|
||||
binding_id: "binding-test".into(),
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: None,
|
||||
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
|
||||
method: "POST".to_string(),
|
||||
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE.to_string(),
|
||||
body_digest: workspace_request_body_digest(&body),
|
||||
iat: unix_now_i64(),
|
||||
exp: challenge.expires_at,
|
||||
jti: "challenge-token".to_string(),
|
||||
};
|
||||
let token =
|
||||
issue_workspace_capability_token(&workspace_identity.signing_key().unwrap(), &claims)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&response_body)
|
||||
);
|
||||
let verification_response =
|
||||
serde_json::from_slice::<WorkspaceRuntimeVerificationResponse>(&response_body).unwrap();
|
||||
verify_runtime_verification_response(
|
||||
&verification_response,
|
||||
&challenge,
|
||||
&runtime_identity.public_key,
|
||||
unix_now_i64(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let acknowledgement = WorkspaceRuntimeVerificationAcknowledgement {
|
||||
challenge_id: verification_response.challenge_id.clone(),
|
||||
workspace_id: verification_response.workspace_id.clone(),
|
||||
runtime_id: verification_response.runtime_id.clone(),
|
||||
binding_id: verification_response.binding_id.clone(),
|
||||
workspace_key_id: verification_response.workspace_key_id.clone(),
|
||||
workspace_public_key_fingerprint: verification_response
|
||||
.workspace_public_key_fingerprint
|
||||
.clone(),
|
||||
workspace_trust_id: verification_response.workspace_trust_id.clone(),
|
||||
runtime_public_key_fingerprint: verification_response
|
||||
.runtime_public_key_fingerprint
|
||||
.clone(),
|
||||
workspace_nonce: verification_response.workspace_nonce.clone(),
|
||||
runtime_nonce: verification_response.runtime_nonce.clone(),
|
||||
response_digest: workspace_request_body_digest(&response_body),
|
||||
response: verification_response.clone(),
|
||||
expires_at: verification_response.expires_at,
|
||||
};
|
||||
let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap();
|
||||
let acknowledgement_claims = WorkspaceCapabilityClaims {
|
||||
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK.to_string(),
|
||||
body_digest: workspace_request_body_digest(&acknowledgement_body),
|
||||
jti: "ack-token".to_string(),
|
||||
..claims
|
||||
};
|
||||
let acknowledgement_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&acknowledgement_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {acknowledgement_token}"),
|
||||
)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(acknowledgement_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
|
||||
for authorization in [None, Some("Bearer malformed")] {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES);
|
||||
if let Some(authorization) = authorization {
|
||||
request = request.header(header::AUTHORIZATION, authorization);
|
||||
}
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let ping_claims = WorkspaceCapabilityClaims {
|
||||
operation: RUNTIME_PING_PERMISSION.to_string(),
|
||||
method: "GET".to_string(),
|
||||
path_and_query: "/v1/ping".to_string(),
|
||||
body_digest: workspace_request_body_digest(&[]),
|
||||
jti: "ping-token".to_string(),
|
||||
..acknowledgement_claims
|
||||
};
|
||||
let ping_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&ping_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {ping_token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut retention_create = task_request("retention-scope");
|
||||
retention_create.workspace_api = Some(WorkspaceApiRef {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
base_url: "https://backend.test".to_string(),
|
||||
});
|
||||
let retention_worker_id = retention_create.worker_id.clone();
|
||||
runtime.create_worker(retention_create).unwrap();
|
||||
let retention_path = format!("/v1/workers/{retention_worker_id}/retention/execute");
|
||||
let retention_request = WorkerRetentionExecutionRequest {
|
||||
operation_id: "retention-op".to_string(),
|
||||
input_fingerprint: "fingerprint".to_string(),
|
||||
archive_id: None,
|
||||
workspace_id: "workspace-b".to_string(),
|
||||
source_runtime_id: "runtime-a".to_string(),
|
||||
worker_id: retention_worker_id,
|
||||
source_created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
removed_at: "2025-01-02T00:00:00Z".to_string(),
|
||||
effective_profile: None,
|
||||
retention_class: None,
|
||||
policy_id: "policy".to_string(),
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Retain,
|
||||
};
|
||||
let retention_body = serde_json::to_vec(&retention_request).unwrap();
|
||||
let retention_claims = WorkspaceCapabilityClaims {
|
||||
operation: required_runtime_permission(&Method::POST, &retention_path)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
method: "POST".to_string(),
|
||||
path_and_query: retention_path.clone(),
|
||||
worker_id: Some(retention_worker_id.to_string()),
|
||||
body_digest: workspace_request_body_digest(&retention_body),
|
||||
jti: "retention-scope-token".to_string(),
|
||||
..ping_claims
|
||||
};
|
||||
let retention_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&retention_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(retention_path)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {retention_token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(retention_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||
assert_eq!(error.error.code, "worker_not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() {
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let fingerprint = |key: &str| {
|
||||
format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
|
||||
crate::auth::decode_public_key(key).unwrap()
|
||||
))
|
||||
)
|
||||
};
|
||||
let old_trust = WorkspaceIssuerTrustRecord::from_bundle(
|
||||
fn workspace_trust(identity: &RuntimeIdentityMaterial) -> WorkspaceIssuerTrustRecord {
|
||||
WorkspaceIssuerTrustRecord::from_bundle(
|
||||
server_api::WorkspacePublicIdentityBundle {
|
||||
workspace_id: "workspace-a".into(),
|
||||
backend_url: "https://backend.test".into(),
|
||||
key_id: "workspace-key".into(),
|
||||
key_id: identity.identity_id.clone(),
|
||||
algorithm: "ed25519".into(),
|
||||
public_key: workspace_identity.public_key.clone(),
|
||||
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
|
||||
crate::auth::decode_public_key(&identity.public_key).unwrap()
|
||||
))
|
||||
),
|
||||
},
|
||||
"old-trust".into(),
|
||||
"administrative-trust-id".into(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let old_verification = WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: old_trust.workspace_id.clone(),
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn signed_claims(
|
||||
trust: &WorkspaceIssuerTrustRecord,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
) -> WorkspaceCapabilityClaims {
|
||||
WorkspaceCapabilityClaims {
|
||||
issuer: trust.backend_url.clone(),
|
||||
issuer_workspace_id: trust.workspace_id.clone(),
|
||||
issuer_key_id: trust.key_id.clone(),
|
||||
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
|
||||
runtime_id: "runtime-test".into(),
|
||||
binding_id: "old-binding".into(),
|
||||
workspace_key_id: old_trust.key_id.clone(),
|
||||
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
|
||||
workspace_trust_id: old_trust.trust_id.clone(),
|
||||
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
|
||||
verified_at: 1,
|
||||
};
|
||||
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
|
||||
let mut current = old_verification.clone();
|
||||
let mut trust = old_trust.clone();
|
||||
let mut workspace_key = workspace_identity.clone();
|
||||
let mut runtime_key = runtime_identity.clone();
|
||||
match changed {
|
||||
"binding" => current.binding_id = "reenrolled-binding".into(),
|
||||
"trust" => {
|
||||
trust.trust_id = "reaccepted-trust".into();
|
||||
current.workspace_trust_id = trust.trust_id.clone();
|
||||
}
|
||||
"workspace_key" => {
|
||||
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
|
||||
trust.key_id = workspace_key.identity_id.clone();
|
||||
trust.public_key = workspace_key.public_key.clone();
|
||||
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
|
||||
current.workspace_key_id = trust.key_id.clone();
|
||||
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
|
||||
}
|
||||
"runtime_key" => {
|
||||
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
worker_id: worker_id_from_runtime_path(path),
|
||||
operation: workspace_runtime_operation(
|
||||
&Method::from_bytes(method.as_bytes()).unwrap(),
|
||||
path,
|
||||
)
|
||||
.into(),
|
||||
method: method.into(),
|
||||
path_and_query: path.into(),
|
||||
body_digest: workspace_request_body_digest(body),
|
||||
iat: unix_now_i64(),
|
||||
exp: unix_now_i64() + 60,
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_test_auth(records: Vec<WorkspaceIssuerTrustRecord>) -> WorkspaceRuntimeHttpAuth {
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
runtime_id: "runtime-test".into(),
|
||||
verifier: WorkspaceCapabilityVerifier::new(
|
||||
records,
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn signed_request(
|
||||
app: Router,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
claims: &WorkspaceCapabilityClaims,
|
||||
body: Vec<u8>,
|
||||
workspace: &str,
|
||||
) -> Response {
|
||||
let token =
|
||||
issue_workspace_capability_token(&identity.signing_key().unwrap(), claims).unwrap();
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method(claims.method.as_str())
|
||||
.uri(&claims.path_and_query)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, workspace)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_signed_ping_succeeds_without_handshake_and_rejects_replay() {
|
||||
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let trust = workspace_trust(&identity);
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
Runtime::new_memory(),
|
||||
None,
|
||||
workspace_test_auth(vec![trust.clone()]),
|
||||
);
|
||||
let claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
|
||||
let response = signed_request(app.clone(), &identity, &claims, vec![], "workspace-a").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let ping: runtime_api::RuntimePingResponse = read_json(response).await;
|
||||
assert_eq!(ping.runtime_id, "runtime-test");
|
||||
assert_eq!(ping.protocol_version, RUNTIME_HTTP_PROTOCOL_VERSION);
|
||||
let response = signed_request(app, &identity, &claims, vec![], "workspace-a").await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||
assert_eq!(error.error.code, "unauthorized");
|
||||
assert!(error.error.message.contains("already consumed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_ping_rejects_untrusted_revoked_wrong_key_signature_and_workspace_scope() {
|
||||
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let other_key = RuntimeIdentityMaterial::generate("other-key").unwrap();
|
||||
let trust = workspace_trust(&identity);
|
||||
for case in [
|
||||
"untrusted",
|
||||
"revoked",
|
||||
"bad_signature",
|
||||
"wrong_key",
|
||||
"wrong_runtime",
|
||||
"wrong_operation",
|
||||
"expired",
|
||||
"wrong_workspace_scope",
|
||||
"unsigned",
|
||||
] {
|
||||
let mut current = trust.clone();
|
||||
if case == "revoked" {
|
||||
current.state = WorkspaceIssuerTrustState::Revoked;
|
||||
}
|
||||
let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
|
||||
authority.record(old_verification.clone()).unwrap();
|
||||
let records = if case == "untrusted" {
|
||||
vec![]
|
||||
} else {
|
||||
vec![current]
|
||||
};
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
Runtime::new_memory(),
|
||||
None,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier: WorkspaceCapabilityVerifier::new(
|
||||
vec![trust.clone()],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
|
||||
verifications: authority.clone(),
|
||||
},
|
||||
workspace_test_auth(records),
|
||||
);
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: trust.backend_url.clone(),
|
||||
issuer_workspace_id: trust.workspace_id.clone(),
|
||||
issuer_key_id: trust.key_id.clone(),
|
||||
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
|
||||
trust_id: trust.trust_id.clone(),
|
||||
binding_id: current.binding_id.clone(),
|
||||
runtime_id: current.runtime_id.clone(),
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION.into(),
|
||||
method: "GET".into(),
|
||||
path_and_query: "/v1/ping".into(),
|
||||
body_digest: workspace_request_body_digest(&[]),
|
||||
iat: unix_now_i64(),
|
||||
exp: unix_now_i64() + 60,
|
||||
jti: "stale-verification".into(),
|
||||
};
|
||||
for (expected, token_id) in [
|
||||
(StatusCode::FORBIDDEN, "stale-verification"),
|
||||
(StatusCode::OK, "fresh-verification"),
|
||||
] {
|
||||
if expected == StatusCode::OK {
|
||||
authority.record(current.clone()).unwrap();
|
||||
let mut claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
|
||||
match case {
|
||||
"wrong_key" => claims.issuer_key_id = "untrusted-key".into(),
|
||||
"wrong_runtime" => claims.runtime_id = "another-runtime".into(),
|
||||
"wrong_operation" => claims.operation = "workers:create".into(),
|
||||
"expired" => {
|
||||
claims.iat -= 120;
|
||||
claims.exp -= 120;
|
||||
}
|
||||
let token = issue_workspace_capability_token(
|
||||
&workspace_key.signing_key().unwrap(),
|
||||
&WorkspaceCapabilityClaims {
|
||||
jti: token_id.into(),
|
||||
..claims.clone()
|
||||
_ => {}
|
||||
}
|
||||
let response = if case == "unsigned" {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/ping")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
} else {
|
||||
signed_request(
|
||||
app,
|
||||
if case == "bad_signature" {
|
||||
&other_key
|
||||
} else {
|
||||
&identity
|
||||
},
|
||||
&claims,
|
||||
vec![],
|
||||
if case == "wrong_workspace_scope" {
|
||||
"workspace-b"
|
||||
} else {
|
||||
"workspace-a"
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
expected,
|
||||
"{changed}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
if expected == StatusCode::FORBIDDEN {
|
||||
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
|
||||
}
|
||||
}
|
||||
.await
|
||||
};
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
if case == "wrong_workspace_scope" {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
},
|
||||
"{case}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
error.error.code,
|
||||
if case == "wrong_workspace_scope" {
|
||||
"runtime_ping_workspace_scope_mismatch"
|
||||
} else {
|
||||
"unauthorized"
|
||||
},
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_signed_workdir_operation_succeeds_without_evidence_and_rejects_changed_body()
|
||||
{
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp.path().join("hello.txt"), "hello").unwrap();
|
||||
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let trust = workspace_trust(&identity);
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new("wd-1"),
|
||||
temp.path().to_path_buf(),
|
||||
temp.path().to_path_buf(),
|
||||
SharedScope::new(Scope::writable(temp.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
let state = RuntimeHttpState {
|
||||
runtime: Runtime::new_memory(),
|
||||
local_token: None,
|
||||
workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))),
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
|
||||
"session-1".into(),
|
||||
RuntimeHttpWorkdirSession {
|
||||
owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"),
|
||||
session,
|
||||
},
|
||||
)]))),
|
||||
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/v1/workdir-sessions/{session_id}/operations",
|
||||
post(run_workdir_session_operation),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.layer(middleware::from_fn_with_state(state, require_runtime_auth));
|
||||
let body = serde_json::to_vec(&WorkdirSessionOperationRequest {
|
||||
operation: WorkdirSessionOperation::Stat(StatRequest {
|
||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let claims = signed_claims(
|
||||
&trust,
|
||||
"POST",
|
||||
"/v1/workdir-sessions/session-1/operations",
|
||||
&body,
|
||||
);
|
||||
let response = signed_request(app.clone(), &identity, &claims, body, "workspace-a").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let result: WorkdirSessionOperationResult = read_json(response).await;
|
||||
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
..claims
|
||||
};
|
||||
let response = signed_request(app, &identity, &claims, b"{}".to_vec(), "workspace-a").await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||
assert!(error.error.message.contains("request body"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_handshake_routes_are_not_network_aliases() {
|
||||
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let trust = workspace_trust(&identity);
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
Runtime::new_memory(),
|
||||
None,
|
||||
workspace_test_auth(vec![trust.clone()]),
|
||||
);
|
||||
for path in [
|
||||
"/v1/workspace-runtime-verification/challenge",
|
||||
"/v1/workspace-runtime-verification/acknowledgement",
|
||||
] {
|
||||
let claims = signed_claims(&trust, "POST", path, b"{}");
|
||||
let response = signed_request(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&claims,
|
||||
b"{}".to_vec(),
|
||||
"workspace-a",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4630,8 +4276,7 @@ mod tests {
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
signer: RuntimeVerificationSigner::from_identity(&identity).unwrap(),
|
||||
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
|
||||
runtime_id: identity.identity_id.clone(),
|
||||
},
|
||||
);
|
||||
(runtime, app)
|
||||
|
||||
@@ -164,7 +164,7 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
.state
|
||||
.workspace_auth
|
||||
.as_ref()
|
||||
.map(|auth| auth.signer.runtime_id().trim())
|
||||
.map(|auth| auth.runtime_id.trim())
|
||||
.filter(|runtime_id| !runtime_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
|
||||
@@ -27,11 +27,11 @@ use worker_runtime::http_server::{
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use worker_runtime::working_directory::RuntimeGitMaterializer;
|
||||
use worker_runtime::workspace_issuer::{
|
||||
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
||||
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
|
||||
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
|
||||
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
|
||||
FileWorkspaceClaimReplayProtection, MAX_WORKSPACE_ISSUER_TRUST_RECORDS,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation,
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, add_workspace_issuer_trust,
|
||||
replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
|
||||
validate_workspace_issuer_trust_records,
|
||||
};
|
||||
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
|
||||
use worker_runtime::{Runtime, RuntimeOptions};
|
||||
@@ -972,7 +972,7 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
|
||||
if migrated {
|
||||
// Commit the revoked migration before exposing any issuer authority.
|
||||
// Persist the validated migration without changing saved trust permissions.
|
||||
write_runtime_auth_file(path, &auth)?;
|
||||
}
|
||||
Ok(auth)
|
||||
@@ -1025,25 +1025,15 @@ fn load_workspace_runtime_http_auth(
|
||||
let Some(identity) = auth.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if auth.workspace_issuers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let replay_path = runtime_auth_path(config).with_extension("workspace-replay.json");
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
auth.workspace_issuers,
|
||||
Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)),
|
||||
)
|
||||
.map_err(|error| ProcessError::auth(format!("invalid Workspace issuer trust: {error}")))?;
|
||||
let signer = RuntimeVerificationSigner::from_identity(&identity)
|
||||
.map_err(|error| ProcessError::auth(format!("invalid Runtime identity: {error}")))?;
|
||||
let verifications_path =
|
||||
runtime_auth_path(config).with_extension("workspace-verifications.json");
|
||||
Ok(Some(WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications: Arc::new(FileWorkspaceRuntimeVerificationAuthority::new(
|
||||
verifications_path,
|
||||
)),
|
||||
runtime_id: identity.identity_id,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1407,6 +1397,34 @@ mod tests {
|
||||
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_identity_with_no_trusted_workspace_does_not_disable_request_authentication() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = ProcessConfig {
|
||||
fs_root: Some(temp.path().to_path_buf()),
|
||||
..ProcessConfig::default().unwrap()
|
||||
};
|
||||
let path = runtime_auth_path(&config);
|
||||
write_runtime_auth_file(
|
||||
&path,
|
||||
&RuntimeAuthFile {
|
||||
identity: Some(RuntimeIdentityMaterial::generate("runtime-test").unwrap()),
|
||||
..RuntimeAuthFile::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Obsolete evidence is neither a startup prerequisite nor live authority.
|
||||
let evidence = path.with_extension("workspace-verifications.json");
|
||||
std::fs::write(&evidence, "obsolete evidence, not even valid JSON").unwrap();
|
||||
let auth = load_workspace_runtime_http_auth(&config).unwrap().unwrap();
|
||||
assert_eq!(auth.runtime_id, "runtime-test");
|
||||
assert!(!auth.verifier.has_active_workspace_issuer("workspace-a"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&evidence).unwrap(),
|
||||
"obsolete evidence, not even valid JSON"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
|
||||
use server_api::WorkspacePublicIdentityBundle;
|
||||
@@ -1454,38 +1472,6 @@ mod tests {
|
||||
let first = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(first.workspace_issuers.len(), 1);
|
||||
assert!(!first.workspace_issuers[0].trust_id.is_empty());
|
||||
// `trust-workspace show` serializes this persisted record. Owner
|
||||
// registration must forward that exact enrollment ID, not derive one
|
||||
// from the key fingerprint or an initial counter.
|
||||
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
|
||||
let mut registration_wire = serde_json::json!({
|
||||
"public_bundle": {
|
||||
"identity_id": runtime_identity.identity_id,
|
||||
"public_key": runtime_identity.public_key,
|
||||
},
|
||||
"endpoint": "https://runtime.example.test",
|
||||
"workspace_trust_id": shown["trust_id"],
|
||||
});
|
||||
let registration: server_api::CreateRemoteRuntimeRequest =
|
||||
serde_json::from_value(registration_wire.clone()).unwrap();
|
||||
assert_eq!(
|
||||
registration.workspace_trust_id,
|
||||
first.workspace_issuers[0].trust_id
|
||||
);
|
||||
assert_ne!(
|
||||
registration.workspace_trust_id,
|
||||
first.workspace_issuers[0].public_key_fingerprint
|
||||
);
|
||||
registration_wire
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("workspace_trust_id");
|
||||
assert!(
|
||||
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
run_trust_workspace_command(VecDeque::from([
|
||||
"add".to_string(),
|
||||
"--bundle".to_string(),
|
||||
@@ -1645,9 +1631,14 @@ mod tests {
|
||||
assert_eq!(error, "invalid trust-workspace list arguments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
|
||||
#[tokio::test]
|
||||
async fn pre_id_issuer_migration_preserves_saved_permission_for_ordinary_signed_ping() {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tower::ServiceExt;
|
||||
use worker_runtime::workspace_issuer::{
|
||||
WorkspaceCapabilityClaims, issue_workspace_capability_token,
|
||||
workspace_request_body_digest,
|
||||
};
|
||||
|
||||
let identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
|
||||
let public_key = decode_public_key(&identity.public_key).unwrap();
|
||||
@@ -1680,16 +1671,97 @@ mod tests {
|
||||
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
|
||||
|
||||
let migrated = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(
|
||||
migrated.workspace_issuers[0].state,
|
||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
||||
let expected_state = if state == "active" {
|
||||
WorkspaceIssuerTrustState::Active
|
||||
} else {
|
||||
WorkspaceIssuerTrustState::Revoked
|
||||
};
|
||||
let record = &migrated.workspace_issuers[0];
|
||||
assert_eq!(record.state, expected_state);
|
||||
assert_eq!(record.workspace_id, "workspace-1");
|
||||
assert_eq!(record.backend_url, "https://backend.example.test");
|
||||
assert_eq!(record.key_id, "WK-legacy");
|
||||
assert_eq!(record.public_key, identity.public_key);
|
||||
assert_eq!(record.public_key_fingerprint, fingerprint);
|
||||
assert_eq!(record.registered_at_unix, 10);
|
||||
assert_eq!(record.updated_at_unix, 20);
|
||||
// Migration is durably saved by the loader itself, not just by a later write.
|
||||
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: record.backend_url.clone(),
|
||||
issuer_workspace_id: record.workspace_id.clone(),
|
||||
issuer_key_id: record.key_id.clone(),
|
||||
issuer_public_key_fingerprint: record.public_key_fingerprint.clone(),
|
||||
runtime_id: "runtime-test".into(),
|
||||
worker_id: None,
|
||||
operation: worker_runtime::http_server::RUNTIME_PING_PERMISSION.into(),
|
||||
method: "GET".into(),
|
||||
path_and_query: "/v1/ping".into(),
|
||||
body_digest: workspace_request_body_digest(&[]),
|
||||
iat: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||
exp: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap() + 60,
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
};
|
||||
let token = issue_workspace_capability_token(&identity.signing_key().unwrap(), &claims)
|
||||
.unwrap();
|
||||
let app = worker_runtime::http_server::runtime_http_router_with_workspace_auth(
|
||||
Runtime::new_memory(), None, WorkspaceRuntimeHttpAuth {
|
||||
runtime_id: "runtime-test".into(),
|
||||
verifier: WorkspaceCapabilityVerifier::new(migrated.workspace_issuers.clone(),
|
||||
Arc::new(worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default())).unwrap(),
|
||||
},
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/v1/ping")
|
||||
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(
|
||||
worker_runtime::http_server::RUNTIME_WORKSPACE_SCOPE_HEADER,
|
||||
"workspace-1",
|
||||
)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
if state == "active" {
|
||||
axum::http::StatusCode::OK
|
||||
} else {
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
},
|
||||
"{state}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
if state == "active" {
|
||||
let ping: runtime_api::RuntimePingResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(ping.runtime_id, "runtime-test");
|
||||
} else {
|
||||
let error: worker_runtime::http_server::RuntimeHttpErrorResponse =
|
||||
serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(error.error.code, "unauthorized");
|
||||
assert!(error.error.message.contains("revoked"));
|
||||
}
|
||||
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
|
||||
let archived: toml::Value =
|
||||
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
|
||||
assert_eq!(archived, original_record);
|
||||
write_runtime_auth_file(&path, &migrated).unwrap();
|
||||
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
|
||||
if state == "active" {
|
||||
// A current saved Revoked record stays revoked even if its archived
|
||||
// pre-ID source was Active. Never guess revocation provenance.
|
||||
let mut saved_revoked = migrated.clone();
|
||||
saved_revoked.workspace_issuers[0].state = WorkspaceIssuerTrustState::Revoked;
|
||||
write_runtime_auth_file(&path, &saved_revoked).unwrap();
|
||||
assert_eq!(read_runtime_auth_file(&path).unwrap(), saved_revoked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -817,17 +817,12 @@ mod current_authority {
|
||||
|
||||
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let workspace_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
|
||||
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
|
||||
.unwrap()
|
||||
.1;
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
|
||||
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');
|
||||
INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519','public','sha256:workspace','material','active','created','provisioned','updated');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let request = WorkspaceRuntimeBinding {
|
||||
@@ -838,11 +833,7 @@ mod current_authority {
|
||||
public_key: runtime_key.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
state: WorkspaceRuntimeBindingState::Configured,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("workspace-key".into()),
|
||||
workspace_public_key_fingerprint: Some(workspace_fingerprint),
|
||||
workspace_trust_id: Some("runtime-issued-trust".into()),
|
||||
created_at: "created".into(),
|
||||
updated_at: "updated".into(),
|
||||
revoked_at: None,
|
||||
@@ -853,36 +844,10 @@ mod current_authority {
|
||||
(store, binding)
|
||||
}
|
||||
|
||||
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
|
||||
WorkspaceRuntimeVerificationEvidence {
|
||||
workspace_id: binding.workspace_id.clone(),
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
|
||||
workspace_public_key_fingerprint: binding
|
||||
.workspace_public_key_fingerprint
|
||||
.clone()
|
||||
.unwrap(),
|
||||
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
|
||||
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
|
||||
challenge_id: "challenge".into(),
|
||||
state: "verified".into(),
|
||||
last_outcome: "verified".into(),
|
||||
verified_at: Some("verified-at".into()),
|
||||
checked_at: "checked-at".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
|
||||
fn same_key_reactivation_fences_old_config_and_admin_mutations() {
|
||||
let (store, binding) = binding_store();
|
||||
let old_proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.unwrap();
|
||||
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
let (_, revoked) = store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
"space",
|
||||
@@ -893,16 +858,22 @@ mod current_authority {
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(binding.binding_id, revoked.binding_id);
|
||||
let mut replacement = binding.clone();
|
||||
replacement.state = WorkspaceRuntimeBindingState::Configured;
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
|
||||
let (_, reactivated) = store
|
||||
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
|
||||
.put_workspace_runtime_binding_key(binding.clone(), Some(&revoked.binding_id), "owner")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
binding.public_key_fingerprint,
|
||||
reactivated.public_key_fingerprint
|
||||
);
|
||||
assert_ne!(binding.binding_id, reactivated.binding_id);
|
||||
assert!(
|
||||
store
|
||||
.workspace_runtime_binding_matches(&reactivated)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
assert!(matches!(
|
||||
store.put_workspace_runtime_binding_key(
|
||||
reactivated.clone(),
|
||||
@@ -921,42 +892,27 @@ mod current_authority {
|
||||
),
|
||||
Err(Error::RuntimeBindingIdConflict { .. })
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.workspace_runtime_verification_matches(
|
||||
&reactivated,
|
||||
&old_proof.workspace_public_key_fingerprint,
|
||||
&old_proof.workspace_trust_id
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
|
||||
fn endpoint_aba_fences_old_config_without_copying_workspace_signing_identity() {
|
||||
let (store, binding) = binding_store();
|
||||
let proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&proof)
|
||||
let renamed = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Renamed",
|
||||
&binding.base_url,
|
||||
"renamed-at",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(binding.binding_id, renamed.binding_id);
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
let moved = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"Renamed",
|
||||
"https://other.test",
|
||||
"changed",
|
||||
)
|
||||
@@ -965,35 +921,166 @@ mod current_authority {
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"Renamed",
|
||||
"https://remote.test",
|
||||
"changed-back",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(moved.binding_id, binding.binding_id);
|
||||
assert_ne!(returned.binding_id, binding.binding_id);
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.is_err()
|
||||
);
|
||||
let current = evidence(&returned);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(¤t)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(¤t)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
|
||||
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_runtime_binding("space", "remote")
|
||||
.unwrap(),
|
||||
Some(returned.clone())
|
||||
);
|
||||
assert!(store.workspace_runtime_binding_matches(&returned).unwrap());
|
||||
}
|
||||
|
||||
fn rows(conn: &Connection, sql: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let mut stmt = conn.prepare(sql).unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn schema89_bindings() -> Connection {
|
||||
let conn = legacy_binding_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations VALUES(89,'descriptive legacy evidence columns')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("INSERT INTO workspace_runtime_bindings VALUES
|
||||
('space','configured-trust','Configured','https://configured.test','key-c','fp-c','binding-c','configured','workspace_identity','workspace-key','sha256:workspace','trust-c','created-c','updated-c',NULL),
|
||||
('space','verified','Verified','https://verified.test','key-v','fp-v','binding-v','verified','workspace_identity','workspace-key','sha256:workspace','trust-v','created-v','updated-v',NULL),
|
||||
('space','revoked-null-trust','Revoked','https://revoked.test','key-r','fp-r','binding-r','revoked','workspace_identity','workspace-key',NULL,NULL,'created-r','updated-r','revoked-r'),
|
||||
('space','legacy-verified','Legacy','https://legacy.test','key-l','fp-l','binding-l','verified','legacy_server_issuer',NULL,NULL,NULL,'created-l','updated-l',NULL),
|
||||
('space','legacy-revoked','Legacy revoked','https://legacy-r.test','key-lr','fp-lr','binding-lr','revoked','legacy_server_issuer',NULL,NULL,NULL,'created-lr','updated-lr','revoked-lr');
|
||||
INSERT INTO workspace_runtime_verifications VALUES
|
||||
('space','verified','binding-v','workspace-key','sha256:workspace','trust-v','fp-v','challenge-v','verified','verified','verified-at','checked-v'),
|
||||
('space','configured-trust','binding-c','workspace-key','sha256:workspace','trust-c','fp-c','challenge-c','pending','challenge_issued',NULL,'checked-c'),
|
||||
('space','revoked-null-trust','binding-r','workspace-key','sha256:workspace','old-trust','fp-r','challenge-r','failed','verification_failed',NULL,'checked-r');").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema90_preserves_connections_and_archives_all_auth_metadata_without_reenrollment() {
|
||||
let conn = schema89_bindings();
|
||||
let connections = rows(
|
||||
&conn,
|
||||
"SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
let metadata = rows(
|
||||
&conn,
|
||||
"SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
let proofs = rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id",
|
||||
);
|
||||
let identities = rows(&conn, "SELECT * FROM workspace_signing_identities");
|
||||
let audit = rows(&conn, "SELECT * FROM workspace_runtime_binding_audit");
|
||||
let removal = rows(&conn, "SELECT * FROM runtime_removal_operations");
|
||||
migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 90);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
|
||||
),
|
||||
connections
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
proofs
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM workspace_signing_identities"),
|
||||
identities
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM workspace_runtime_binding_audit"),
|
||||
audit
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM runtime_removal_operations"),
|
||||
removal
|
||||
);
|
||||
verify_runtime_binding_connection_schema(&conn).unwrap();
|
||||
assert!(!table_exists(&conn, "workspace_runtime_verifications").unwrap());
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM pragma_foreign_key_check"),
|
||||
Vec::<Vec<rusqlite::types::Value>>::new()
|
||||
);
|
||||
conn.execute_batch("UPDATE workspace_runtime_bindings SET base_url='https://moved.test',binding_id='binding-new' WHERE runtime_id='verified';
|
||||
UPDATE workspace_runtime_bindings SET revoked_at='admin-revoked' WHERE runtime_id='configured-trust';
|
||||
DELETE FROM workspace_runtime_bindings WHERE runtime_id='legacy-revoked';").unwrap();
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
proofs
|
||||
);
|
||||
conn.execute(
|
||||
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conn.execute("UPDATE workspace_runtime_bindings SET display_name='Blocked' WHERE runtime_id='remote'", []).unwrap_err().to_string().contains("runtime_removal_in_progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema90_failed_rebuild_rolls_back_connections_archives_and_schema_marker() {
|
||||
let conn = schema89_bindings();
|
||||
// An incompatible preexisting archive is not permission to discard historical evidence.
|
||||
conn.execute_batch("CREATE TABLE workspace_runtime_verification_archive(existing TEXT);")
|
||||
.unwrap();
|
||||
let before = rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
assert!(migrate_runtime_binding_connections_v89_to_v90(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 89);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
|
||||
),
|
||||
before
|
||||
);
|
||||
assert!(!table_exists(&conn, "workspace_runtime_binding_verification_archive").unwrap());
|
||||
assert!(table_exists(&conn, "workspace_runtime_verifications").unwrap());
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
.state,
|
||||
WorkspaceRuntimeBindingState::Configured
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use crate::Error;
|
||||
use crate::resource_broker::BackendResourceBroker;
|
||||
#[cfg(test)]
|
||||
use crate::resource_broker::BackendResourceTarget;
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
|
||||
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
|
||||
use chrono::Utc;
|
||||
use protocol::Segment;
|
||||
@@ -66,11 +64,7 @@ use worker_runtime::ssh_host_key_probe::{
|
||||
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
|
||||
SshHostKeyProbeResponse,
|
||||
};
|
||||
use worker_runtime::workspace_issuer::{
|
||||
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
|
||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
|
||||
};
|
||||
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
|
||||
|
||||
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||
@@ -925,32 +919,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
|
||||
|
||||
fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
_challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
_bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Unsupported,
|
||||
"runtime_workspace_verification_unsupported",
|
||||
"Workspace Runtime verification is unavailable for this Runtime provider",
|
||||
))
|
||||
}
|
||||
|
||||
fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
_acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
_bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Unsupported,
|
||||
"runtime_workspace_verification_unsupported",
|
||||
"Workspace Runtime verification is unavailable for this Runtime provider",
|
||||
))
|
||||
}
|
||||
|
||||
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary>;
|
||||
|
||||
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
|
||||
@@ -2277,48 +2245,6 @@ impl RuntimeRegistry {
|
||||
runtime.ping()
|
||||
}
|
||||
|
||||
pub fn activate_workspace_authorization(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
self.runtime(runtime_id)?
|
||||
.activate_workspace_authorization(binding);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
let runtime = self.runtime(runtime_id).map_err(|_| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Configuration,
|
||||
"runtime_verification_registration_unavailable",
|
||||
"Registered Runtime binding is unavailable",
|
||||
)
|
||||
})?;
|
||||
runtime.send_workspace_verification_challenge(challenge, bearer_token)
|
||||
}
|
||||
|
||||
pub fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
let runtime = self.runtime(runtime_id).map_err(|_| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Configuration,
|
||||
"runtime_verification_registration_unavailable",
|
||||
"Registered Runtime binding is unavailable",
|
||||
)
|
||||
})?;
|
||||
runtime.send_workspace_verification_acknowledgement(acknowledgement, bearer_token)
|
||||
}
|
||||
|
||||
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
|
||||
self.runtimes
|
||||
.read()
|
||||
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
backend_url: String,
|
||||
binding: Arc<RwLock<Option<crate::store::WorkspaceRuntimeBinding>>>,
|
||||
// The connection selected for this client. Compare it with the current
|
||||
// administrative configuration before sending, never with a past auth result.
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
|
||||
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
backend_url: impl Into<String>,
|
||||
binding: Option<crate::store::WorkspaceRuntimeBinding>,
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
signing_identities,
|
||||
backend_url: backend_url.into(),
|
||||
binding: Arc::new(RwLock::new(binding)),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
||||
if let Ok(mut current) = self.binding.write() {
|
||||
*current = Some(binding);
|
||||
binding,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
|
||||
worker_id: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> Result<String, RuntimeDiagnostic> {
|
||||
let binding = self
|
||||
.binding
|
||||
.read()
|
||||
.map_err(|_| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime authorization is unavailable".to_string(),
|
||||
)
|
||||
})?
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_verification_required",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is not verified".to_string(),
|
||||
)
|
||||
})?;
|
||||
if binding.state != WorkspaceRuntimeBindingState::Verified
|
||||
|| binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|
||||
let binding = &self.binding;
|
||||
// A removed/replaced endpoint must not be used by an old client. This
|
||||
// compares connection configuration, not cached authentication success.
|
||||
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|
||||
|| binding.revoked_at.is_some()
|
||||
|| !self
|
||||
.store
|
||||
.workspace_runtime_binding_matches(&binding)
|
||||
.workspace_runtime_binding_matches(binding)
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
|
||||
return Err(diagnostic(
|
||||
"workspace_runtime_authorization_stale",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding changed or was revoked".to_string(),
|
||||
"Workspace Runtime connection changed or was revoked".to_string(),
|
||||
));
|
||||
}
|
||||
let identity = self
|
||||
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_public_key_fingerprint = binding
|
||||
.workspace_public_key_fingerprint
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace public key fingerprint"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
|
||||
)
|
||||
})?;
|
||||
if identity.state != "active"
|
||||
|| identity.key_id != workspace_key_id
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|
||||
|| !self
|
||||
.store
|
||||
.workspace_runtime_verification_matches(
|
||||
&binding,
|
||||
workspace_public_key_fingerprint,
|
||||
workspace_trust_id,
|
||||
)
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
error.to_string(),
|
||||
)
|
||||
})?
|
||||
{
|
||||
if identity.state != "active" {
|
||||
return Err(diagnostic(
|
||||
"workspace_runtime_authorization_stale",
|
||||
"workspace_signing_identity_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace signing identity no longer matches the verified binding".to_string(),
|
||||
"Workspace signing identity is not active".to_string(),
|
||||
));
|
||||
}
|
||||
let fingerprint = identity.public_key_fingerprint.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_signing_identity_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace signing identity has no public key fingerprint".to_string(),
|
||||
)
|
||||
})?;
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: self.backend_url.clone(),
|
||||
issuer_workspace_id: binding.workspace_id.clone(),
|
||||
issuer_key_id: identity.key_id,
|
||||
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
|
||||
trust_id: workspace_trust_id.to_string(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
issuer_public_key_fingerprint: fingerprint,
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
worker_id: worker_id.map(str::to_string),
|
||||
operation: operation.to_string(),
|
||||
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
|
||||
exp: now.saturating_add(60),
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
};
|
||||
// The signer validates that these claims still name the selected current
|
||||
// identity; a concurrent rotation cannot silently sign mismatched claims.
|
||||
self.signing_identities
|
||||
.issue_workspace_capability(&binding.workspace_id, &claims)
|
||||
.map_err(|error| {
|
||||
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
|
||||
format!("{base}/v1/workers/{worker_id}/protocol/ws")
|
||||
}
|
||||
|
||||
fn post_bearer_json<T, U>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
bearer_token: &str,
|
||||
) -> Result<U, RuntimePingFailure>
|
||||
where
|
||||
T: Serialize + ?Sized,
|
||||
U: DeserializeOwned,
|
||||
{
|
||||
let response = self
|
||||
.http
|
||||
.post(self.endpoint(path))
|
||||
.bearer_auth(bearer_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::NetworkUnreachable,
|
||||
"runtime_workspace_verification_unreachable",
|
||||
format!("Runtime verification request failed: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::MalformedResponse,
|
||||
"runtime_workspace_verification_rejected",
|
||||
format!(
|
||||
"Runtime verification request returned HTTP {}",
|
||||
status.as_u16()
|
||||
),
|
||||
));
|
||||
}
|
||||
response.json::<U>().map_err(|error| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::MalformedResponse,
|
||||
"runtime_workspace_verification_invalid_response",
|
||||
format!("Runtime verification response was invalid: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
||||
where
|
||||
T: DeserializeOwned + Send + 'static,
|
||||
@@ -4702,36 +4530,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
||||
if let Some(authorization) = &self.workspace_authorization {
|
||||
authorization.activate(binding);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
self.post_bearer_json(
|
||||
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
||||
challenge,
|
||||
bearer_token,
|
||||
)
|
||||
}
|
||||
|
||||
fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
self.post_bearer_json(
|
||||
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
acknowledgement,
|
||||
bearer_token,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
|
||||
if limit == 0 {
|
||||
return RuntimeList::new(Vec::new(), Vec::new());
|
||||
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod restore_auth_tests;
|
||||
mod workspace_auth_tests;
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -9,36 +9,18 @@ use worker_runtime::{
|
||||
auth::RuntimeIdentityMaterial,
|
||||
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
|
||||
workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
||||
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
||||
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
|
||||
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let workspace_public_key_fingerprint =
|
||||
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
|
||||
.unwrap();
|
||||
let binding_id = "binding-restore-test".to_string();
|
||||
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
|
||||
verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: "workspace-test".into(),
|
||||
runtime_id: "runtime-test".into(),
|
||||
binding_id: binding_id.clone(),
|
||||
workspace_trust_id: "trust-restore-test".to_string(),
|
||||
workspace_key_id: "workspace-key".into(),
|
||||
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
|
||||
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
|
||||
verified_at: now,
|
||||
})
|
||||
.unwrap();
|
||||
let public_key =
|
||||
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
None,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications,
|
||||
runtime_id: "runtime-test".into(),
|
||||
},
|
||||
);
|
||||
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
|
||||
@@ -93,8 +74,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
"wrong-worker",
|
||||
"wrong-body",
|
||||
"wrong-key-fingerprint",
|
||||
"wrong-binding-id",
|
||||
"wrong-trust-id",
|
||||
] {
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: "https://backend.test".into(),
|
||||
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
} else {
|
||||
workspace_public_key_fingerprint.clone()
|
||||
},
|
||||
binding_id: if case == "wrong-binding-id" {
|
||||
"binding-retired".to_string()
|
||||
} else {
|
||||
binding_id.clone()
|
||||
},
|
||||
trust_id: if case == "wrong-trust-id" {
|
||||
"trust-retired".to_string()
|
||||
} else {
|
||||
"trust-restore-test".to_string()
|
||||
},
|
||||
runtime_id: "runtime-test".into(),
|
||||
worker_id: if case == "wrong-worker" {
|
||||
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
|
||||
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
} else {
|
||||
assert_eq!(
|
||||
status,
|
||||
if case == "wrong-binding-id" {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
},
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{path} {case}: {}",
|
||||
error.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
error.error.code,
|
||||
if case == "wrong-binding-id" {
|
||||
"workspace_runtime_verification_stale"
|
||||
} else {
|
||||
"unauthorized"
|
||||
}
|
||||
);
|
||||
assert_eq!(error.error.code, "unauthorized");
|
||||
let expected = match case {
|
||||
"wrong-operation" => Some("does not authorize this operation"),
|
||||
"wrong-worker" => Some("targets another Worker"),
|
||||
"wrong-body" => Some("does not bind this request body"),
|
||||
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
|
||||
"wrong-key-fingerprint" => None,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
use crate::store::{
|
||||
AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||
WorkspaceSigningIdentityRecord,
|
||||
};
|
||||
use crate::workspace_signing_identity::{
|
||||
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
|
||||
WorkspaceSigningPrivateMaterial, public_key_fingerprint,
|
||||
};
|
||||
use worker_runtime::auth::RuntimeIdentityMaterial;
|
||||
use worker_runtime::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, VerifiedWorkspaceCapability,
|
||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerificationError,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
};
|
||||
|
||||
const WORKSPACE: &str = "workspace-auth-test";
|
||||
const RUNTIME: &str = "runtime-auth-test";
|
||||
const BACKEND: &str = "https://backend.test";
|
||||
const PATH: &str = "/v1/ping?probe=1";
|
||||
|
||||
struct Fixture {
|
||||
_temp: tempfile::TempDir,
|
||||
store: Arc<SqliteWorkspaceStore>,
|
||||
materials: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||
service: WorkspaceSigningIdentityService,
|
||||
authorizer: WorkspaceRuntimeAuthorization,
|
||||
binding: WorkspaceRuntimeBinding,
|
||||
identity: WorkspaceSigningIdentityRecord,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "owner".into(),
|
||||
kind: "user".into(),
|
||||
handle: "owner".into(),
|
||||
display_name: "Owner".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
owner_account_id: "owner".into(),
|
||||
display_name: "Workspace".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
||||
let identity = service.provision_existing(WORKSPACE, "owner").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
|
||||
store
|
||||
.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
display_name: "Runtime".into(),
|
||||
base_url: "https://runtime.test".into(),
|
||||
public_key_fingerprint: public_key_fingerprint(&runtime_identity.public_key)
|
||||
.unwrap(),
|
||||
public_key: runtime_identity.public_key,
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let binding = store
|
||||
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let authorizer = WorkspaceRuntimeAuthorization::new(
|
||||
store.clone(),
|
||||
service.clone(),
|
||||
BACKEND,
|
||||
binding.clone(),
|
||||
);
|
||||
Self {
|
||||
_temp: temp,
|
||||
store,
|
||||
materials,
|
||||
service,
|
||||
authorizer,
|
||||
binding,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(&self) -> Result<String, RuntimeDiagnostic> {
|
||||
self.authorizer
|
||||
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, b"")
|
||||
}
|
||||
|
||||
fn verifier(&self) -> WorkspaceCapabilityVerifier {
|
||||
WorkspaceCapabilityVerifier::new(
|
||||
vec![WorkspaceIssuerTrustRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
backend_url: BACKEND.into(),
|
||||
key_id: self.identity.key_id.clone(),
|
||||
algorithm: "ed25519".into(),
|
||||
public_key: self.identity.public_key.clone().unwrap(),
|
||||
public_key_fingerprint: self.identity.public_key_fingerprint.clone().unwrap(),
|
||||
trust_id: uuid::Uuid::now_v7().to_string(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: Utc::now().timestamp(),
|
||||
updated_at_unix: Utc::now().timestamp(),
|
||||
}],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Fixture-only rotation: deliberately no production rotation API or live database.
|
||||
fn rotate_identity(&mut self) {
|
||||
let key_id = "WK-rotated";
|
||||
let material_ref = format!("workspace-signing/{WORKSPACE}/{key_id}");
|
||||
let material = WorkspaceSigningPrivateMaterial::generate(WORKSPACE, key_id).unwrap();
|
||||
let public_key = material.validate_and_public_key(WORKSPACE, key_id).unwrap();
|
||||
let fingerprint = public_key_fingerprint(&public_key).unwrap();
|
||||
self.materials
|
||||
.put_if_absent(&material_ref, &material)
|
||||
.unwrap();
|
||||
self.store.with_conn(|conn| {
|
||||
conn.execute("UPDATE workspace_signing_identities SET key_id = ?1, public_key = ?2, public_key_fingerprint = ?3, private_material_ref = ?4 WHERE workspace_id = ?5",
|
||||
rusqlite::params![key_id, public_key, fingerprint, material_ref, WORKSPACE])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
self.identity = self.service.get_validated(WORKSPACE).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(
|
||||
verifier: &WorkspaceCapabilityVerifier,
|
||||
token: &str,
|
||||
) -> Result<VerifiedWorkspaceCapability, WorkspaceCapabilityVerificationError> {
|
||||
verifier.verify(
|
||||
token,
|
||||
&WorkspaceCapabilityExpectation {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION,
|
||||
method: "GET",
|
||||
path_and_query: PATH,
|
||||
body_digest: &workspace_request_body_digest(b""),
|
||||
now_unix: Utc::now().timestamp(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorizer_token_authenticates_actual_request_without_prior_verification_record() {
|
||||
let fixture = Fixture::new().await;
|
||||
let token = fixture.issue().unwrap();
|
||||
let verifier = fixture.verifier();
|
||||
let authenticated = verify(&verifier, &token).unwrap();
|
||||
assert_eq!(authenticated.workspace_id, WORKSPACE);
|
||||
assert_eq!(authenticated.issuer_key_id, fixture.identity.key_id);
|
||||
assert_eq!(
|
||||
verify(&verifier, &token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::Replay
|
||||
);
|
||||
let token = fixture.issue().unwrap();
|
||||
let wrong_request = WorkspaceCapabilityExpectation {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION,
|
||||
method: "GET",
|
||||
path_and_query: "/v1/ping?probe=2",
|
||||
body_digest: &workspace_request_body_digest(b""),
|
||||
now_unix: Utc::now().timestamp(),
|
||||
};
|
||||
assert_eq!(
|
||||
verifier.verify(&token, &wrong_request).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongPathAndQuery
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_authorizer_selects_rotated_workspace_key_and_new_runtime_trust() {
|
||||
let mut fixture = Fixture::new().await;
|
||||
let old_verifier = fixture.verifier();
|
||||
let old_token = fixture.issue().unwrap();
|
||||
fixture.rotate_identity();
|
||||
let new_token = fixture.issue().unwrap();
|
||||
let new_verifier = fixture.verifier();
|
||||
assert_eq!(
|
||||
verify(&new_verifier, &new_token).unwrap().issuer_key_id,
|
||||
"WK-rotated"
|
||||
);
|
||||
assert_eq!(
|
||||
verify(&new_verifier, &old_token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongIssuerKey
|
||||
);
|
||||
assert_eq!(
|
||||
verify(&old_verifier, &new_token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongIssuerKey
|
||||
);
|
||||
// Changing Workspace authority did not replace or copy metadata into the connection.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
fixture.binding
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaced_or_revoked_connection_rejects_stale_authorizer() {
|
||||
for revoked in [false, true] {
|
||||
let fixture = Fixture::new().await;
|
||||
fixture.issue().unwrap();
|
||||
if revoked {
|
||||
fixture
|
||||
.store
|
||||
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2")
|
||||
.unwrap();
|
||||
} else {
|
||||
let mut replacement = fixture.binding.clone();
|
||||
replacement.base_url = "https://replacement-runtime.test".into();
|
||||
fixture
|
||||
.store
|
||||
.upsert_workspace_runtime_binding(replacement, true)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
fixture.issue().unwrap_err().code,
|
||||
"workspace_runtime_authorization_stale"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_workspace_signing_material_rejects_authorization_without_fallback() {
|
||||
let fixture = Fixture::new().await;
|
||||
fixture
|
||||
.materials
|
||||
.delete(&fixture.identity.private_material_ref)
|
||||
.unwrap();
|
||||
let error = fixture.issue().unwrap_err();
|
||||
assert_eq!(error.code, "workspace_runtime_authorization_unavailable");
|
||||
assert!(
|
||||
error.message.contains("private material is missing"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_claims_must_match_current_workspace_identity_before_signing() {
|
||||
let mut fixture = Fixture::new().await;
|
||||
let now = Utc::now().timestamp();
|
||||
let mut claims = WorkspaceCapabilityClaims {
|
||||
issuer: BACKEND.into(),
|
||||
issuer_workspace_id: WORKSPACE.into(),
|
||||
issuer_key_id: fixture.identity.key_id.clone(),
|
||||
issuer_public_key_fingerprint: fixture.identity.public_key_fingerprint.clone().unwrap(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION.into(),
|
||||
method: "GET".into(),
|
||||
path_and_query: PATH.into(),
|
||||
body_digest: workspace_request_body_digest(b""),
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
};
|
||||
let token = fixture
|
||||
.service
|
||||
.issue_workspace_capability(WORKSPACE, &claims)
|
||||
.unwrap();
|
||||
verify(&fixture.verifier(), &token).unwrap();
|
||||
for field in ["workspace", "key", "fingerprint"] {
|
||||
let mut mismatched = claims.clone();
|
||||
match field {
|
||||
"workspace" => mismatched.issuer_workspace_id = "another-workspace".into(),
|
||||
"key" => mismatched.issuer_key_id = "WK-another".into(),
|
||||
"fingerprint" => {
|
||||
mismatched.issuer_public_key_fingerprint = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &mismatched),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch"),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
fixture.rotate_identity();
|
||||
assert!(
|
||||
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &claims),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch")
|
||||
);
|
||||
claims.issuer_key_id = fixture.identity.key_id.clone();
|
||||
claims.issuer_public_key_fingerprint = fixture.identity.public_key_fingerprint.clone().unwrap();
|
||||
let token = fixture
|
||||
.service
|
||||
.issue_workspace_capability(WORKSPACE, &claims)
|
||||
.unwrap();
|
||||
verify(&fixture.verifier(), &token).unwrap();
|
||||
}
|
||||
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||
workspace_key_id TEXT,
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
|
||||
OR
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
|
||||
),
|
||||
CHECK (
|
||||
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||
OR
|
||||
(state != 'revoked' AND revoked_at IS NULL)
|
||||
)
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TABLE workspace_runtime_verifications (
|
||||
-- Historical authentication metadata only, never current admission authority.
|
||||
CREATE TABLE workspace_runtime_binding_verification_archive (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
|
||||
binding_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
workspace_key_id TEXT,
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_runtime_verification_archive (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL,
|
||||
workspace_key_id TEXT NOT NULL,
|
||||
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
|
||||
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
|
||||
workspace_public_key_fingerprint TEXT NOT NULL,
|
||||
workspace_trust_id TEXT NOT NULL,
|
||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||
challenge_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||
state TEXT NOT NULL,
|
||||
last_outcome TEXT NOT NULL,
|
||||
verified_at TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
|
||||
CHECK((state = 'verified' AND verified_at IS NOT NULL)
|
||||
OR (state != 'verified' AND verified_at IS NULL))
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX workspace_runtime_verifications_state_idx
|
||||
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
|
||||
CREATE TABLE workspace_runtime_binding_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
|
||||
@@ -48,7 +48,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
upgraded
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 89);
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
|
||||
assert_eq!(
|
||||
evidence_rows(conn, "repository_secret_legacy_receipts"),
|
||||
|
||||
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
};
|
||||
use yoi_workspace_server::{
|
||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
|
||||
.into_iter()
|
||||
.filter(|binding| {
|
||||
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||
&& binding.state == WorkspaceRuntimeBindingState::Verified
|
||||
&& binding.revoked_at.is_none()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -787,11 +787,7 @@ mod tests {
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
@@ -815,11 +811,7 @@ mod tests {
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("WK-test".to_owned()),
|
||||
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
|
||||
workspace_trust_id: Some("trust-test".to_owned()),
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -53,7 +53,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
"flow_source_contents",
|
||||
"repositories",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_verification_archive",
|
||||
"workspace_runtime_verification_archive",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,11 +111,9 @@ impl RealDriveFixture {
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: "binding-drive-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Verified,
|
||||
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -160,12 +160,10 @@ impl LaunchFixture {
|
||||
if remote {
|
||||
fixture.runtime_id = "ticket-worker-real-http".to_string();
|
||||
use worker_runtime::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection,
|
||||
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
};
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
|
||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
||||
let workspace_identity = fixture
|
||||
.api
|
||||
.signing_identities
|
||||
@@ -204,13 +202,11 @@ impl LaunchFixture {
|
||||
display_name: "Ticket Worker real HTTP Runtime".into(),
|
||||
base_url: endpoint.clone(),
|
||||
public_key: runtime_identity.public_key.clone(),
|
||||
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: "binding-launch-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Configured,
|
||||
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some(workspace_identity.key_id),
|
||||
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
|
||||
workspace_trust_id: Some("trust-launch-test".to_string()),
|
||||
|
||||
created_at: TEST_CREATED_AT.into(),
|
||||
updated_at: TEST_CREATED_AT.into(),
|
||||
revoked_at: None,
|
||||
@@ -228,6 +224,7 @@ impl LaunchFixture {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let runtime_id = fixture.runtime_id.clone();
|
||||
fixture.runtime_server = Some(tokio::spawn(async move {
|
||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
||||
runtime,
|
||||
@@ -235,10 +232,7 @@ impl LaunchFixture {
|
||||
None,
|
||||
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications: Arc::new(
|
||||
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
|
||||
),
|
||||
runtime_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -258,27 +252,12 @@ impl LaunchFixture {
|
||||
cached_status: "active".into(),
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
};
|
||||
fixture.api.runtime.register_or_replace(
|
||||
RemoteWorkerRuntime::new(
|
||||
remote_config.clone(),
|
||||
TEST_WORKSPACE_ID.to_string(),
|
||||
backend_url.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let verified = perform_workspace_runtime_verification(
|
||||
&fixture.api,
|
||||
fixture.api.runtime.clone(),
|
||||
&binding,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
remote_config.workspace_authorization =
|
||||
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
|
||||
fixture.api.store.clone(),
|
||||
fixture.api.signing_identities.clone(),
|
||||
backend_url.clone(),
|
||||
Some(verified),
|
||||
binding,
|
||||
));
|
||||
fixture.api.runtime.register_or_replace(
|
||||
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
|
||||
|
||||
@@ -694,7 +694,6 @@ async fn revoked_runtime_and_persisted_workspace_fence_reject_new_requests() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
binding.revoked_at = Some("revoked".into());
|
||||
binding.state = StoredRuntimeBindingState::Revoked;
|
||||
f.api
|
||||
.store
|
||||
.upsert_workspace_runtime_binding_record(binding, true)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_resource_keys",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_verification_archive",
|
||||
"workspace_runtime_verification_archive",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_audit",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
|
||||
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
|
||||
}
|
||||
|
||||
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let identity = self.get_validated(workspace_id)?;
|
||||
if identity.state != "active" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_not_provisioned",
|
||||
"Workspace signing identity is not provisioned",
|
||||
));
|
||||
}
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_missing",
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing public key fingerprint is missing",
|
||||
)
|
||||
})?;
|
||||
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
|
||||
let public_key = encode_public_key(signing_key.public_key().as_ref());
|
||||
if public_key_fingerprint(&public_key)? != fingerprint {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
let (_, signing_key) = self.select_signing_key(workspace_id)?;
|
||||
Ok(signing_key.sign(payload).as_ref().to_vec())
|
||||
}
|
||||
|
||||
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
|
||||
workspace_id: &str,
|
||||
claims: &WorkspaceCapabilityClaims,
|
||||
) -> Result<String> {
|
||||
let (identity, signing_key) = self.select_signing_key(workspace_id)?;
|
||||
if claims.issuer_workspace_id != identity.workspace_id
|
||||
|| claims.issuer_key_id != identity.key_id
|
||||
|| Some(claims.issuer_public_key_fingerprint.as_str())
|
||||
!= identity.public_key_fingerprint.as_deref()
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_capability_identity_mismatch",
|
||||
"Workspace capability claims do not match the current signing identity",
|
||||
));
|
||||
}
|
||||
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
|
||||
let signature = self.sign(workspace_id, input.bytes())?;
|
||||
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
|
||||
let signature = signing_key.sign(input.bytes());
|
||||
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
|
||||
}
|
||||
|
||||
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
|
||||
@@ -306,7 +288,40 @@ impl WorkspaceSigningIdentityService {
|
||||
})
|
||||
}
|
||||
|
||||
fn select_signing_key(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<(
|
||||
WorkspaceSigningIdentityRecord,
|
||||
ring::signature::Ed25519KeyPair,
|
||||
)> {
|
||||
let identity = self
|
||||
.store
|
||||
.get_workspace_signing_identity(workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_metadata_missing",
|
||||
"Workspace signing identity metadata is missing",
|
||||
)
|
||||
})?;
|
||||
if identity.state != "active" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_not_provisioned",
|
||||
"Workspace signing identity is not provisioned",
|
||||
));
|
||||
}
|
||||
let signing_key = self.validated_signing_key(&identity)?;
|
||||
Ok((identity, signing_key))
|
||||
}
|
||||
|
||||
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
|
||||
self.validated_signing_key(identity).map(|_| ())
|
||||
}
|
||||
|
||||
fn validated_signing_key(
|
||||
&self,
|
||||
identity: &WorkspaceSigningIdentityRecord,
|
||||
) -> Result<ring::signature::Ed25519KeyPair> {
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let public_key =
|
||||
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
|
||||
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
|
||||
let public_key = encode_public_key(signing_key.public_key().as_ref());
|
||||
let fingerprint = public_key_fingerprint(&public_key)?;
|
||||
if identity.public_key.as_deref() != Some(public_key.as_str())
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
|
||||
@@ -327,7 +342,7 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
Ok(signing_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +858,14 @@ mod tests {
|
||||
loads: std::sync::atomic::AtomicUsize::new(0),
|
||||
}),
|
||||
);
|
||||
let selected_signature = swapping_service.sign("workspace-1", payload).unwrap();
|
||||
ring::signature::UnparsedPublicKey::new(
|
||||
&ring::signature::ED25519,
|
||||
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.verify(payload, &selected_signature)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
swapping_service.sign("workspace-1", payload),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. })
|
||||
|
||||
@@ -8,11 +8,11 @@ Never use the live dogfood Server or Runtime as the first startup test for a new
|
||||
|
||||
1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
|
||||
2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
|
||||
3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified.
|
||||
3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must configure the Runtime endpoint and pinned Runtime key, the Runtime operator must install the Workspace issuer bundle, and actual signed requests must authenticate. A connection-test result describes only that attempt; no stored verification state is a readiness prerequisite.
|
||||
4. Have an external supervisor or operator restart Server and Runtime from the same validated source commit/build artifacts. A Worker hosted by the target Runtime must never terminate its own Runtime.
|
||||
5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
|
||||
|
||||
The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly.
|
||||
The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must configure the same Workspace-scoped connection and explicit Runtime-side Workspace trust used by production, then exercise per-request authentication rather than recreating global trust or seeding a past verification result.
|
||||
|
||||
## What to record
|
||||
|
||||
@@ -25,7 +25,7 @@ A report is useful when it explains:
|
||||
- what design boundary was missing
|
||||
- what evidence was observed
|
||||
|
||||
For a Remote Runtime rollout, also record the source commit, binary artifact digest, Server schema version, Runtime `binding_id`, Workspace `key_id` and `workspace_trust_id`, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
|
||||
For a Remote Runtime rollout, also record the source commit, binary artifact digest, Server schema version, Runtime `binding_id` and pinned key fingerprint, the current Workspace `key_id` and public fingerprint, the Runtime-side trust registration/revocation, and typed HTTP/WebSocket outcomes. Do not copy a Runtime trust ID into the Server connection configuration or treat a successful connection test as persisted authentication. A successful document response does not outweigh visible UI, console, or API errors.
|
||||
|
||||
## Runtime command caveat
|
||||
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
# Workspace ↔ Runtime 認証
|
||||
|
||||
Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
|
||||
認証の成否は実際の HTTP / WebSocket request ごとに決まり、保存済みの「verified」状態や過去の接続テスト結果を前提にしない。
|
||||
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
|
||||
|
||||
## Authority
|
||||
|
||||
- Server DB は Workspace ごとの signing identity と Runtime binding を保持する。
|
||||
- Runtime は `trust-workspace` で受理した `WorkspacePublicIdentityBundle` を保持する。
|
||||
- Server DB は Workspace ごとの signing identity と Runtime 接続設定(binding)を保持する。
|
||||
- binding は endpoint、固定した Runtime public key/fingerprint、`binding_id`、authentication mode、管理者による失効を保持する。Workspace key や Runtime 側の trust ID のコピー、認証成功状態は保持しない。
|
||||
- Runtime は管理者が `trust-workspace` で受理した `WorkspacePublicIdentityBundle` を保持する。
|
||||
- bundle は `workspace_id`、Workspace `key_id`、Workspace public key/fingerprint、Backend URL を固定する。
|
||||
- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。
|
||||
- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key ID、現在の trust ID、expiry、JTI を検証する。
|
||||
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。
|
||||
- Server → Runtime の各 HTTP / WebSocket request は、その時点の Workspace signing identity で短命な capability token を発行する。署名時には claims の Workspace・key ID・fingerprint が選択した identity と一致することを検証する。秘密鍵materialがない場合は fallback せず拒否する。
|
||||
- Runtime は現在の明示的な Workspace trust、署名、request method、`path_and_query`、body digest、permission、Workspace、Runtime、必要に応じ Worker、key ID/fingerprint、expiry、JTI を検証する。過去の handshake や verification record は不要である。
|
||||
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。この proof の契約は変わらない。
|
||||
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
|
||||
|
||||
旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
|
||||
|
||||
## Provisioning
|
||||
|
||||
The enrollment input includes the exact Runtime-issued `workspace_trust_id` from
|
||||
`trust-workspace add/show`. Export the current Workspace public bundle first,
|
||||
accept it on the Runtime, then register the Runtime public bundle, endpoint and
|
||||
trust ID. Server verification binds that enrollment ID; it must never invent an
|
||||
ID from the key fingerprint or assume an initial counter value. Re-enrollment
|
||||
requires registering the newly issued trust ID, even when the key is unchanged.
|
||||
Runtime 側の Workspace trust 登録と Server 側の Runtime 接続設定は独立した管理操作である。Runtime が発行する `trust_id` を Server にコピーする必要はない。
|
||||
|
||||
1. Runtime identity を初期化する。
|
||||
|
||||
@@ -31,17 +28,16 @@ requires registering the newly issued trust ID, even when the key is unchanged.
|
||||
yoi-runtime identity show
|
||||
```
|
||||
|
||||
2. The Workspace owner exports the current Workspace issuer public bundle from Workspace identity settings.
|
||||
3. The operator accepts that bundle on the Runtime and reads the issued trust ID.
|
||||
2. Workspace owner が Workspace identity settings から現在の Workspace issuer public bundle を export する。
|
||||
3. Runtime 管理者が bundle を確認し、Runtime に明示的に登録する。
|
||||
|
||||
```sh
|
||||
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
|
||||
yoi-runtime trust-workspace show --workspace-id <workspace-id>
|
||||
```
|
||||
|
||||
4. The owner registers the Runtime public bundle, endpoint and exact `workspace_trust_id` in Settings > Runtimes.
|
||||
5. The Server sends a signed challenge bound to that trust ID, the Workspace key fingerprint and the binding ID, then verifies the Runtime proof and acknowledgement.
|
||||
6. Normal Workspace-signed requests become available only after the verified binding is committed. Revoking and re-enrolling the same key still requires the newly issued Runtime trust ID.
|
||||
4. Workspace owner が Settings > Runtimes に Runtime public bundle と endpoint を登録する。接続設定には Runtime key を固定するが、Workspace key や Runtime の trust ID は登録しない。
|
||||
5. 接続テストまたは通常操作を実行する。実際の Workspace-signed request が Runtime の現在の trust と照合される。接続テスト結果はその試行の診断であり、永続的な認証状態を作らず、以後の request の成功を保証しない。
|
||||
|
||||
同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
|
||||
|
||||
@@ -55,29 +51,41 @@ requires registering the newly issued trust ID, even when the key is unchanged.
|
||||
- 最大 8 MiB
|
||||
- 最大 4,096 records
|
||||
- exact Workspace / Runtime identity
|
||||
- `key_id` と public key fingerprint、opaque `trust_id`
|
||||
- `key_id` と public key fingerprint、管理 record の opaque `trust_id`
|
||||
- normalized Backend URL
|
||||
- 同じ `key_id` に異なる鍵materialを割り当てないこと。実際の置換・失効は新しい `trust_id` を発行する
|
||||
- 同じ `key_id` に異なる鍵materialを割り当てないこと。実際の置換・失効は新しい `trust_id` を発行するが、この ID は request token や Server binding に含めない
|
||||
- list は `offset` / `limit` 必須で、1 page 最大 100 records
|
||||
|
||||
## Local token
|
||||
|
||||
`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
|
||||
|
||||
## Rotation と失効
|
||||
## Key 変更と失効
|
||||
|
||||
Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い key/trust ID、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
|
||||
Workspace signing identity は各 request の発行時に現在の authority から選択する。Workspace public key が変更された場合、Runtime 管理者が現在の public bundle を明示的に `trust-workspace replace` で受理する必要がある。Workspace key のコピーを更新するためだけに Server の Runtime 接続設定を置き換える操作は不要である。これは Workspace key rotation の新しい production API を定義するものではない。
|
||||
|
||||
Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、`binding_id`、Workspace `key_id`、`workspace_trust_id` の変更を検知した場合、stale client を利用しない。
|
||||
Runtime 管理者は `trust-workspace revoke` で Workspace trust を失効できる。Runtime は現在の trust と一致しない key、失効済み trust、expired token、replayed JTI を拒否する。同じ key の再登録も管理者の明示的な操作で行い、request の自己申告による trust 登録は行わない。
|
||||
|
||||
Runtime key や endpoint の変更は Server の接続設定を明示的に置き換える。Server の Runtime cache と authorizer は現在の接続設定(endpoint、Runtime public key/fingerprint、`binding_id`、authentication mode、管理者による失効)と照合し、replaced/revoked binding を使う stale client を拒否する。接続設定の一致は送信先の選択であって、認証成功の証拠ではない。
|
||||
|
||||
接続先が到達不能・認証失敗でも、owner は観測した `binding_id` を指定して接続設定を修復できる。取得できた active Worker は置換を遮断するが、Worker 一覧の取得失敗を「idle の証明」とは扱わず、認証成功を修復の前提にもしない。接続設定の置換は Worker の停止・削除ではなく、Runtime 削除の資源確認とは別の操作である。
|
||||
|
||||
## 保存形式の移行
|
||||
|
||||
Server schema90 は既存 binding の endpoint、Runtime key、binding ID、明示的失効を保持し、古い認証状態と検証記録を履歴専用 archive に移す。履歴は要求の認可には参照しない。既存 Workspace identity や接続設定の作り直しは不要である。
|
||||
|
||||
Runtime の旧形式の Workspace trust は、検証済みの公開鍵と管理者の Active/Revoked 設定を保持して読み込む。形式の移行だけでは失効させず、すでに保存されている Revoked 設定を自動で解除することもしない。旧 verification file は認可に使用しない。消費済み token の replay 記録は引き継ぎ、同じ鍵の再登録でリセットしない。
|
||||
|
||||
API と capability claims の旧 handshake 契約は維持しないため、Backend・Runtime・Web は対応する版へ更新する。接続テストは更新後の実認証を確認するものであり、登録を有効化する手順ではない。
|
||||
|
||||
## 運用確認
|
||||
|
||||
Remote Runtime を有効化した後は次を確認する。
|
||||
Remote Runtime を設定した後は次を確認する。
|
||||
|
||||
1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
|
||||
2. Workspace Settings の Runtime binding が `verified` で、現在の `binding_id`・`workspace_key_id`・`workspace_trust_id` と verification evidence を表示する。
|
||||
3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。
|
||||
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。
|
||||
1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する active bundle を表示する。
|
||||
2. Workspace Settings の Runtime 接続設定が期待する endpoint と固定 Runtime key を示し、失効していない。
|
||||
3. 接続テストの今回の結果を確認し、Runtime ping、Worker list/create、`worker.protocol` subscription が実際の Workspace-signed request で成功することを確認する。保存済み「verified」表示や verification evidence は確認項目ではない。
|
||||
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked Runtime trust、replaced/revoked binding、replayed JTI が拒否される。
|
||||
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
|
||||
|
||||
Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
|
||||
|
||||
+4
-113
@@ -1530,15 +1530,10 @@
|
||||
},
|
||||
"public_bundle": {
|
||||
"$ref": "#/components/schemas/RuntimePublicIdentityBundle"
|
||||
},
|
||||
"workspace_trust_id": {
|
||||
"description": "Exact Runtime-issued enrollment identity from `trust-workspace add/show`.\nA key fingerprint alone cannot distinguish revocation and re-enrollment.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"public_bundle",
|
||||
"workspace_trust_id",
|
||||
"endpoint"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -7840,15 +7835,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RuntimeConnectionDisplayState": {
|
||||
"enum": [
|
||||
"configured",
|
||||
"verified",
|
||||
"unavailable",
|
||||
"revoked"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RuntimeConnectionTestFailureKind": {
|
||||
"enum": [
|
||||
"authentication",
|
||||
@@ -7865,6 +7851,7 @@
|
||||
},
|
||||
"RuntimeConnectionTestResponse": {
|
||||
"additionalProperties": false,
|
||||
"description": "Observation of an actual signed ping, not a grant or cached approval.",
|
||||
"properties": {
|
||||
"actual_protocol_version": {
|
||||
"format": "uint32",
|
||||
@@ -7881,9 +7868,6 @@
|
||||
"checked_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"connection_state": {
|
||||
"$ref": "#/components/schemas/RuntimeConnectionDisplayState"
|
||||
},
|
||||
"diagnostics": {
|
||||
"default": [],
|
||||
"items": {
|
||||
@@ -7913,16 +7897,6 @@
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/RuntimeConnectionTestStatus"
|
||||
},
|
||||
"verification": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -7931,7 +7905,6 @@
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"binding_id",
|
||||
"connection_state",
|
||||
"checked_at",
|
||||
"status",
|
||||
"expected_protocol_version"
|
||||
@@ -8385,57 +8358,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RuntimeVerificationEvidenceSummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"binding_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_outcome": {
|
||||
"$ref": "#/components/schemas/RuntimeVerificationOutcome"
|
||||
},
|
||||
"runtime_public_key_fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"verified_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspace_key_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_public_key_fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_trust_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"last_checked_at",
|
||||
"last_outcome",
|
||||
"binding_id",
|
||||
"workspace_key_id",
|
||||
"workspace_public_key_fingerprint",
|
||||
"workspace_trust_id",
|
||||
"runtime_public_key_fingerprint"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RuntimeVerificationOutcome": {
|
||||
"enum": [
|
||||
"verified",
|
||||
"challenge_issued",
|
||||
"verification_failed",
|
||||
"connectivity_failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RuntimeWorkerCompletionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -15335,43 +15257,14 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkspaceRuntimeBindingState": {
|
||||
"enum": [
|
||||
"configured",
|
||||
"verified",
|
||||
"revoked"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WorkspaceRuntimeBindingSummary": {
|
||||
"additionalProperties": false,
|
||||
"description": "Binding identity and explicit revocation only. Each signed request authenticates\nusing the current Workspace signing identity, not a stored approval.",
|
||||
"properties": {
|
||||
"binding_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"connection_state": {
|
||||
"$ref": "#/components/schemas/RuntimeConnectionDisplayState"
|
||||
},
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/WorkspaceRuntimeBindingState"
|
||||
},
|
||||
"verification": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_key_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspace_trust_id": {
|
||||
"revoked_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -15379,8 +15272,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state",
|
||||
"connection_state",
|
||||
"binding_id"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -16135,7 +16026,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3"
|
||||
"x-yoi-source-digest": "sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -673,39 +673,15 @@
|
||||
{
|
||||
"path": "crates/worker-runtime/src/workspace_issuer.rs",
|
||||
"kind": "saved_data",
|
||||
"reason": "LegacyWorkspaceIssuerTrustRecord/LegacyVerificationDocumentの保存済みenrollment・verification keyとfixture。旧trustをrevokedへ移行し、旧verification bytesをarchiveするがlive recordsを空にするconsumerがある。削除すると既存記録を読めないか、旧capabilityを再承認なしに復活させない移行検証を失う。",
|
||||
"reason": "LegacyWorkspaceIssuerTrustRecordの保存済みenrollment keyとfixture。旧形式の管理者によるWorkspace信頼登録を読み取る境界だけに綴りを保持する。通常要求の署名検証は現行の信頼済み公開鍵を使い、旧counterや過去のverification記録は認可に使わない。削除すると既存の信頼登録を読めなくなる。",
|
||||
"lines": [
|
||||
{
|
||||
"sha256": "56e399dc4e828e66677d4ad09b4c66a9c09658322b3886a06b4ae688c63dfc84",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "ea16c387d0048571bd0a5576f1358af32acf63f8ee5ebe08c262da83c5a04d88",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "bfd48831eb694b879a2859d42dba00fe74a4a817177acc61a44795cbd19b1056",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "94af0abdfb45c712e9cd1949ac6a15d1a21c3b271f004c9282f81b5fd5ae4f5d",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "d099e3a7216349c0ef30026967b8fe344e3b08b58a77bad6437468502b56bf71",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "bed791d41faa85b38602c238d30460681e6d9ebbe56c4c08b843dd1971a589b2",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "ce36822c65f785f330e42b7d37241062d994dc4e1c18abaa327b7996da5b6521",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"sha256": "5f4aed5ed50f2bbf54bbb9293948c3294eb2d30086d0fbe7cb07899efb5c96ba",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
@@ -25,7 +25,7 @@ export type CompletionEntry = { description?: string | null; invocation?: Featur
|
||||
|
||||
export type CompletionKind = "file" | "feature" | "feature_argument";
|
||||
|
||||
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; workspace_trust_id: string; };
|
||||
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; };
|
||||
|
||||
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
|
||||
|
||||
@@ -77,11 +77,9 @@ export type RuntimeConfigBundleResult = { availability?: unknown; diagnostics?:
|
||||
|
||||
export type RuntimeConfigBundleSyncRequest = { bundle: unknown; };
|
||||
|
||||
export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavailable" | "revoked";
|
||||
|
||||
export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration";
|
||||
|
||||
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; };
|
||||
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; workspace_id: string; };
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
@@ -121,10 +119,6 @@ export type RuntimeTrustKeyState = { binding_id?: string | null; created_at?: st
|
||||
|
||||
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||
|
||||
export type RuntimeVerificationEvidenceSummary = { binding_id: string; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_key_id: string; workspace_public_key_fingerprint: string; workspace_trust_id: string; };
|
||||
|
||||
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
|
||||
|
||||
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; request_id?: string | null; };
|
||||
|
||||
export type RuntimeWorkerCompletionsResult = { context?: CompletionContext | null; diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; request_id?: string | null; runtime_id: string; worker_id: string; };
|
||||
@@ -217,9 +211,7 @@ export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_tar
|
||||
|
||||
export type WorkspaceConfigAccess = "read_only" | "read_write";
|
||||
|
||||
export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked";
|
||||
|
||||
export type WorkspaceRuntimeBindingSummary = { binding_id: string; connection_state: RuntimeConnectionDisplayState; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_id?: string | null; workspace_trust_id?: string | null; };
|
||||
export type WorkspaceRuntimeBindingSummary = { binding_id: string; revoked_at?: string | null; };
|
||||
|
||||
export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array<RuntimeTrustAuditEntry>; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -2,15 +2,12 @@ import type {
|
||||
Diagnostic,
|
||||
RuntimeConnectionTestFailureKind,
|
||||
RuntimeConnectionTestResponse,
|
||||
RuntimeVerificationEvidenceSummary,
|
||||
} from "#lib/generated/runtime-api.ts";
|
||||
|
||||
const RESPONSE_KEYS = [
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"binding_id",
|
||||
"connection_state",
|
||||
"verification",
|
||||
"checked_at",
|
||||
"status",
|
||||
"failure_kind",
|
||||
@@ -108,27 +105,11 @@ export function parseRuntimeConnectionTestResponse(
|
||||
return null;
|
||||
}
|
||||
const bindingId = value.binding_id;
|
||||
const connectionState = parseConnectionState(value.connection_state);
|
||||
const verification = parseVerificationEvidence(value.verification);
|
||||
if (
|
||||
!isBoundedString(bindingId) ||
|
||||
connectionState === null ||
|
||||
(value.verification !== null && verification === null) ||
|
||||
(verification !== null &&
|
||||
verification.binding_id !== bindingId) ||
|
||||
(connectionState === "verified" && value.status !== "compatible") ||
|
||||
(connectionState === "verified" && verification !== null &&
|
||||
(verification.last_outcome !== "verified" ||
|
||||
verification.verified_at === null))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!isBoundedString(bindingId, 256)) return null;
|
||||
return {
|
||||
workspace_id: value.workspace_id,
|
||||
runtime_id: value.runtime_id,
|
||||
binding_id: bindingId,
|
||||
connection_state: connectionState,
|
||||
verification,
|
||||
checked_at: value.checked_at,
|
||||
status: value.status,
|
||||
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
||||
@@ -138,60 +119,6 @@ export function parseRuntimeConnectionTestResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function parseConnectionState(
|
||||
value: unknown,
|
||||
): "configured" | "verified" | "unavailable" | "revoked" | null {
|
||||
return value === "configured" || value === "verified" ||
|
||||
value === "unavailable" || value === "revoked"
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseVerificationEvidence(
|
||||
value: unknown,
|
||||
): RuntimeVerificationEvidenceSummary | null {
|
||||
if (value === null) return null;
|
||||
const keys = [
|
||||
"verified_at",
|
||||
"last_checked_at",
|
||||
"last_outcome",
|
||||
"binding_id",
|
||||
"workspace_key_id",
|
||||
"workspace_public_key_fingerprint",
|
||||
"workspace_trust_id",
|
||||
"runtime_public_key_fingerprint",
|
||||
] as const;
|
||||
if (!isRecord(value) || !hasExactKeys(value, keys)) return null;
|
||||
if (
|
||||
(value.verified_at !== null &&
|
||||
(!isBoundedString(value.verified_at, 128) ||
|
||||
Number.isNaN(Date.parse(value.verified_at)))) ||
|
||||
!isBoundedString(value.last_checked_at, 128) ||
|
||||
Number.isNaN(Date.parse(value.last_checked_at)) ||
|
||||
(value.last_outcome !== "verified" &&
|
||||
value.last_outcome !== "challenge_issued" &&
|
||||
value.last_outcome !== "verification_failed" &&
|
||||
value.last_outcome !== "connectivity_failed") ||
|
||||
!isBoundedString(value.binding_id) ||
|
||||
!isBoundedString(value.workspace_key_id, 128) ||
|
||||
!isBoundedString(value.workspace_public_key_fingerprint) ||
|
||||
!isBoundedString(value.workspace_trust_id) ||
|
||||
!isBoundedString(value.runtime_public_key_fingerprint, 128)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
verified_at: value.verified_at,
|
||||
last_checked_at: value.last_checked_at,
|
||||
last_outcome: value.last_outcome,
|
||||
binding_id: value.binding_id,
|
||||
workspace_key_id: value.workspace_key_id,
|
||||
workspace_public_key_fingerprint: value.workspace_public_key_fingerprint,
|
||||
workspace_trust_id: value.workspace_trust_id,
|
||||
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testRuntimeConnection(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
Diagnostic,
|
||||
RemoveRuntimeRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeConnectionDisplayState,
|
||||
RuntimeIdentityAuthority,
|
||||
RuntimeManagementApiError,
|
||||
RuntimeManagementSummary,
|
||||
@@ -17,14 +16,13 @@ import type {
|
||||
RuntimeTrustKeyRevealResponse,
|
||||
RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus,
|
||||
RuntimeVerificationEvidenceSummary,
|
||||
UpdateRemoteRuntimeRequest,
|
||||
WorkspaceRuntimeBindingState,
|
||||
WorkspaceRuntimeBindingSummary,
|
||||
WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeListResponse,
|
||||
WorkspaceRuntimeResource,
|
||||
} from "#lib/generated/runtime-api.ts";
|
||||
import type { RepositoryApiError } from "#lib/generated/repository-api.ts";
|
||||
import { workspaceApiPath } from "./http.ts";
|
||||
|
||||
export type WorkspaceRuntimeList =
|
||||
@@ -90,17 +88,6 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
||||
"stale_binding",
|
||||
"fingerprint_in_use",
|
||||
]);
|
||||
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
|
||||
"configured",
|
||||
"verified",
|
||||
"revoked",
|
||||
]);
|
||||
const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
|
||||
"configured",
|
||||
"verified",
|
||||
"unavailable",
|
||||
"revoked",
|
||||
]);
|
||||
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
|
||||
"pending",
|
||||
"cleanup_pending",
|
||||
@@ -326,140 +313,16 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeVerification(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): RuntimeVerificationEvidenceSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"verified_at",
|
||||
"last_checked_at",
|
||||
"last_outcome",
|
||||
"binding_id",
|
||||
"workspace_key_id",
|
||||
"workspace_public_key_fingerprint",
|
||||
"workspace_trust_id",
|
||||
"runtime_public_key_fingerprint",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
const verifiedAt = item.verified_at === null
|
||||
? null
|
||||
: boundedString(item.verified_at, `${path}.verified_at`, 128);
|
||||
const lastOutcome = enumValue(
|
||||
item.last_outcome,
|
||||
`${path}.last_outcome`,
|
||||
new Set(
|
||||
[
|
||||
"verified",
|
||||
"challenge_issued",
|
||||
"verification_failed",
|
||||
"connectivity_failed",
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
return {
|
||||
verified_at: verifiedAt,
|
||||
last_checked_at: boundedString(
|
||||
item.last_checked_at,
|
||||
`${path}.last_checked_at`,
|
||||
128,
|
||||
),
|
||||
last_outcome: lastOutcome,
|
||||
binding_id: boundedIdentity(
|
||||
item.binding_id,
|
||||
`${path}.binding_id`,
|
||||
),
|
||||
workspace_key_id: boundedString(
|
||||
item.workspace_key_id,
|
||||
`${path}.workspace_key_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
workspace_public_key_fingerprint: boundedIdentity(
|
||||
item.workspace_public_key_fingerprint,
|
||||
`${path}.workspace_public_key_fingerprint`,
|
||||
),
|
||||
workspace_trust_id: boundedIdentity(
|
||||
item.workspace_trust_id,
|
||||
`${path}.workspace_trust_id`,
|
||||
),
|
||||
runtime_public_key_fingerprint: boundedString(
|
||||
item.runtime_public_key_fingerprint,
|
||||
`${path}.runtime_public_key_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeBinding(
|
||||
value: unknown,
|
||||
path: string,
|
||||
requiresWorkspaceIdentity: boolean,
|
||||
): WorkspaceRuntimeBindingSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["state", "connection_state", "binding_id"],
|
||||
["workspace_key_id", "workspace_trust_id", "verification"],
|
||||
path,
|
||||
);
|
||||
const workspaceKeyId = optionalNullableString(
|
||||
item.workspace_key_id,
|
||||
`${path}.workspace_key_id`,
|
||||
LIMITS.idBytes,
|
||||
);
|
||||
const workspaceTrustId = optionalNullableIdentity(
|
||||
item.workspace_trust_id,
|
||||
`${path}.workspace_trust_id`,
|
||||
);
|
||||
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
state !== "revoked" &&
|
||||
(workspaceKeyId == null || workspaceTrustId == null)
|
||||
) {
|
||||
return fail(path, "requires Workspace signing key identity metadata");
|
||||
}
|
||||
const connectionState = enumValue(
|
||||
item.connection_state,
|
||||
`${path}.connection_state`,
|
||||
CONNECTION_STATES,
|
||||
);
|
||||
const binding_id = boundedIdentity(item.binding_id, `${path}.binding_id`);
|
||||
const verification = item.verification === undefined
|
||||
? undefined
|
||||
: runtimeVerification(item.verification, `${path}.verification`);
|
||||
if (
|
||||
verification !== undefined && verification.binding_id !== binding_id
|
||||
) {
|
||||
return fail(path, "verification must match the current binding binding_id");
|
||||
}
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
connectionState === "verified" &&
|
||||
(verification === undefined ||
|
||||
verification.verified_at === null ||
|
||||
verification.last_outcome !== "verified")
|
||||
) {
|
||||
return fail(
|
||||
path,
|
||||
"verified Workspace identity binding requires verification evidence",
|
||||
);
|
||||
}
|
||||
exactKeys(item, ["binding_id"], ["revoked_at"], path);
|
||||
return {
|
||||
state,
|
||||
connection_state: connectionState,
|
||||
binding_id,
|
||||
...(workspaceKeyId === undefined
|
||||
? {}
|
||||
: { workspace_key_id: workspaceKeyId }),
|
||||
...(workspaceTrustId === undefined
|
||||
? {}
|
||||
: { workspace_trust_id: workspaceTrustId }),
|
||||
...(verification === undefined ? {} : { verification }),
|
||||
binding_id: boundedIdentity(item.binding_id, `${path}.binding_id`),
|
||||
revoked_at:
|
||||
optionalNullableTimestamp(item.revoked_at, `${path}.revoked_at`) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -483,7 +346,7 @@ function runtimeManagement(
|
||||
const builtIn = boolean(item.built_in, `${path}.built_in`);
|
||||
const binding = item.binding == null
|
||||
? undefined
|
||||
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
|
||||
: runtimeBinding(item.binding, `${path}.binding`);
|
||||
return {
|
||||
built_in: builtIn,
|
||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||
@@ -763,7 +626,7 @@ export function parseRuntimeTrustConflict(
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message"],
|
||||
["current_binding_id", "current_fingerprint"],
|
||||
["current_binding_id", "current_fingerprint", "diagnostics"],
|
||||
"Runtime trust conflict",
|
||||
);
|
||||
return {
|
||||
@@ -786,6 +649,15 @@ export function parseRuntimeTrustConflict(
|
||||
"Runtime trust conflict.current_fingerprint",
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
...(response.diagnostics === undefined ? {} : {
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"Runtime trust conflict.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime trust conflict.diagnostics[${index}]`)
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -891,6 +763,37 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryApiError(value: unknown): RepositoryApiError {
|
||||
const response = object(value, "Runtime trust error");
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message"],
|
||||
["diagnostics"],
|
||||
"Runtime trust error",
|
||||
);
|
||||
return {
|
||||
error: boundedString(
|
||||
response.error,
|
||||
"Runtime trust error.error",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
message: boundedString(
|
||||
response.message,
|
||||
"Runtime trust error.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
),
|
||||
...(response.diagnostics === undefined ? {} : {
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"Runtime trust error.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function requestErrorFrom(
|
||||
value: unknown,
|
||||
status: number,
|
||||
@@ -917,30 +820,13 @@ function requestErrorFrom(
|
||||
),
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message", "diagnostics"],
|
||||
[],
|
||||
"Runtime trust error",
|
||||
);
|
||||
const diagnostics = array(
|
||||
response.diagnostics,
|
||||
"Runtime trust error.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||
);
|
||||
const message = boundedString(
|
||||
response.message,
|
||||
"Runtime trust error.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
);
|
||||
const field = diagnostics.some((entry) =>
|
||||
const error = repositoryApiError(response);
|
||||
const field = (error.diagnostics ?? []).some((entry) =>
|
||||
entry.code.startsWith("runtime_public_key_")
|
||||
)
|
||||
? "public_key"
|
||||
: null;
|
||||
return new RuntimeTrustRequestError(message, field);
|
||||
return new RuntimeTrustRequestError(error.message, field);
|
||||
} catch {
|
||||
return new RuntimeTrustRequestError(
|
||||
`Runtime trust request failed (${status})`,
|
||||
@@ -960,6 +846,21 @@ function throwRuntimeTrustConflict(payload: unknown): never {
|
||||
throw new RuntimeTrustConflictError(conflict);
|
||||
}
|
||||
|
||||
function throwRuntimeCreationConflict(payload: unknown): never {
|
||||
let error: RepositoryApiError;
|
||||
try {
|
||||
error = repositoryApiError(payload);
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust conflict response was invalid",
|
||||
);
|
||||
}
|
||||
if (CONFLICT_KINDS.has(error.error as RuntimeTrustConflictKind)) {
|
||||
throwRuntimeTrustConflict(error);
|
||||
}
|
||||
throw requestErrorFrom(error, 409);
|
||||
}
|
||||
|
||||
async function finishMutation(
|
||||
response: Response,
|
||||
workspaceId: string,
|
||||
@@ -990,17 +891,16 @@ export async function createRemoteRuntime(
|
||||
request: CreateRemoteRuntimeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeResource> {
|
||||
const enrollmentId = boundedIdentity(request.workspace_trust_id, "workspace_trust_id");
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(workspaceId, "/runtimes"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...request, workspace_trust_id: enrollmentId }),
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
const payload = await readBoundedJson(response);
|
||||
if (response.status === 409) throwRuntimeTrustConflict(payload);
|
||||
if (response.status === 409) throwRuntimeCreationConflict(payload);
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
const runtime = runtimeResource(payload, "Runtime create response");
|
||||
if (runtime.runtime_id !== request.public_bundle.identity_id) {
|
||||
|
||||
@@ -68,6 +68,6 @@ export function repositorySshProbeRuntimes(
|
||||
runtime.management.endpoint_configured &&
|
||||
runtime.management.binding !== undefined &&
|
||||
runtime.management.binding !== null &&
|
||||
runtime.management.binding.state !== "revoked"
|
||||
runtime.management.binding.revoked_at == null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let runtimePublicBundle = $state('');
|
||||
let workspaceTrustId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let runtimeFingerprint = $state<string | null>(null);
|
||||
@@ -36,9 +35,7 @@
|
||||
|
||||
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
||||
if (result.status === 'compatible') {
|
||||
return result.connection_state === 'verified'
|
||||
? `Verified · protocol v${result.actual_protocol_version}`
|
||||
: `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`;
|
||||
return `Signed ping succeeded · protocol v${result.actual_protocol_version}`;
|
||||
}
|
||||
switch (result.failure_kind) {
|
||||
case 'authentication': return 'Authentication failed';
|
||||
@@ -145,18 +142,16 @@
|
||||
}
|
||||
await createRemoteRuntime(data.workspaceId, {
|
||||
public_bundle: publicBundle,
|
||||
workspace_trust_id: workspaceTrustId,
|
||||
display_name: displayName || null,
|
||||
endpoint,
|
||||
expected_binding_id: null,
|
||||
});
|
||||
runtimePublicBundle = '';
|
||||
workspaceTrustId = '';
|
||||
runtimeFingerprint = null;
|
||||
displayName = '';
|
||||
endpoint = '';
|
||||
showAddRuntime = false;
|
||||
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
|
||||
requestNotice = 'Runtime registered for this Workspace. Run Test to observe a signed ping.';
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
||||
@@ -171,7 +166,8 @@
|
||||
runtime: WorkspaceRuntimeResource,
|
||||
): RuntimeConnectionTestResponse | undefined {
|
||||
const result = testResults[runtime.runtime_id];
|
||||
return result?.binding_id === runtime.management?.binding?.binding_id
|
||||
return result?.workspace_id === data.workspaceId &&
|
||||
result?.binding_id === runtime.management?.binding?.binding_id
|
||||
? result
|
||||
: undefined;
|
||||
}
|
||||
@@ -179,12 +175,16 @@
|
||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||
const bindingId = runtime.management?.binding?.binding_id;
|
||||
if (typeof bindingId !== 'string') return;
|
||||
const workspaceId = data.workspaceId;
|
||||
const generation = ++connectionTestGeneration;
|
||||
requestError = null;
|
||||
const { [runtime.runtime_id]: _previousResult, ...remainingResults } = testResults;
|
||||
testResults = remainingResults;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
||||
if (
|
||||
workspaceId !== data.workspaceId ||
|
||||
generation !== connectionTestGeneration ||
|
||||
result.binding_id !== bindingId
|
||||
) {
|
||||
@@ -261,15 +261,9 @@
|
||||
</button>
|
||||
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
|
||||
<small>
|
||||
Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code>.
|
||||
It identifies this enrollment, not the Workspace key fingerprint.
|
||||
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
|
||||
service. Existing Workspace trust entries are preserved.
|
||||
</small>
|
||||
<label>
|
||||
Workspace trust ID
|
||||
<input bind:value={workspaceTrustId} required autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
{:else}
|
||||
<p class="section-state">Loading Workspace public identity…</p>
|
||||
{/if}
|
||||
@@ -323,14 +317,14 @@
|
||||
</div>
|
||||
<p>
|
||||
Registration stores this Workspace-scoped binding. After it appears in the list, run
|
||||
<strong>Test</strong> to complete authenticated verification.
|
||||
<strong>Test</strong> to observe a signed ping. Each request authenticates independently.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="settings-action-row">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint || !workspaceTrustId}
|
||||
disabled={busyRuntimeId !== null || !runtimeFingerprint}
|
||||
>Register Runtime</button>
|
||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||
Cancel
|
||||
@@ -368,7 +362,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.runtimes.items as runtime}
|
||||
<tr class:inactive={runtime.status !== 'active'}>
|
||||
<tr class:inactive={Boolean(runtime.management?.binding?.revoked_at) || currentTestResult(runtime)?.status === 'failed'}>
|
||||
<td>
|
||||
<strong>
|
||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
||||
@@ -379,7 +373,7 @@
|
||||
</td>
|
||||
<td>{runtime.kind}</td>
|
||||
<td>
|
||||
{runtime.management?.binding?.connection_state ?? runtime.status}
|
||||
{runtime.management?.binding?.revoked_at ? 'Revoked' : currentTestResult(runtime) ? connectionTestSummary(currentTestResult(runtime)!) : 'Not tested'}
|
||||
</td>
|
||||
<td>{runtimePlatform(runtime)}</td>
|
||||
<td>{managementLabel(runtime)}</td>
|
||||
@@ -390,16 +384,14 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="settings-action-row">
|
||||
{#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
|
||||
{#if runtime.management?.config_managed && !runtime.management.binding?.revoked_at}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => testRuntime(runtime)}
|
||||
>Test</button>
|
||||
{/if}
|
||||
{#if runtime.management?.binding?.state === 'configured'}
|
||||
<span class="settings-muted-action">Verification required</span>
|
||||
{:else if !runtime.management?.config_managed}
|
||||
{#if !runtime.management?.config_managed}
|
||||
<span class="settings-muted-action">Test unavailable</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+53
-33
@@ -2,6 +2,7 @@
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import type {
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeConnectionTestResponse,
|
||||
RuntimeTrustKeyStatus,
|
||||
} from '#lib/generated/runtime-api.ts';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@
|
||||
RuntimeTrustRequestError,
|
||||
type RuntimeTrustRouteOperation,
|
||||
} from '#lib/workspace/api/runtime-management.ts';
|
||||
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||
@@ -25,18 +27,19 @@
|
||||
let showPublicKey = $state(false);
|
||||
let revealedPublicKey = $state<string | null>(null);
|
||||
let publicKey = $state('');
|
||||
let workspaceTrustId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let editingMetadata = $state(false);
|
||||
let deleteRuntimeConfirmation = $state('');
|
||||
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
||||
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | 'test' | null>(null);
|
||||
let fieldError = $state<string | null>(null);
|
||||
let deleteRuntimeError = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
let replacementFingerprint = $state<string | null>(null);
|
||||
let replacementFingerprintError = $state<string | null>(null);
|
||||
let connectionResult = $state<RuntimeConnectionTestResponse | null>(null);
|
||||
let connectionError = $state<string | null>(null);
|
||||
let fingerprintGeneration = 0;
|
||||
const routeFence = new RuntimeTrustRouteFence();
|
||||
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
|
||||
@@ -47,10 +50,11 @@
|
||||
if (nextGeneration === routeGeneration) return;
|
||||
routeGeneration = nextGeneration;
|
||||
fingerprintGeneration += 1;
|
||||
connectionResult = null;
|
||||
connectionError = null;
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
publicKey = '';
|
||||
workspaceTrustId = '';
|
||||
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||
editingMetadata = false;
|
||||
@@ -109,6 +113,28 @@
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
const bindingId = data.runtimeDetail?.runtime.management.binding?.binding_id;
|
||||
if (busyAction !== null || !bindingId) return;
|
||||
const operation = routeFence.capture(data.runtimeId);
|
||||
const workspaceId = data.workspaceId;
|
||||
busyAction = 'test';
|
||||
connectionResult = null;
|
||||
connectionError = null;
|
||||
try {
|
||||
const result = await testRuntimeConnection(workspaceId, operation.runtimeId);
|
||||
if (!isCurrentRoute(operation) || workspaceId !== data.workspaceId ||
|
||||
result.binding_id !== data.runtimeDetail?.runtime.management.binding?.binding_id) return;
|
||||
connectionResult = result;
|
||||
} catch (error) {
|
||||
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) {
|
||||
connectionError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAuthority(): Promise<void> {
|
||||
await invalidateAll();
|
||||
}
|
||||
@@ -195,7 +221,7 @@
|
||||
const binding = data.runtimeDetail.runtime.management.binding;
|
||||
if (!binding || !data.runtimeDetail.endpoint) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
'The Workspace identity binding and authoritative Runtime endpoint are required.',
|
||||
'The Runtime binding and authoritative endpoint are required.',
|
||||
);
|
||||
}
|
||||
await createRemoteRuntime(data.workspaceId, {
|
||||
@@ -203,14 +229,12 @@
|
||||
identity_id: operation.runtimeId,
|
||||
public_key: key,
|
||||
},
|
||||
workspace_trust_id: workspaceTrustId,
|
||||
display_name: data.runtimeDetail.runtime.label,
|
||||
endpoint: data.runtimeDetail.endpoint,
|
||||
expected_binding_id: binding.binding_id,
|
||||
});
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
publicKey = '';
|
||||
workspaceTrustId = '';
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
successMessage = action === 'create'
|
||||
@@ -396,8 +420,8 @@
|
||||
{@const detail = data.runtimeDetail}
|
||||
{@const runtime = detail.runtime}
|
||||
{@const trust = detail.trust_key}
|
||||
{@const verifiedTrust = runtime.management.binding?.state === 'verified'}
|
||||
{@const currentAction = trustAction(trust.status)}
|
||||
{@const latestResult = connectionResult?.workspace_id === data.workspaceId && connectionResult?.runtime_id === data.runtimeId && connectionResult?.binding_id === runtime.management.binding?.binding_id ? connectionResult : null}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
||||
@@ -405,17 +429,32 @@
|
||||
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||
<div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
|
||||
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
|
||||
<div><dt>Verified</dt><dd>{formatTimestamp(runtime.management.binding?.verification?.verified_at)}</dd></div>
|
||||
<div><dt>Last verification check</dt><dd>{runtime.management.binding?.verification?.last_outcome ?? '—'} · {formatTimestamp(runtime.management.binding?.verification?.last_checked_at)}</dd></div>
|
||||
<div><dt>Connection test</dt><dd>{runtime.management.binding?.revoked_at ? 'Revoked' : latestResult ? latestResult.status === 'compatible' ? 'Signed ping succeeded' : `Signed ping failed · ${latestResult.failure_kind}` : 'Not tested'}</dd></div>
|
||||
<div><dt>Binding ID</dt><dd><code>{runtime.management.binding?.binding_id ?? '—'}</code></dd></div>
|
||||
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
|
||||
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||
</dl>
|
||||
{#if runtime.management.config_managed && !runtime.management.binding?.revoked_at}
|
||||
<div class="settings-action-row">
|
||||
<button type="button" disabled={busyAction !== null} onclick={testConnection}>
|
||||
{busyAction === 'test' ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if latestResult}
|
||||
<p class="section-state" class:error={latestResult.status === 'failed'}>
|
||||
Checked {formatTimestamp(latestResult.checked_at)}
|
||||
{#each latestResult.diagnostics ?? [] as diagnostic}
|
||||
<span>{diagnostic.message}</span>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{#if connectionError}
|
||||
<p class="section-state error" role="alert">{connectionError}</p>
|
||||
{/if}
|
||||
{#if (runtime.diagnostics ?? []).length > 0}
|
||||
<ul class="settings-diagnostics-list">
|
||||
{#each runtime.diagnostics ?? [] as diagnostic}
|
||||
@@ -495,25 +534,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if verifiedTrust}
|
||||
<div class="runtime-public-key-readonly">
|
||||
<strong>Runtime public key</strong>
|
||||
<p>
|
||||
This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key.
|
||||
</p>
|
||||
<code>{trust.fingerprint}</code>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-workspace-trust-id-input">Workspace trust ID</label>
|
||||
<input
|
||||
id="runtime-workspace-trust-id-input"
|
||||
bind:value={workspaceTrustId}
|
||||
required
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p>Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code> for the current enrollment. Do not use a key fingerprint or a revoked enrollment ID.</p>
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-public-key-input">Runtime public key</label>
|
||||
<textarea
|
||||
id="runtime-public-key-input"
|
||||
@@ -548,8 +569,7 @@
|
||||
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<div class="runtime-revoke-row">
|
||||
<div>
|
||||
|
||||
+7
-21
@@ -10,7 +10,6 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import RuntimeSettings from "./+page.svelte";
|
||||
import RuntimeDetail from "./[runtimeId]/+page.svelte";
|
||||
import { identityFixture } from "#lib/workspace/settings/identity.test-fixtures.ts";
|
||||
|
||||
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn() }));
|
||||
vi.mock("$app/navigation", () => ({ invalidateAll: vi.fn(async () => {}) }));
|
||||
@@ -27,14 +26,15 @@ afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("Runtime registration requires and preserves the Runtime-issued enrollment ID independently of the fingerprint", async () => {
|
||||
test("Runtime registration uses public bundle without requiring copied Workspace enrollment identity", async () => {
|
||||
api.fingerprint.mockResolvedValue("sha256:runtime-public-key");
|
||||
api.create.mockResolvedValue({});
|
||||
const data = {
|
||||
workspaceId: "workspace-a",
|
||||
workspace: { permissions: { manage_runtimes: true } },
|
||||
runtimes: { items: [], diagnostics: [] },
|
||||
signingIdentity: identityFixture("workspace-a"),
|
||||
signingIdentity: null,
|
||||
signingIdentityError: "Workspace public identity could not be loaded",
|
||||
} as unknown as ComponentProps<typeof RuntimeSettings>["data"];
|
||||
render(RuntimeSettings, {
|
||||
props: { data, params: { workspaceId: "workspace-a" } },
|
||||
@@ -61,11 +61,7 @@ test("Runtime registration requires and preserves the Runtime-issued enrollment
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("sha256:runtime-public-key")).toBeTruthy()
|
||||
);
|
||||
expect(submit).toHaveProperty("disabled", true);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
||||
target: { value: "Runtime-issued-enrollment-A" },
|
||||
});
|
||||
expect(screen.queryByLabelText("Workspace trust ID")).toBeNull();
|
||||
expect(submit).toHaveProperty("disabled", false);
|
||||
await fireEvent.submit(submit.closest("form")!);
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledOnce());
|
||||
@@ -74,14 +70,13 @@ test("Runtime registration requires and preserves the Runtime-issued enrollment
|
||||
identity_id: "runtime-a",
|
||||
public_key: "runtime-public-key",
|
||||
},
|
||||
workspace_trust_id: "Runtime-issued-enrollment-A",
|
||||
display_name: null,
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("Runtime reactivation submits a new enrollment ID with the observed binding instead of reusing revoked trust", async () => {
|
||||
test("Runtime reactivation uses the observed binding without copied Workspace identity", async () => {
|
||||
api.fingerprint.mockResolvedValue("sha256:replacement-key");
|
||||
api.create.mockResolvedValue({});
|
||||
const data = {
|
||||
@@ -100,10 +95,8 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
||||
config_managed: true,
|
||||
removable: true,
|
||||
binding: {
|
||||
state: "revoked",
|
||||
connection_state: "revoked",
|
||||
revoked_at: "2026-10-10T00:00:00Z",
|
||||
binding_id: "observed-binding",
|
||||
workspace_trust_id: "revoked-enrollment",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -121,13 +114,7 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
||||
params: { workspaceId: "workspace-a", runtimeId: "runtime-a" },
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("Workspace trust ID")).toHaveProperty(
|
||||
"value",
|
||||
"",
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
||||
target: { value: "new-enrollment" },
|
||||
});
|
||||
expect(screen.queryByLabelText("Workspace trust ID")).toBeNull();
|
||||
await fireEvent.input(screen.getByLabelText("Runtime public key"), {
|
||||
target: { value: "yoi-ed25519-pub:v1:new-key" },
|
||||
});
|
||||
@@ -145,7 +132,6 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
||||
identity_id: "runtime-a",
|
||||
public_key: "yoi-ed25519-pub:v1:new-key",
|
||||
},
|
||||
workspace_trust_id: "new-enrollment",
|
||||
display_name: "Runtime A",
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: "observed-binding",
|
||||
|
||||
@@ -54,7 +54,8 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
|
||||
endpoint_configured: true,
|
||||
endpoint_display: "https://arcadia.example",
|
||||
binding: {
|
||||
state: "verified",
|
||||
binding_id: "observed-binding",
|
||||
revoked_at: null,
|
||||
},
|
||||
},
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
@@ -68,7 +69,10 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
|
||||
runtime_id: "revoked",
|
||||
management: {
|
||||
...configured.management,
|
||||
binding: { state: "revoked" },
|
||||
binding: {
|
||||
binding_id: "revoked-binding",
|
||||
revoked_at: "2026-10-10T00:00:00Z",
|
||||
},
|
||||
},
|
||||
} as unknown as WorkspaceRuntimeResource;
|
||||
const unbound = {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
|
||||
// Production static build plus loopback fixtures only; no live accounts or keys.
|
||||
// --before retains the superseded wire shape solely for before/after visual evidence.
|
||||
Deno.test("Runtime settings keep list, editing and testing accessible after signed-ping authentication failure", async () => {
|
||||
const before = Deno.args.includes("--before");
|
||||
const root = new URL("../../../../", import.meta.url).pathname;
|
||||
const output = new URL(
|
||||
`../../.svelte-kit/runtime-corrective/${before ? "before" : "after"}/`,
|
||||
import.meta.url,
|
||||
).pathname;
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
`${root}tools/web-ux/browser-tests/workspace_home_fixture_server.ts`,
|
||||
String(port),
|
||||
`${root}web/workspace/build`,
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).spawn();
|
||||
try {
|
||||
// Wait for an observable ready response, bounded by timeout.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let stopped = false;
|
||||
const deadline = setTimeout(() => {
|
||||
stopped = true;
|
||||
reject(new Error("fixture readiness timed out"));
|
||||
}, 10000);
|
||||
const probe = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const response = await fetch(`${base}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) {
|
||||
clearTimeout(deadline);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
} catch { /* owned server is starting */ }
|
||||
setTimeout(probe, 25);
|
||||
};
|
||||
void probe();
|
||||
});
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
for (const owner of [true, false]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
let documentStatus: number | null = null;
|
||||
const navigate = async (url: string) => {
|
||||
documentStatus = (await page.goto(url))?.status() ?? null;
|
||||
assertEquals(documentStatus, 200);
|
||||
};
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
errors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("requestfailed", (request) => errors.push(request.url()));
|
||||
const workspace = owner ? "home-owner" : "home-member";
|
||||
const binding = before
|
||||
? {
|
||||
binding_id: "binding-observed",
|
||||
state: "configured",
|
||||
connection_state: "unavailable",
|
||||
workspace_key_id: "workspace-key",
|
||||
workspace_trust_id: "enrollment",
|
||||
}
|
||||
: { binding_id: "binding-observed", revoked_at: null };
|
||||
const runtime = {
|
||||
runtime_id: "fixture-runtime",
|
||||
label: "Development Runtime",
|
||||
kind: "remote",
|
||||
status: "unavailable",
|
||||
source: {
|
||||
kind: "remote_http",
|
||||
status: "active",
|
||||
identity_authority: "server_runtime_configuration",
|
||||
note: "Fixture",
|
||||
},
|
||||
host_ids: [],
|
||||
worker_creation_available: false,
|
||||
os: "linux",
|
||||
arch: "x86_64",
|
||||
diagnostics: [{
|
||||
code: "runtime_authentication_failed",
|
||||
severity: "error",
|
||||
message:
|
||||
"The signed Runtime request was not authenticated. Update the endpoint or key and retry.",
|
||||
}],
|
||||
management: {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: true,
|
||||
endpoint_configured: true,
|
||||
token_ref_configured: false,
|
||||
binding,
|
||||
},
|
||||
};
|
||||
await page.route(
|
||||
"**/api/w/*/runtimes",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
workspace_id: workspace,
|
||||
limit: 200,
|
||||
source: "fixture",
|
||||
items: [runtime],
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
"**/api/w/*/runtimes/fixture-runtime",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
workspace_id: workspace,
|
||||
runtime,
|
||||
endpoint: "https://runtime.example.test",
|
||||
trust_key: {
|
||||
status: "active",
|
||||
binding_id: "binding-observed",
|
||||
fingerprint: "sha256:runtime-public-key",
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
updated_at: "2026-10-01T00:00:00Z",
|
||||
revoked_at: null,
|
||||
},
|
||||
recent_audit: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
"**/api/w/*/runtimes/fixture-runtime/connection-tests",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
workspace_id: workspace,
|
||||
runtime_id: "fixture-runtime",
|
||||
binding_id: "binding-observed",
|
||||
...(before
|
||||
? {
|
||||
connection_state: "unavailable",
|
||||
verification: null,
|
||||
}
|
||||
: {}),
|
||||
checked_at: "2026-10-10T00:00:00Z",
|
||||
status: "failed",
|
||||
failure_kind: "authentication",
|
||||
expected_protocol_version: 1,
|
||||
actual_protocol_version: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const prefix = `${colorScheme}-${width}-${
|
||||
owner ? "owner" : "member"
|
||||
}`;
|
||||
const capture = async (name: string) => {
|
||||
await page.screenshot({
|
||||
path: `${output}${prefix}-${name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
evidence.push({
|
||||
colorScheme,
|
||||
width,
|
||||
owner,
|
||||
name,
|
||||
url: page.url(),
|
||||
documentStatus,
|
||||
screenshot: `${prefix}-${name}.png`,
|
||||
errors: [...errors],
|
||||
accessibility: await page.locator("main").ariaSnapshot(),
|
||||
});
|
||||
assertEquals(
|
||||
await page.evaluate(
|
||||
"document.documentElement.scrollWidth > innerWidth",
|
||||
),
|
||||
false,
|
||||
prefix,
|
||||
);
|
||||
};
|
||||
await navigate(`${base}/w/${workspace}/settings/runtimes`);
|
||||
await page.getByRole("link", {
|
||||
name: "Development Runtime",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("button", { name: "Test", exact: true })
|
||||
.click();
|
||||
await page.getByText("Connection test: Authentication failed", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await capture("list-failed");
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Test", exact: true })
|
||||
.isEnabled(),
|
||||
true,
|
||||
);
|
||||
if (owner) {
|
||||
await page.getByRole("button", {
|
||||
name: "Add Runtime",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByLabel("Runtime public bundle", { exact: true })
|
||||
.waitFor();
|
||||
await capture("registration");
|
||||
}
|
||||
await navigate(
|
||||
`${base}/w/${workspace}/settings/runtimes/fixture-runtime`,
|
||||
);
|
||||
await page.getByRole("heading", {
|
||||
name: "Identity and binding",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await capture("detail");
|
||||
if (!before) {
|
||||
await page.getByRole("button", { name: "Test", exact: true })
|
||||
.click();
|
||||
await page.getByText("Signed ping failed · authentication", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await capture("detail-failed");
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Test", exact: true })
|
||||
.isEnabled(),
|
||||
);
|
||||
}
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Edit Runtime",
|
||||
exact: true,
|
||||
}).count(),
|
||||
owner ? 1 : 0,
|
||||
);
|
||||
if (owner) {
|
||||
await page.getByRole("button", {
|
||||
name: "Edit Runtime",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByLabel("Endpoint", { exact: true }).waitFor();
|
||||
await capture("edit");
|
||||
assert(
|
||||
await page.getByRole("button", {
|
||||
name: "Save Runtime settings",
|
||||
exact: true,
|
||||
}).isEnabled(),
|
||||
);
|
||||
if (!before) {
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace trust ID", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!before) {
|
||||
Object.assign(binding, { revoked_at: "2026-10-10T00:00:00Z" });
|
||||
await page.goto(`${base}/w/${workspace}/settings/runtimes`);
|
||||
await page.getByRole("cell", { name: "Revoked", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Test", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await capture("revoked");
|
||||
}
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
`${output}review-context.json`,
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
});
|
||||
@@ -20,8 +20,6 @@ function compatibleResponse(): Record<string, unknown> {
|
||||
workspace_id: "workspace-a",
|
||||
runtime_id: "runtime-a",
|
||||
binding_id: "binding-3",
|
||||
connection_state: "verified",
|
||||
verification: null,
|
||||
checked_at: "2026-09-01T12:00:00Z",
|
||||
status: "compatible",
|
||||
failure_kind: null,
|
||||
@@ -63,16 +61,7 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...compatibleResponse(),
|
||||
verification: {
|
||||
verified_at: "2026-09-01T12:00:00Z",
|
||||
last_checked_at: "2026-09-01T12:00:01Z",
|
||||
last_outcome: "verified",
|
||||
binding_id: "binding-2",
|
||||
workspace_key_id: "WK-a",
|
||||
workspace_public_key_fingerprint: "sha256:workspace",
|
||||
workspace_trust_id: "trust-a",
|
||||
runtime_public_key_fingerprint: "sha256:runtime",
|
||||
},
|
||||
verification: {},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
@@ -120,3 +109,26 @@ Deno.test("runtime connection request rejects a mismatched response identity", a
|
||||
"Connection test response did not match the selected Runtime",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime authentication failure remains an observed result without granting approval", async () => {
|
||||
const failed = {
|
||||
...compatibleResponse(),
|
||||
status: "failed",
|
||||
failure_kind: "authentication",
|
||||
actual_protocol_version: null,
|
||||
};
|
||||
assertEquals(parseRuntimeConnectionTestResponse(failed), failed);
|
||||
const fetched = await testRuntimeConnection(
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
(() => Promise.resolve(Response.json(failed))) as typeof fetch,
|
||||
);
|
||||
assertEquals(fetched, failed);
|
||||
assertEquals(
|
||||
parseRuntimeConnectionTestResponse({
|
||||
...failed,
|
||||
connection_state: "verified",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ Deno.test("Runtime registration presents the complete multi-Workspace trust sequ
|
||||
"yoi-runtime identity show --json",
|
||||
"3. Register the connection",
|
||||
"Register Runtime",
|
||||
"Run Test to complete authenticated verification.",
|
||||
"Run Test to observe a signed ping.",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
@@ -129,16 +129,14 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
|
||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||
const reveal = page.indexOf("Reveal public key");
|
||||
const verifiedKey = page.indexOf("{#if verifiedTrust}");
|
||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
|
||||
const trustStart = page.indexOf("async function saveTrustKey");
|
||||
const metadataMutation = page.slice(metadataStart, trustStart);
|
||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||
assert(
|
||||
verifiedKey >= 0 && verifiedKey < mutation &&
|
||||
page.slice(verifiedKey, mutation).includes("{:else}"),
|
||||
"verified Runtime public key must be read-only while unverified trust keeps key input",
|
||||
!page.includes("verifiedTrust") && !page.includes("workspace_trust_id"),
|
||||
"cached verification and copied enrollment identity must not gate key editing",
|
||||
);
|
||||
assert(
|
||||
metadataMutation.includes("updateRemoteRuntime(") &&
|
||||
@@ -150,7 +148,7 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
!metadataMutation.includes("publicKey") &&
|
||||
!metadataMutation.includes("public_bundle") &&
|
||||
!metadataMutation.includes("public_key"),
|
||||
"verified Runtime metadata updates must not carry public key authority",
|
||||
"Runtime metadata updates must not carry public key authority",
|
||||
);
|
||||
assert(
|
||||
page.includes("Current fingerprint"),
|
||||
@@ -195,7 +193,6 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"saveRuntimeMetadata",
|
||||
"Runtime settings",
|
||||
"Edit Runtime",
|
||||
"This verified key is read-only",
|
||||
"publicKey = ''",
|
||||
"requestError = null",
|
||||
"successMessage = null",
|
||||
@@ -246,3 +243,33 @@ Deno.test("Runtime detail uses flat sections instead of nested cards", async ()
|
||||
"Runtime detail hierarchy should use flat section separators",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime labels and recovery controls do not depend on persisted authentication approval", async () => {
|
||||
for (const name of ["+page.svelte", "[runtimeId]/+page.svelte"]) {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/" + name,
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
for (
|
||||
const field of [
|
||||
"connection_state",
|
||||
"verification",
|
||||
"workspace_key_id",
|
||||
"workspace_trust_id",
|
||||
"Verification required",
|
||||
]
|
||||
) {
|
||||
assert(!page.includes(field), "Runtime page retained " + field);
|
||||
}
|
||||
assert(
|
||||
page.includes("revoked_at"),
|
||||
"explicit revocation must remain visible",
|
||||
);
|
||||
assert(
|
||||
page.includes("testRuntimeConnection("),
|
||||
"actual test must remain available",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import type { RepositoryApiError } from "../src/lib/generated/repository-api.ts";
|
||||
import type { RuntimeManagementApiError } from "../src/lib/generated/runtime-api.ts";
|
||||
import {
|
||||
createRemoteRuntime,
|
||||
parseRuntimeRemovalOperationResponse,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeRemovalAttempt,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRequestError,
|
||||
RuntimeTrustRouteFence,
|
||||
updateRemoteRuntime,
|
||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||
@@ -44,21 +47,8 @@ function runtime() {
|
||||
endpoint_configured: true,
|
||||
token_ref_configured: false,
|
||||
binding: {
|
||||
state: "verified",
|
||||
connection_state: "verified",
|
||||
binding_id: "binding-3",
|
||||
workspace_key_id: "WK-1",
|
||||
workspace_trust_id: "trust-1",
|
||||
verification: {
|
||||
verified_at: "2026-09-01T13:00:00Z",
|
||||
last_checked_at: "2026-09-01T13:00:00Z",
|
||||
last_outcome: "verified",
|
||||
binding_id: "binding-3",
|
||||
workspace_key_id: "WK-1",
|
||||
workspace_public_key_fingerprint: "sha256:workspace-key",
|
||||
workspace_trust_id: "trust-1",
|
||||
runtime_public_key_fingerprint: "SHA256:current",
|
||||
},
|
||||
revoked_at: null,
|
||||
},
|
||||
},
|
||||
runtime_id: "arcadia",
|
||||
@@ -117,8 +107,8 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
|
||||
);
|
||||
|
||||
assert(
|
||||
list.items[0]?.management.binding?.state === "verified",
|
||||
"binding state was not preserved",
|
||||
list.items[0]?.management.binding?.binding_id === "binding-3",
|
||||
"binding identity was not preserved",
|
||||
);
|
||||
|
||||
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||
@@ -142,9 +132,7 @@ Deno.test("Runtime list parser accepts the built-in Runtime's internal binding",
|
||||
const binding = embedded.management.binding as Partial<
|
||||
typeof embedded.management.binding
|
||||
>;
|
||||
delete binding.workspace_key_id;
|
||||
delete binding.workspace_trust_id;
|
||||
delete binding.verification;
|
||||
delete binding.revoked_at;
|
||||
|
||||
const list = parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
@@ -154,23 +142,51 @@ Deno.test("Runtime list parser accepts the built-in Runtime's internal binding",
|
||||
diagnostics: [],
|
||||
});
|
||||
assert(
|
||||
list.items[0]?.management.binding?.connection_state === "verified",
|
||||
list.items[0]?.management.binding?.revoked_at === null,
|
||||
"built-in Runtime binding was not preserved",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
|
||||
const payload = detail();
|
||||
const binding = payload.runtime.management.binding as Partial<
|
||||
typeof payload.runtime.management.binding
|
||||
Deno.test("Runtime list accepts migrated remote bindings without Workspace identity metadata", () => {
|
||||
const migrated = runtime();
|
||||
const binding = migrated.management.binding as Partial<
|
||||
typeof migrated.management.binding
|
||||
>;
|
||||
delete binding.workspace_key_id;
|
||||
delete binding.workspace_trust_id;
|
||||
binding.state = "configured";
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(payload),
|
||||
"requires Workspace signing key identity metadata",
|
||||
delete binding.revoked_at;
|
||||
const list = parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: [migrated],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
});
|
||||
assert(
|
||||
list.items[0]?.management.binding?.binding_id === "binding-3",
|
||||
"migrated binding was rejected",
|
||||
);
|
||||
assert(
|
||||
list.items[0]?.management.binding?.revoked_at === null,
|
||||
"missing revocation should remain unrevoked",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime binding rejects cached approval and copied Workspace identity fields", () => {
|
||||
for (
|
||||
const field of [
|
||||
"state",
|
||||
"connection_state",
|
||||
"verification",
|
||||
"workspace_key_id",
|
||||
"workspace_trust_id",
|
||||
]
|
||||
) {
|
||||
const payload = detail();
|
||||
Object.assign(payload.runtime.management.binding, { [field]: "obsolete" });
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(payload),
|
||||
"not part of the wire contract",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||
@@ -450,82 +466,225 @@ Deno.test("Runtime public key preview matches the Server fingerprint contract",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime registration rejects empty or oversized enrollment identities before sending", async () => {
|
||||
for (const enrollment of ["", "x".repeat(257)]) {
|
||||
Deno.test("Runtime registration sends only Runtime connection data without Workspace identity copies", async () => {
|
||||
const request = {
|
||||
public_bundle: { identity_id: "arcadia", public_key: "test-key" },
|
||||
endpoint: "https://runtime.example",
|
||||
};
|
||||
let body: unknown;
|
||||
await createRemoteRuntime(
|
||||
"workspace-a",
|
||||
request,
|
||||
((_input: string | URL | Request, init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
return Promise.resolve(Response.json(runtime()));
|
||||
}) as typeof fetch,
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(body) === JSON.stringify(request),
|
||||
"registration copied Workspace identity or approval fields",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime trust replacement recognizes RepositoryApiError conflicts without retrying", async () => {
|
||||
for (const errorKind of ["stale_binding", "fingerprint_in_use"] as const) {
|
||||
for (
|
||||
const diagnostics of [undefined, [], [{
|
||||
code: errorKind,
|
||||
severity: "error" as const,
|
||||
message: "Reload Workspace trust before retrying.",
|
||||
}]]
|
||||
) {
|
||||
const request = {
|
||||
public_bundle: {
|
||||
identity_id: "runtime-a",
|
||||
public_key: "yoi-ed25519-pub:v1:test",
|
||||
},
|
||||
display_name: null,
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: "observed-binding-id",
|
||||
};
|
||||
const conflict: RepositoryApiError = {
|
||||
error: errorKind,
|
||||
message: "Reload the authoritative Runtime trust.",
|
||||
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||
};
|
||||
let calls = 0;
|
||||
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls++;
|
||||
assert(
|
||||
String(input) === "/api/w/workspace-a/runtimes",
|
||||
"wrong trust route",
|
||||
);
|
||||
assert(
|
||||
init?.body === JSON.stringify(request),
|
||||
"observed binding ID changed",
|
||||
);
|
||||
return Promise.resolve(Response.json(conflict, { status: 409 }));
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await createRemoteRuntime("workspace-a", request, fetchImpl);
|
||||
throw new Error("expected trust replacement to reject");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof RuntimeTrustConflictError,
|
||||
`binding conflict was hidden: ${String(error)}`,
|
||||
);
|
||||
assert(
|
||||
error.message === conflict.message &&
|
||||
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
||||
"RepositoryApiError conflict was changed",
|
||||
);
|
||||
}
|
||||
assert(calls === 1, "trust replacement was retried");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime revoke preserves typed conflict binding identities and diagnostics", async () => {
|
||||
const conflict: RuntimeManagementApiError = {
|
||||
error: "stale_binding",
|
||||
message: "Reload the authoritative Runtime trust.",
|
||||
current_binding_id: "current-binding-id",
|
||||
current_fingerprint: "sha256:current-key",
|
||||
diagnostics: [],
|
||||
};
|
||||
try {
|
||||
await revokeRuntimeTrustKey(
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
{ expected_binding_id: "observed-binding-id" },
|
||||
"sha256:observed-key",
|
||||
"sha256:observed-key",
|
||||
() => Promise.resolve(Response.json(conflict, { status: 409 })),
|
||||
);
|
||||
throw new Error("expected revoke to reject");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof RuntimeTrustConflictError,
|
||||
"revoke conflict was hidden",
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
||||
"typed revoke conflict identity was changed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime create surfaces other RepositoryApiError 409 messages as request errors", async () => {
|
||||
for (
|
||||
const diagnostics of [undefined, [], [{
|
||||
code: "runtime_binding_replacement_blocked",
|
||||
severity: "error" as const,
|
||||
message: "An active Worker occupies this Runtime.",
|
||||
}]]
|
||||
) {
|
||||
const payload: RepositoryApiError = {
|
||||
error: "conflict",
|
||||
message: "Runtime binding replacement is blocked by an active Worker.",
|
||||
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||
};
|
||||
let calls = 0;
|
||||
try {
|
||||
await createRemoteRuntime("workspace-a", {
|
||||
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||
workspace_trust_id: enrollment,
|
||||
endpoint: "https://runtime.example",
|
||||
}, (() => { calls++; throw new Error("unexpected request"); }) as typeof fetch);
|
||||
throw new Error("expected enrollment validation to reject");
|
||||
}, () => {
|
||||
calls++;
|
||||
return Promise.resolve(Response.json(payload, { status: 409 }));
|
||||
});
|
||||
throw new Error("expected occupancy conflict to reject");
|
||||
} catch (error) {
|
||||
assert(error instanceof Error && error.message.includes("workspace_trust_id"), "enrollment validation was bypassed");
|
||||
}
|
||||
assert(calls === 0, "invalid enrollment reached the Server");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime trust replacement preserves binding conflicts without retrying", async () => {
|
||||
for (const errorKind of ["stale_binding", "fingerprint_in_use"] as const) {
|
||||
const request = {
|
||||
public_bundle: {
|
||||
identity_id: "runtime-a",
|
||||
public_key: "yoi-ed25519-pub:v1:test",
|
||||
},
|
||||
display_name: null,
|
||||
endpoint: "https://runtime.example",
|
||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
||||
expected_binding_id: "observed-binding-id",
|
||||
};
|
||||
const conflict = {
|
||||
error: errorKind,
|
||||
message: "Reload the authoritative Runtime trust.",
|
||||
current_binding_id: "current-binding-id",
|
||||
current_fingerprint: "sha256:current-key",
|
||||
};
|
||||
let calls = 0;
|
||||
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls++;
|
||||
assert(String(input) === "/api/w/workspace-a/runtimes", "wrong trust route");
|
||||
assert(init?.body === JSON.stringify(request), "observed binding ID changed");
|
||||
return Promise.resolve(Response.json(conflict, { status: 409 }));
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await createRemoteRuntime("workspace-a", request, fetchImpl);
|
||||
throw new Error("expected trust replacement to reject");
|
||||
} catch (error) {
|
||||
assert(error instanceof RuntimeTrustConflictError, "binding conflict was hidden");
|
||||
assert(
|
||||
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
||||
"authoritative conflict identity was changed",
|
||||
error instanceof RuntimeTrustRequestError,
|
||||
"occupancy error was misclassified",
|
||||
);
|
||||
assert(error.message === payload.message, "occupancy message was hidden");
|
||||
}
|
||||
assert(calls === 1, "trust replacement was retried");
|
||||
assert(calls === 1, "occupancy conflict was retried");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime trust replacement rejects unknown fields and missing binding identity in conflict responses", async () => {
|
||||
const fetchImpl = (() => Promise.resolve(Response.json({
|
||||
error: "stale_binding",
|
||||
message: "Stale trust",
|
||||
unexpected_field: true,
|
||||
}, { status: 409 }))) as typeof fetch;
|
||||
Deno.test("Runtime create bounds legitimate RepositoryApiError 409 messages", async () => {
|
||||
const message = "Runtime is occupied. ".repeat(30);
|
||||
try {
|
||||
await createRemoteRuntime("workspace-a", {
|
||||
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||
endpoint: "https://runtime.example",
|
||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
||||
expected_binding_id: "observed-binding-id",
|
||||
}, fetchImpl);
|
||||
throw new Error("expected malformed conflict to reject");
|
||||
}, () =>
|
||||
Promise.resolve(Response.json(
|
||||
{
|
||||
error: "conflict",
|
||||
message,
|
||||
} satisfies RepositoryApiError,
|
||||
{ status: 409 },
|
||||
)));
|
||||
throw new Error("expected occupancy conflict to reject");
|
||||
} catch (error) {
|
||||
assert(!(error instanceof RuntimeTrustConflictError), "legacy conflict was accepted");
|
||||
assert(
|
||||
error instanceof Error && error.message === "Runtime trust conflict response was invalid",
|
||||
"malformed conflict did not fail closed",
|
||||
error instanceof RuntimeTrustRequestError,
|
||||
"occupancy error was misclassified",
|
||||
);
|
||||
assert(
|
||||
error.message === message.slice(0, 256),
|
||||
"bounded backend message was lost",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime create rejects malformed RepositoryApiError conflicts without exposing their message", async () => {
|
||||
const validDiagnostic = {
|
||||
code: "conflict",
|
||||
severity: "error",
|
||||
message: "detail",
|
||||
};
|
||||
for (const errorKind of ["stale_binding", "fingerprint_in_use", "conflict"]) {
|
||||
const valid = {
|
||||
error: errorKind,
|
||||
message: "Do not expose malformed payload text",
|
||||
};
|
||||
for (
|
||||
const payload of [
|
||||
{ ...valid, unexpected_field: true },
|
||||
{ ...valid, current_binding_id: "not-in-creation-contract" },
|
||||
{ ...valid, current_fingerprint: "not-in-creation-contract" },
|
||||
{ ...valid, error: 409 },
|
||||
{ ...valid, error: "" },
|
||||
{ ...valid, error: "x".repeat(257) },
|
||||
{ error: errorKind },
|
||||
{ ...valid, message: null },
|
||||
{ ...valid, message: "" },
|
||||
{ ...valid, message: "é".repeat(513) },
|
||||
{ ...valid, diagnostics: null },
|
||||
{ ...valid, diagnostics: {} },
|
||||
{ ...valid, diagnostics: Array(65).fill(validDiagnostic) },
|
||||
{
|
||||
...valid,
|
||||
diagnostics: [{ ...validDiagnostic, severity: "unknown" }],
|
||||
},
|
||||
{ ...valid, diagnostics: [{ ...validDiagnostic, code: 1 }] },
|
||||
{ ...valid, diagnostics: [{ ...validDiagnostic, extra: true }] },
|
||||
{
|
||||
...valid,
|
||||
diagnostics: [{ ...validDiagnostic, message: "x".repeat(2049) }],
|
||||
},
|
||||
]
|
||||
) {
|
||||
try {
|
||||
await createRemoteRuntime("workspace-a", {
|
||||
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||
endpoint: "https://runtime.example",
|
||||
expected_binding_id: "observed-binding-id",
|
||||
}, () => Promise.resolve(Response.json(payload, { status: 409 })));
|
||||
throw new Error("expected malformed conflict to reject");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof RuntimeTrustRequestError &&
|
||||
error.message === "Runtime trust conflict response was invalid",
|
||||
`malformed conflict did not fail closed: ${JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -551,7 +710,6 @@ Deno.test("Runtime create surfaces bounded Settings error details", async () =>
|
||||
},
|
||||
display_name: null,
|
||||
endpoint: "https://runtime.example",
|
||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
||||
expected_binding_id: null,
|
||||
},
|
||||
fetchImpl,
|
||||
|
||||
Reference in New Issue
Block a user