feat: project Runtime verification state
This commit is contained in:
@@ -1597,11 +1597,51 @@ pub enum WorkspaceRuntimeAuthenticationMode {
|
|||||||
WorkspaceIdentity,
|
WorkspaceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[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)]
|
||||||
|
#[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)]
|
||||||
|
#[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,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub binding_revision: u64,
|
||||||
|
pub workspace_key_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub workspace_identity_revision: u64,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub workspace_trust_generation: u64,
|
||||||
|
pub runtime_public_key_fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub runtime_identity_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceRuntimeBindingSummary {
|
pub struct WorkspaceRuntimeBindingSummary {
|
||||||
pub state: WorkspaceRuntimeBindingState,
|
pub state: WorkspaceRuntimeBindingState,
|
||||||
|
pub connection_state: RuntimeConnectionDisplayState,
|
||||||
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
|
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
@@ -1610,6 +1650,8 @@ pub struct WorkspaceRuntimeBindingSummary {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
pub workspace_key_generation: Option<u64>,
|
pub workspace_key_generation: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1794,6 +1836,10 @@ pub enum RuntimeConnectionTestFailureKind {
|
|||||||
pub struct RuntimeConnectionTestResponse {
|
pub struct RuntimeConnectionTestResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub binding_revision: u64,
|
||||||
|
pub connection_state: RuntimeConnectionDisplayState,
|
||||||
|
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||||
pub checked_at: String,
|
pub checked_at: String,
|
||||||
pub status: RuntimeConnectionTestStatus,
|
pub status: RuntimeConnectionTestStatus,
|
||||||
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
||||||
@@ -2977,6 +3023,9 @@ pub fn catalog_typescript() -> String {
|
|||||||
RuntimeSourceSummary::decl(&config),
|
RuntimeSourceSummary::decl(&config),
|
||||||
RuntimeSummary::decl(&config),
|
RuntimeSummary::decl(&config),
|
||||||
WorkspaceRuntimeBindingState::decl(&config),
|
WorkspaceRuntimeBindingState::decl(&config),
|
||||||
|
RuntimeConnectionDisplayState::decl(&config),
|
||||||
|
RuntimeVerificationOutcome::decl(&config),
|
||||||
|
RuntimeVerificationEvidenceSummary::decl(&config),
|
||||||
WorkspaceRuntimeAuthenticationMode::decl(&config),
|
WorkspaceRuntimeAuthenticationMode::decl(&config),
|
||||||
WorkspaceRuntimeBindingSummary::decl(&config),
|
WorkspaceRuntimeBindingSummary::decl(&config),
|
||||||
RuntimeManagementSummary::decl(&config),
|
RuntimeManagementSummary::decl(&config),
|
||||||
@@ -3842,6 +3891,9 @@ mod tests {
|
|||||||
let compatible = serde_json::json!({
|
let compatible = serde_json::json!({
|
||||||
"workspace_id": "workspace-test",
|
"workspace_id": "workspace-test",
|
||||||
"runtime_id": "runtime-test",
|
"runtime_id": "runtime-test",
|
||||||
|
"binding_revision": 3,
|
||||||
|
"connection_state": "verified",
|
||||||
|
"verification": null,
|
||||||
"checked_at": "2026-09-01T12:00:00Z",
|
"checked_at": "2026-09-01T12:00:00Z",
|
||||||
"status": "compatible",
|
"status": "compatible",
|
||||||
"failure_kind": null,
|
"failure_kind": null,
|
||||||
|
|||||||
@@ -85,13 +85,13 @@ use workspace_api::{
|
|||||||
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
||||||
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
||||||
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
||||||
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse,
|
||||||
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction,
|
||||||
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
|
RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse,
|
||||||
RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus,
|
||||||
TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption,
|
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||||
WorkerLaunchWorkerSummary,
|
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
@@ -12054,16 +12054,24 @@ async fn scoped_delete_remote_runtime(
|
|||||||
async fn scoped_test_runtime_connection(
|
async fn scoped_test_runtime_connection(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
require_workspace_owner(
|
||||||
|
&api,
|
||||||
|
&path.workspace_id,
|
||||||
|
&actor,
|
||||||
|
"Runtime connection verification",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let binding = api
|
let binding = api
|
||||||
.store
|
.store
|
||||||
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?;
|
.ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?;
|
||||||
if binding.state != StoredRuntimeBindingState::Verified {
|
if binding.state == StoredRuntimeBindingState::Revoked || binding.revoked_at.is_some() {
|
||||||
return Err(Error::RuntimeBindingConflict(format!(
|
return Err(Error::RuntimeBindingConflict(format!(
|
||||||
"Runtime `{}` is not an authenticated verified connection candidate",
|
"Runtime `{}` binding is revoked",
|
||||||
path.runtime_id
|
path.runtime_id
|
||||||
))
|
))
|
||||||
.into());
|
.into());
|
||||||
@@ -13930,16 +13938,18 @@ async fn test_runtime_connection(
|
|||||||
.get_workspace_runtime_verification(api.workspace_id(), &runtime_id)
|
.get_workspace_runtime_verification(api.workspace_id(), &runtime_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
evidence.state = "failed".to_string();
|
if evidence.state != "verified" {
|
||||||
|
evidence.state = "failed".to_string();
|
||||||
|
evidence.verified_at = None;
|
||||||
|
}
|
||||||
evidence.last_outcome = "verification_failed".to_string();
|
evidence.last_outcome = "verification_failed".to_string();
|
||||||
evidence.verified_at = None;
|
|
||||||
evidence.checked_at = Utc::now().to_rfc3339();
|
evidence.checked_at = Utc::now().to_rfc3339();
|
||||||
let _ = api
|
let _ = api
|
||||||
.store
|
.store
|
||||||
.record_workspace_runtime_verification_attempt(&evidence)
|
.record_workspace_runtime_verification_attempt(&evidence)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
return Ok(Json(runtime_connection_test_failure(
|
let mut result = runtime_connection_test_failure(
|
||||||
api.workspace_id(),
|
api.workspace_id(),
|
||||||
&runtime_id,
|
&runtime_id,
|
||||||
Utc::now().to_rfc3339(),
|
Utc::now().to_rfc3339(),
|
||||||
@@ -13950,7 +13960,10 @@ async fn test_runtime_connection(
|
|||||||
"error",
|
"error",
|
||||||
message,
|
message,
|
||||||
),
|
),
|
||||||
)));
|
);
|
||||||
|
result.binding_revision = binding.binding_revision;
|
||||||
|
result.connection_state = RuntimeConnectionDisplayState::Unavailable;
|
||||||
|
return Ok(Json(result));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13966,12 +13979,41 @@ async fn test_runtime_connection(
|
|||||||
message: "Runtime connection test could not be completed".to_string(),
|
message: "Runtime connection test could not be completed".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(runtime_connection_test_response(
|
if ping.is_err()
|
||||||
api.workspace_id(),
|
&& binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity
|
||||||
&runtime_id,
|
&& let Some(mut evidence) = api
|
||||||
checked_at,
|
.store
|
||||||
ping,
|
.get_workspace_runtime_verification(api.workspace_id(), &runtime_id)
|
||||||
)))
|
.await?
|
||||||
|
{
|
||||||
|
evidence.last_outcome = "connectivity_failed".to_string();
|
||||||
|
evidence.checked_at = Utc::now().to_rfc3339();
|
||||||
|
api.store
|
||||||
|
.record_workspace_runtime_verification_attempt(&evidence)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let current_binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(api.workspace_id(), &runtime_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||||
|
let verification = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_verification(api.workspace_id(), &runtime_id)
|
||||||
|
.await?;
|
||||||
|
let summary = runtime_binding_summary(¤t_binding, verification.as_ref());
|
||||||
|
let mut result =
|
||||||
|
runtime_connection_test_response(api.workspace_id(), &runtime_id, checked_at, ping);
|
||||||
|
result.binding_revision = current_binding.binding_revision;
|
||||||
|
result.connection_state = if result.status == RuntimeConnectionTestStatus::Compatible {
|
||||||
|
summary.connection_state
|
||||||
|
} else if summary.connection_state == RuntimeConnectionDisplayState::Revoked {
|
||||||
|
RuntimeConnectionDisplayState::Revoked
|
||||||
|
} else {
|
||||||
|
RuntimeConnectionDisplayState::Unavailable
|
||||||
|
};
|
||||||
|
result.verification = summary.verification;
|
||||||
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_worker_launch_options(
|
async fn get_worker_launch_options(
|
||||||
@@ -16070,6 +16112,16 @@ async fn workspace_runtime_resources_response(
|
|||||||
.store
|
.store
|
||||||
.list_workspace_runtime_bindings(workspace_id, true)
|
.list_workspace_runtime_bindings(workspace_id, true)
|
||||||
.await?;
|
.await?;
|
||||||
|
let mut verifications = HashMap::new();
|
||||||
|
for binding in &bindings {
|
||||||
|
if let Some(verification) = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_verification(workspace_id, &binding.runtime_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
verifications.insert(binding.runtime_id.clone(), verification);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut items = runtimes
|
let mut items = runtimes
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -16091,7 +16143,9 @@ async fn workspace_runtime_resources_response(
|
|||||||
endpoint_configured: binding
|
endpoint_configured: binding
|
||||||
.is_some_and(|binding| !binding.base_url.trim().is_empty()),
|
.is_some_and(|binding| !binding.base_url.trim().is_empty()),
|
||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
binding: binding.map(runtime_binding_summary),
|
binding: binding.map(|binding| {
|
||||||
|
runtime_binding_summary(binding, verifications.get(&binding.runtime_id))
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -16137,7 +16191,10 @@ async fn workspace_runtime_resources_response(
|
|||||||
removable: true,
|
removable: true,
|
||||||
endpoint_configured: !binding.base_url.trim().is_empty(),
|
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
binding: Some(runtime_binding_summary(&binding)),
|
binding: Some(runtime_binding_summary(
|
||||||
|
binding,
|
||||||
|
verifications.get(&binding.runtime_id),
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -16151,13 +16208,38 @@ async fn workspace_runtime_resources_response(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeBindingSummary {
|
fn runtime_binding_summary(
|
||||||
|
binding: &WorkspaceRuntimeBinding,
|
||||||
|
verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>,
|
||||||
|
) -> WorkspaceRuntimeBindingSummary {
|
||||||
|
let valid_verification = verification.filter(|verification| {
|
||||||
|
verification.state == "verified"
|
||||||
|
&& verification.binding_revision == binding.binding_revision
|
||||||
|
&& verification.runtime_public_key_fingerprint == binding.public_key_fingerprint
|
||||||
|
&& verification.workspace_key_id
|
||||||
|
== binding.workspace_key_id.as_deref().unwrap_or_default()
|
||||||
|
});
|
||||||
|
let connection_state = match binding.state {
|
||||||
|
StoredRuntimeBindingState::Revoked => RuntimeConnectionDisplayState::Revoked,
|
||||||
|
_ if verification.is_some_and(|verification| verification.last_outcome != "verified") => {
|
||||||
|
RuntimeConnectionDisplayState::Unavailable
|
||||||
|
}
|
||||||
|
StoredRuntimeBindingState::Verified
|
||||||
|
if binding.authentication_mode
|
||||||
|
== StoredRuntimeAuthenticationMode::LegacyServerIssuer
|
||||||
|
|| valid_verification.is_some() =>
|
||||||
|
{
|
||||||
|
RuntimeConnectionDisplayState::Verified
|
||||||
|
}
|
||||||
|
_ => RuntimeConnectionDisplayState::Configured,
|
||||||
|
};
|
||||||
WorkspaceRuntimeBindingSummary {
|
WorkspaceRuntimeBindingSummary {
|
||||||
state: match binding.state {
|
state: match binding.state {
|
||||||
StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured,
|
StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured,
|
||||||
StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified,
|
StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified,
|
||||||
StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked,
|
StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked,
|
||||||
},
|
},
|
||||||
|
connection_state,
|
||||||
authentication_mode: match binding.authentication_mode {
|
authentication_mode: match binding.authentication_mode {
|
||||||
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
|
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
|
||||||
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer
|
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer
|
||||||
@@ -16169,9 +16251,33 @@ fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntim
|
|||||||
revision: binding.binding_revision,
|
revision: binding.binding_revision,
|
||||||
workspace_key_id: binding.workspace_key_id.clone(),
|
workspace_key_id: binding.workspace_key_id.clone(),
|
||||||
workspace_key_generation: binding.workspace_key_generation,
|
workspace_key_generation: binding.workspace_key_generation,
|
||||||
|
verification: verification.and_then(runtime_verification_summary),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_verification_summary(
|
||||||
|
verification: &crate::store::WorkspaceRuntimeVerificationEvidence,
|
||||||
|
) -> Option<workspace_api::RuntimeVerificationEvidenceSummary> {
|
||||||
|
let last_outcome = match verification.last_outcome.as_str() {
|
||||||
|
"verified" => workspace_api::RuntimeVerificationOutcome::Verified,
|
||||||
|
"challenge_issued" => workspace_api::RuntimeVerificationOutcome::ChallengeIssued,
|
||||||
|
"verification_failed" => workspace_api::RuntimeVerificationOutcome::VerificationFailed,
|
||||||
|
"connectivity_failed" => workspace_api::RuntimeVerificationOutcome::ConnectivityFailed,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(workspace_api::RuntimeVerificationEvidenceSummary {
|
||||||
|
verified_at: verification.verified_at.clone(),
|
||||||
|
last_checked_at: verification.checked_at.clone(),
|
||||||
|
last_outcome,
|
||||||
|
binding_revision: verification.binding_revision,
|
||||||
|
workspace_key_id: verification.workspace_key_id.clone(),
|
||||||
|
workspace_identity_revision: verification.workspace_identity_revision,
|
||||||
|
workspace_trust_generation: verification.workspace_trust_generation,
|
||||||
|
runtime_public_key_fingerprint: verification.runtime_public_key_fingerprint.clone(),
|
||||||
|
runtime_identity_revision: verification.runtime_identity_revision,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn workspace_runtime_detail(
|
async fn workspace_runtime_detail(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -16213,7 +16319,7 @@ async fn workspace_runtime_detail(
|
|||||||
removable: false,
|
removable: false,
|
||||||
endpoint_configured: !binding.base_url.trim().is_empty(),
|
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
binding: Some(runtime_binding_summary(&binding)),
|
binding: Some(runtime_binding_summary(&binding, None)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -16463,6 +16569,9 @@ fn runtime_connection_test_response(
|
|||||||
Ok(ping) => RuntimeConnectionTestResponse {
|
Ok(ping) => RuntimeConnectionTestResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
|
binding_revision: 0,
|
||||||
|
connection_state: RuntimeConnectionDisplayState::Configured,
|
||||||
|
verification: None,
|
||||||
checked_at,
|
checked_at,
|
||||||
status: RuntimeConnectionTestStatus::Compatible,
|
status: RuntimeConnectionTestStatus::Compatible,
|
||||||
failure_kind: None,
|
failure_kind: None,
|
||||||
@@ -16512,6 +16621,9 @@ fn runtime_connection_test_failure(
|
|||||||
RuntimeConnectionTestResponse {
|
RuntimeConnectionTestResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
|
binding_revision: 0,
|
||||||
|
connection_state: RuntimeConnectionDisplayState::Unavailable,
|
||||||
|
verification: None,
|
||||||
checked_at,
|
checked_at,
|
||||||
status: RuntimeConnectionTestStatus::Failed,
|
status: RuntimeConnectionTestStatus::Failed,
|
||||||
failure_kind: Some(failure_kind),
|
failure_kind: Some(failure_kind),
|
||||||
@@ -20336,18 +20448,20 @@ mod tests {
|
|||||||
generic_put.into_response().status(),
|
generic_put.into_response().status(),
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
);
|
);
|
||||||
let configured_test = scoped_test_runtime_connection(
|
let Json(configured_test) = scoped_test_runtime_connection(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
AxumPath(ScopedRuntimePath {
|
AxumPath(ScopedRuntimePath {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: "configured-runtime".to_string(),
|
runtime_id: "configured-runtime".to_string(),
|
||||||
}),
|
}),
|
||||||
|
Extension(actor.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap();
|
||||||
|
assert_eq!(configured_test.status, RuntimeConnectionTestStatus::Failed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
configured_test.into_response().status(),
|
configured_test.connection_state,
|
||||||
StatusCode::CONFLICT
|
RuntimeConnectionDisplayState::Unavailable
|
||||||
);
|
);
|
||||||
|
|
||||||
let (status, Json(replayed)) = create_remote_runtime(
|
let (status, Json(replayed)) = create_remote_runtime(
|
||||||
@@ -27991,7 +28105,9 @@ mod tests {
|
|||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let (endpoint, _server) = runtime_ping_stub(status, body).await;
|
let (endpoint, _server) = runtime_ping_stub(status, body).await;
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await;
|
let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint)
|
||||||
|
.await
|
||||||
|
.layer(Extension(test_owner_actor()));
|
||||||
post_json(
|
post_json(
|
||||||
app,
|
app,
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
|
||||||
@@ -28000,6 +28116,25 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_connection_verification_requires_workspace_owner() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let mut actor = test_owner_actor();
|
||||||
|
actor.account_id = "account-other".to_string();
|
||||||
|
let error = scoped_test_runtime_connection(
|
||||||
|
State(api),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "probe-runtime".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(actor),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn runtime_connection_test_reports_exact_compatible_protocol() {
|
async fn runtime_connection_test_reports_exact_compatible_protocol() {
|
||||||
let response = run_runtime_connection_test(
|
let response = run_runtime_connection_test(
|
||||||
@@ -28012,6 +28147,9 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(response["status"], "compatible");
|
assert_eq!(response["status"], "compatible");
|
||||||
|
assert_eq!(response["binding_revision"], 1);
|
||||||
|
assert_eq!(response["connection_state"], "verified");
|
||||||
|
assert_eq!(response["verification"], serde_json::Value::Null);
|
||||||
assert_eq!(response["failure_kind"], serde_json::Value::Null);
|
assert_eq!(response["failure_kind"], serde_json::Value::Null);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response["expected_protocol_version"],
|
response["expected_protocol_version"],
|
||||||
@@ -30413,8 +30551,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
let status = response.status();
|
||||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"{uri}: {}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
serde_json::from_slice(&bytes).unwrap()
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2234,6 +2234,7 @@ impl SqliteWorkspaceStore {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
Ok(evidence.state == "verified"
|
Ok(evidence.state == "verified"
|
||||||
|
&& evidence.last_outcome == "verified"
|
||||||
&& evidence.verified_at.is_some()
|
&& evidence.verified_at.is_some()
|
||||||
&& evidence.binding_revision == binding.binding_revision
|
&& evidence.binding_revision == binding.binding_revision
|
||||||
&& evidence.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default()
|
&& evidence.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default()
|
||||||
@@ -2251,18 +2252,22 @@ impl SqliteWorkspaceStore {
|
|||||||
validate_identifier("workspace_id", workspace_id)?;
|
validate_identifier("workspace_id", workspace_id)?;
|
||||||
validate_identifier("runtime_id", runtime_id)?;
|
validate_identifier("runtime_id", runtime_id)?;
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.query_row(
|
let evidence = conn
|
||||||
r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id,
|
.query_row(
|
||||||
|
r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id,
|
||||||
workspace_identity_revision, workspace_trust_generation,
|
workspace_identity_revision, workspace_trust_generation,
|
||||||
runtime_public_key_fingerprint, runtime_identity_revision,
|
runtime_public_key_fingerprint, runtime_identity_revision,
|
||||||
challenge_id, state, last_outcome, verified_at, checked_at
|
challenge_id, state, last_outcome, verified_at, checked_at
|
||||||
FROM workspace_runtime_verifications
|
FROM workspace_runtime_verifications
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
|
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
|
||||||
params![workspace_id, runtime_id],
|
params![workspace_id, runtime_id],
|
||||||
read_workspace_runtime_verification,
|
read_workspace_runtime_verification,
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()?;
|
||||||
.map_err(Error::from)
|
if let Some(evidence) = &evidence {
|
||||||
|
validate_workspace_runtime_verification(evidence)?;
|
||||||
|
}
|
||||||
|
Ok(evidence)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2287,9 +2292,29 @@ impl SqliteWorkspaceStore {
|
|||||||
runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint,
|
runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint,
|
||||||
runtime_identity_revision = excluded.runtime_identity_revision,
|
runtime_identity_revision = excluded.runtime_identity_revision,
|
||||||
challenge_id = excluded.challenge_id,
|
challenge_id = excluded.challenge_id,
|
||||||
state = excluded.state,
|
state = CASE
|
||||||
|
WHEN workspace_runtime_verifications.state = 'verified'
|
||||||
|
AND workspace_runtime_verifications.binding_revision = excluded.binding_revision
|
||||||
|
AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id
|
||||||
|
AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision
|
||||||
|
AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation
|
||||||
|
AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint
|
||||||
|
AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision
|
||||||
|
THEN workspace_runtime_verifications.state
|
||||||
|
ELSE excluded.state
|
||||||
|
END,
|
||||||
last_outcome = excluded.last_outcome,
|
last_outcome = excluded.last_outcome,
|
||||||
verified_at = excluded.verified_at,
|
verified_at = CASE
|
||||||
|
WHEN workspace_runtime_verifications.state = 'verified'
|
||||||
|
AND workspace_runtime_verifications.binding_revision = excluded.binding_revision
|
||||||
|
AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id
|
||||||
|
AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision
|
||||||
|
AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation
|
||||||
|
AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint
|
||||||
|
AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision
|
||||||
|
THEN workspace_runtime_verifications.verified_at
|
||||||
|
ELSE excluded.verified_at
|
||||||
|
END,
|
||||||
checked_at = excluded.checked_at"#,
|
checked_at = excluded.checked_at"#,
|
||||||
params![
|
params![
|
||||||
evidence.workspace_id,
|
evidence.workspace_id,
|
||||||
@@ -6725,7 +6750,14 @@ fn validate_workspace_runtime_verification(
|
|||||||
&evidence.runtime_public_key_fingerprint,
|
&evidence.runtime_public_key_fingerprint,
|
||||||
)?;
|
)?;
|
||||||
validate_non_empty("verification state", &evidence.state)?;
|
validate_non_empty("verification state", &evidence.state)?;
|
||||||
validate_non_empty("verification outcome", &evidence.last_outcome)?;
|
if !matches!(
|
||||||
|
evidence.last_outcome.as_str(),
|
||||||
|
"verified" | "challenge_issued" | "verification_failed" | "connectivity_failed"
|
||||||
|
) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Runtime verification outcome is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
validate_non_empty("checked_at", &evidence.checked_at)?;
|
validate_non_empty("checked_at", &evidence.checked_at)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -9829,6 +9861,31 @@ mod tests {
|
|||||||
.complete_workspace_runtime_verification(&evidence)
|
.complete_workspace_runtime_verification(&evidence)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(verified.state, WorkspaceRuntimeBindingState::Verified);
|
assert_eq!(verified.state, WorkspaceRuntimeBindingState::Verified);
|
||||||
|
let pending_retry = WorkspaceRuntimeVerificationEvidence {
|
||||||
|
state: "pending".to_string(),
|
||||||
|
last_outcome: "challenge_issued".to_string(),
|
||||||
|
verified_at: None,
|
||||||
|
checked_at: "3".to_string(),
|
||||||
|
..evidence.clone()
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.record_workspace_runtime_verification_attempt(&pending_retry)
|
||||||
|
.unwrap();
|
||||||
|
let retained = store
|
||||||
|
.get_workspace_runtime_verification("workspace-a", "runtime-a")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retained.state, "verified");
|
||||||
|
assert_eq!(retained.verified_at.as_deref(), Some("2"));
|
||||||
|
assert_eq!(retained.last_outcome, "challenge_issued");
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.workspace_runtime_verification_matches(&verified, 1, 1)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.complete_workspace_runtime_verification(&evidence)
|
||||||
|
.unwrap();
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|
||||||
let reopened = SqliteWorkspaceStore::open(&path).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
|||||||
@@ -349,16 +349,42 @@ export type WorkspaceRuntimeBindingState =
|
|||||||
| "verified"
|
| "verified"
|
||||||
| "revoked";
|
| "revoked";
|
||||||
|
|
||||||
|
export type RuntimeConnectionDisplayState =
|
||||||
|
| "configured"
|
||||||
|
| "verified"
|
||||||
|
| "unavailable"
|
||||||
|
| "revoked";
|
||||||
|
|
||||||
|
export type RuntimeVerificationOutcome =
|
||||||
|
| "verified"
|
||||||
|
| "challenge_issued"
|
||||||
|
| "verification_failed"
|
||||||
|
| "connectivity_failed";
|
||||||
|
|
||||||
|
export type RuntimeVerificationEvidenceSummary = {
|
||||||
|
verified_at: string | null;
|
||||||
|
last_checked_at: string;
|
||||||
|
last_outcome: RuntimeVerificationOutcome;
|
||||||
|
binding_revision: number;
|
||||||
|
workspace_key_id: string;
|
||||||
|
workspace_identity_revision: number;
|
||||||
|
workspace_trust_generation: number;
|
||||||
|
runtime_public_key_fingerprint: string;
|
||||||
|
runtime_identity_revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceRuntimeAuthenticationMode =
|
export type WorkspaceRuntimeAuthenticationMode =
|
||||||
| "legacy_server_issuer"
|
| "legacy_server_issuer"
|
||||||
| "workspace_identity";
|
| "workspace_identity";
|
||||||
|
|
||||||
export type WorkspaceRuntimeBindingSummary = {
|
export type WorkspaceRuntimeBindingSummary = {
|
||||||
state: WorkspaceRuntimeBindingState;
|
state: WorkspaceRuntimeBindingState;
|
||||||
|
connection_state: RuntimeConnectionDisplayState;
|
||||||
authentication_mode: WorkspaceRuntimeAuthenticationMode;
|
authentication_mode: WorkspaceRuntimeAuthenticationMode;
|
||||||
revision: number;
|
revision: number;
|
||||||
workspace_key_id?: string | null;
|
workspace_key_id?: string | null;
|
||||||
workspace_key_generation?: number | null;
|
workspace_key_generation?: number | null;
|
||||||
|
verification?: RuntimeVerificationEvidenceSummary | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeManagementSummary = {
|
export type RuntimeManagementSummary = {
|
||||||
@@ -464,6 +490,9 @@ export type RuntimeConnectionTestFailureKind =
|
|||||||
export type RuntimeConnectionTestResponse = {
|
export type RuntimeConnectionTestResponse = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
|
binding_revision: number;
|
||||||
|
connection_state: RuntimeConnectionDisplayState;
|
||||||
|
verification: RuntimeVerificationEvidenceSummary | null;
|
||||||
checked_at: string;
|
checked_at: string;
|
||||||
status: RuntimeConnectionTestStatus;
|
status: RuntimeConnectionTestStatus;
|
||||||
failure_kind: RuntimeConnectionTestFailureKind | null;
|
failure_kind: RuntimeConnectionTestFailureKind | null;
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import type {
|
|||||||
Diagnostic,
|
Diagnostic,
|
||||||
RuntimeConnectionTestFailureKind,
|
RuntimeConnectionTestFailureKind,
|
||||||
RuntimeConnectionTestResponse,
|
RuntimeConnectionTestResponse,
|
||||||
|
RuntimeVerificationEvidenceSummary,
|
||||||
} from "$lib/generated/workspace-api";
|
} from "$lib/generated/workspace-api";
|
||||||
|
|
||||||
const RESPONSE_KEYS = [
|
const RESPONSE_KEYS = [
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
"runtime_id",
|
"runtime_id",
|
||||||
|
"binding_revision",
|
||||||
|
"connection_state",
|
||||||
|
"verification",
|
||||||
"checked_at",
|
"checked_at",
|
||||||
"status",
|
"status",
|
||||||
"failure_kind",
|
"failure_kind",
|
||||||
@@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse(
|
|||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const bindingRevision = value.binding_revision;
|
||||||
|
const connectionState = parseConnectionState(value.connection_state);
|
||||||
|
const verification = parseVerificationEvidence(value.verification);
|
||||||
|
if (
|
||||||
|
!isSafeRevision(bindingRevision) ||
|
||||||
|
connectionState === null ||
|
||||||
|
(value.verification !== null && verification === null) ||
|
||||||
|
(verification !== null &&
|
||||||
|
verification.binding_revision !== bindingRevision) ||
|
||||||
|
(connectionState === "verified" && value.status !== "compatible") ||
|
||||||
|
(connectionState === "verified" && verification !== null &&
|
||||||
|
(verification.last_outcome !== "verified" ||
|
||||||
|
verification.verified_at === null))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
workspace_id: value.workspace_id,
|
workspace_id: value.workspace_id,
|
||||||
runtime_id: value.runtime_id,
|
runtime_id: value.runtime_id,
|
||||||
|
binding_revision: bindingRevision,
|
||||||
|
connection_state: connectionState,
|
||||||
|
verification,
|
||||||
checked_at: value.checked_at,
|
checked_at: value.checked_at,
|
||||||
status: value.status,
|
status: value.status,
|
||||||
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
||||||
@@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseConnectionState(
|
||||||
|
value: unknown,
|
||||||
|
): "configured" | "verified" | "unavailable" | "revoked" | null {
|
||||||
|
return value === "configured" || value === "verified" ||
|
||||||
|
value === "unavailable" || value === "revoked"
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeRevision(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVerificationEvidence(
|
||||||
|
value: unknown,
|
||||||
|
): RuntimeVerificationEvidenceSummary | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
const keys = [
|
||||||
|
"verified_at",
|
||||||
|
"last_checked_at",
|
||||||
|
"last_outcome",
|
||||||
|
"binding_revision",
|
||||||
|
"workspace_key_id",
|
||||||
|
"workspace_identity_revision",
|
||||||
|
"workspace_trust_generation",
|
||||||
|
"runtime_public_key_fingerprint",
|
||||||
|
"runtime_identity_revision",
|
||||||
|
] 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") ||
|
||||||
|
!isSafeRevision(value.binding_revision) ||
|
||||||
|
!isBoundedString(value.workspace_key_id, 128) ||
|
||||||
|
!isSafeRevision(value.workspace_identity_revision) ||
|
||||||
|
!isSafeRevision(value.workspace_trust_generation) ||
|
||||||
|
!isBoundedString(value.runtime_public_key_fingerprint, 128) ||
|
||||||
|
!isSafeRevision(value.runtime_identity_revision)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
verified_at: value.verified_at,
|
||||||
|
last_checked_at: value.last_checked_at,
|
||||||
|
last_outcome: value.last_outcome,
|
||||||
|
binding_revision: value.binding_revision,
|
||||||
|
workspace_key_id: value.workspace_key_id,
|
||||||
|
workspace_identity_revision: value.workspace_identity_revision,
|
||||||
|
workspace_trust_generation: value.workspace_trust_generation,
|
||||||
|
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
|
||||||
|
runtime_identity_revision: value.runtime_identity_revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function testRuntimeConnection(
|
export async function testRuntimeConnection(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Diagnostic,
|
Diagnostic,
|
||||||
PutRuntimeTrustKeyRequest,
|
PutRuntimeTrustKeyRequest,
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
|
RuntimeConnectionDisplayState,
|
||||||
RuntimeIdentityAuthority,
|
RuntimeIdentityAuthority,
|
||||||
RuntimeManagementSummary,
|
RuntimeManagementSummary,
|
||||||
RuntimeSourceKind,
|
RuntimeSourceKind,
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
RuntimeTrustKeyRevealResponse,
|
RuntimeTrustKeyRevealResponse,
|
||||||
RuntimeTrustKeyState,
|
RuntimeTrustKeyState,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
|
RuntimeVerificationEvidenceSummary,
|
||||||
WorkspaceRuntimeAuthenticationMode,
|
WorkspaceRuntimeAuthenticationMode,
|
||||||
WorkspaceRuntimeBindingState,
|
WorkspaceRuntimeBindingState,
|
||||||
WorkspaceRuntimeBindingSummary,
|
WorkspaceRuntimeBindingSummary,
|
||||||
@@ -76,6 +78,13 @@ const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
|
|||||||
"verified",
|
"verified",
|
||||||
"revoked",
|
"revoked",
|
||||||
]);
|
]);
|
||||||
|
const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
|
||||||
|
"configured",
|
||||||
|
"verified",
|
||||||
|
"unavailable",
|
||||||
|
"revoked",
|
||||||
|
]);
|
||||||
|
|
||||||
const AUTHENTICATION_MODES = new Set<WorkspaceRuntimeAuthenticationMode>([
|
const AUTHENTICATION_MODES = new Set<WorkspaceRuntimeAuthenticationMode>([
|
||||||
"legacy_server_issuer",
|
"legacy_server_issuer",
|
||||||
"workspace_identity",
|
"workspace_identity",
|
||||||
@@ -299,6 +308,79 @@ 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_revision",
|
||||||
|
"workspace_key_id",
|
||||||
|
"workspace_identity_revision",
|
||||||
|
"workspace_trust_generation",
|
||||||
|
"runtime_public_key_fingerprint",
|
||||||
|
"runtime_identity_revision",
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
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_revision: safeRevision(
|
||||||
|
item.binding_revision,
|
||||||
|
`${path}.binding_revision`,
|
||||||
|
),
|
||||||
|
workspace_key_id: boundedString(
|
||||||
|
item.workspace_key_id,
|
||||||
|
`${path}.workspace_key_id`,
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
workspace_identity_revision: safeRevision(
|
||||||
|
item.workspace_identity_revision,
|
||||||
|
`${path}.workspace_identity_revision`,
|
||||||
|
),
|
||||||
|
workspace_trust_generation: safeRevision(
|
||||||
|
item.workspace_trust_generation,
|
||||||
|
`${path}.workspace_trust_generation`,
|
||||||
|
),
|
||||||
|
runtime_public_key_fingerprint: boundedString(
|
||||||
|
item.runtime_public_key_fingerprint,
|
||||||
|
`${path}.runtime_public_key_fingerprint`,
|
||||||
|
LIMITS.fingerprintBytes,
|
||||||
|
),
|
||||||
|
runtime_identity_revision: safeRevision(
|
||||||
|
item.runtime_identity_revision,
|
||||||
|
`${path}.runtime_identity_revision`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeBinding(
|
function runtimeBinding(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -306,8 +388,8 @@ function runtimeBinding(
|
|||||||
const item = object(value, path);
|
const item = object(value, path);
|
||||||
exactKeys(
|
exactKeys(
|
||||||
item,
|
item,
|
||||||
["state", "authentication_mode", "revision"],
|
["state", "connection_state", "authentication_mode", "revision"],
|
||||||
["workspace_key_id", "workspace_key_generation"],
|
["workspace_key_id", "workspace_key_generation", "verification"],
|
||||||
path,
|
path,
|
||||||
);
|
);
|
||||||
const authenticationMode = enumValue(
|
const authenticationMode = enumValue(
|
||||||
@@ -339,16 +421,45 @@ function runtimeBinding(
|
|||||||
"must not attach Workspace key metadata to legacy authority",
|
"must not attach Workspace key metadata to legacy authority",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
|
||||||
|
const connectionState = enumValue(
|
||||||
|
item.connection_state,
|
||||||
|
`${path}.connection_state`,
|
||||||
|
CONNECTION_STATES,
|
||||||
|
);
|
||||||
|
const revision = safeRevision(item.revision, `${path}.revision`);
|
||||||
|
const verification = item.verification === undefined
|
||||||
|
? undefined
|
||||||
|
: runtimeVerification(item.verification, `${path}.verification`);
|
||||||
|
if (
|
||||||
|
verification !== undefined && verification.binding_revision !== revision
|
||||||
|
) {
|
||||||
|
return fail(path, "verification must match the current binding revision");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
authenticationMode === "workspace_identity" &&
|
||||||
|
connectionState === "verified" &&
|
||||||
|
(verification === undefined ||
|
||||||
|
verification.verified_at === null ||
|
||||||
|
verification.last_outcome !== "verified")
|
||||||
|
) {
|
||||||
|
return fail(
|
||||||
|
path,
|
||||||
|
"verified Workspace identity binding requires verification evidence",
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
state: enumValue(item.state, `${path}.state`, BINDING_STATES),
|
state,
|
||||||
|
connection_state: connectionState,
|
||||||
authentication_mode: authenticationMode,
|
authentication_mode: authenticationMode,
|
||||||
revision: safeRevision(item.revision, `${path}.revision`),
|
revision,
|
||||||
...(workspaceKeyId === undefined
|
...(workspaceKeyId === undefined
|
||||||
? {}
|
? {}
|
||||||
: { workspace_key_id: workspaceKeyId }),
|
: { workspace_key_id: workspaceKeyId }),
|
||||||
...(workspaceKeyGeneration === undefined
|
...(workspaceKeyGeneration === undefined
|
||||||
? {}
|
? {}
|
||||||
: { workspace_key_generation: workspaceKeyGeneration }),
|
: { workspace_key_generation: workspaceKeyGeneration }),
|
||||||
|
...(verification === undefined ? {} : { verification }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
let busyRuntimeId = $state<string | null>(null);
|
let busyRuntimeId = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||||
|
let connectionTestGeneration = 0;
|
||||||
|
|
||||||
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
|
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
|
||||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||||
@@ -33,7 +34,9 @@
|
|||||||
|
|
||||||
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
||||||
if (result.status === 'compatible') {
|
if (result.status === 'compatible') {
|
||||||
return `Compatible · protocol v${result.actual_protocol_version}`;
|
return result.connection_state === 'verified'
|
||||||
|
? `Verified · protocol v${result.actual_protocol_version}`
|
||||||
|
: `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`;
|
||||||
}
|
}
|
||||||
switch (result.failure_kind) {
|
switch (result.failure_kind) {
|
||||||
case 'authentication': return 'Authentication failed';
|
case 'authentication': return 'Authentication failed';
|
||||||
@@ -144,16 +147,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentTestResult(
|
||||||
|
runtime: WorkspaceRuntimeResource,
|
||||||
|
): RuntimeConnectionTestResponse | undefined {
|
||||||
|
const result = testResults[runtime.runtime_id];
|
||||||
|
return result?.binding_revision === runtime.management?.binding?.revision
|
||||||
|
? result
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||||
|
const bindingRevision = runtime.management?.binding?.revision;
|
||||||
|
if (typeof bindingRevision !== 'number') return;
|
||||||
|
const generation = ++connectionTestGeneration;
|
||||||
requestError = null;
|
requestError = null;
|
||||||
busyRuntimeId = runtime.runtime_id;
|
busyRuntimeId = runtime.runtime_id;
|
||||||
try {
|
try {
|
||||||
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
||||||
|
if (
|
||||||
|
generation !== connectionTestGeneration ||
|
||||||
|
result.binding_revision !== bindingRevision
|
||||||
|
) {
|
||||||
|
await invalidateAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
testResults = { ...testResults, [runtime.runtime_id]: result };
|
testResults = { ...testResults, [runtime.runtime_id]: result };
|
||||||
|
await invalidateAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
requestError = error instanceof Error ? error.message : String(error);
|
if (generation === connectionTestGeneration) {
|
||||||
|
requestError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busyRuntimeId = null;
|
if (generation === connectionTestGeneration) {
|
||||||
|
busyRuntimeId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -287,7 +314,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>
|
<td>
|
||||||
{runtime.management?.binding?.state ?? runtime.status}
|
{runtime.management?.binding?.connection_state ?? runtime.status}
|
||||||
</td>
|
</td>
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{managementLabel(runtime)}</td>
|
<td>{managementLabel(runtime)}</td>
|
||||||
@@ -298,7 +325,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
{#if runtime.management?.config_managed && runtime.management.binding?.state === 'verified'}
|
{#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyRuntimeId !== null}
|
disabled={busyRuntimeId !== null}
|
||||||
@@ -313,7 +340,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]}
|
{@const currentResult = currentTestResult(runtime)}
|
||||||
|
{#if runtime.diagnostics.length > 0 || currentResult}
|
||||||
<tr class="settings-runtime-detail-row">
|
<tr class="settings-runtime-detail-row">
|
||||||
<td colspan="7">
|
<td colspan="7">
|
||||||
{#if runtime.diagnostics.length > 0}
|
{#if runtime.diagnostics.length > 0}
|
||||||
@@ -326,14 +354,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{#if testResults[runtime.runtime_id]}
|
{#if currentResult}
|
||||||
{@const result = testResults[runtime.runtime_id]}
|
<div class:failed={currentResult.status === 'failed'} class="settings-test-result">
|
||||||
<div class:failed={result.status === 'failed'} class="settings-test-result">
|
<strong>Connection test: {connectionTestSummary(currentResult)}</strong>
|
||||||
<strong>Connection test: {connectionTestSummary(result)}</strong>
|
{#if currentResult.diagnostics[0]}
|
||||||
{#if result.diagnostics[0]}
|
<span>{currentResult.diagnostics[0].message}</span>
|
||||||
<span>{result.diagnostics[0].message}</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<small>Checked {new Date(result.checked_at).toLocaleString()}</small>
|
<small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -332,9 +332,12 @@
|
|||||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||||
<div><dt>Relationship state</dt><dd>{runtime.management.binding?.state ?? 'Not configured'}</dd></div>
|
<div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
|
||||||
<div><dt>Authentication mode</dt><dd>{runtime.management.binding?.authentication_mode ?? '—'}</dd></div>
|
<div><dt>Authentication mode</dt><dd>{runtime.management.binding?.authentication_mode ?? '—'}</dd></div>
|
||||||
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></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>Verified binding revision</dt><dd>{runtime.management.binding?.verification?.binding_revision?.toString() ?? '—'}</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>Runtime key status</dt><dd>{trust.status}</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>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||||
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ function compatibleResponse(): Record<string, unknown> {
|
|||||||
return {
|
return {
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
runtime_id: "runtime-a",
|
runtime_id: "runtime-a",
|
||||||
|
binding_revision: 3,
|
||||||
|
connection_state: "verified",
|
||||||
|
verification: null,
|
||||||
checked_at: "2026-09-01T12:00:00Z",
|
checked_at: "2026-09-01T12:00:00Z",
|
||||||
status: "compatible",
|
status: "compatible",
|
||||||
failure_kind: null,
|
failure_kind: null,
|
||||||
@@ -57,6 +60,23 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
|
|||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
assertEquals(
|
||||||
|
parseRuntimeConnectionTestResponse({
|
||||||
|
...compatibleResponse(),
|
||||||
|
verification: {
|
||||||
|
verified_at: "2026-09-01T12:00:00Z",
|
||||||
|
last_checked_at: "2026-09-01T12:00:01Z",
|
||||||
|
last_outcome: "verified",
|
||||||
|
binding_revision: 2,
|
||||||
|
workspace_key_id: "WK-a",
|
||||||
|
workspace_identity_revision: 1,
|
||||||
|
workspace_trust_generation: 1,
|
||||||
|
runtime_public_key_fingerprint: "sha256:runtime",
|
||||||
|
runtime_identity_revision: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
|
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
|
||||||
|
|||||||
@@ -42,10 +42,22 @@ function runtime() {
|
|||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
binding: {
|
binding: {
|
||||||
state: "verified",
|
state: "verified",
|
||||||
|
connection_state: "verified",
|
||||||
authentication_mode: "workspace_identity",
|
authentication_mode: "workspace_identity",
|
||||||
revision: 3,
|
revision: 3,
|
||||||
workspace_key_id: "WK-1",
|
workspace_key_id: "WK-1",
|
||||||
workspace_key_generation: 1,
|
workspace_key_generation: 1,
|
||||||
|
verification: {
|
||||||
|
verified_at: "2026-09-01T13:00:00Z",
|
||||||
|
last_checked_at: "2026-09-01T13:00:00Z",
|
||||||
|
last_outcome: "verified",
|
||||||
|
binding_revision: 3,
|
||||||
|
workspace_key_id: "WK-1",
|
||||||
|
workspace_identity_revision: 1,
|
||||||
|
workspace_trust_generation: 1,
|
||||||
|
runtime_public_key_fingerprint: "SHA256:current",
|
||||||
|
runtime_identity_revision: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtime_id: "arcadia",
|
runtime_id: "arcadia",
|
||||||
|
|||||||
Reference in New Issue
Block a user