From 243a081874d97eb061413a7e531f26bd7debcd8e Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 8 Sep 2026 04:22:32 +0900 Subject: [PATCH] feat: add configured Workspace Runtime bindings --- crates/workspace-api/src/lib.rs | 55 +- crates/workspace-server/src/hosts.rs | 58 ++- crates/workspace-server/src/latest_schema.sql | 16 +- crates/workspace-server/src/main.rs | 13 +- crates/workspace-server/src/server.rs | 488 ++++++++++++++++-- crates/workspace-server/src/store.rs | 459 ++++++++++++++-- .../src/lib/generated/workspace-api.ts | 30 ++ 7 files changed, 1007 insertions(+), 112 deletions(-) diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 7529c12d..13d7c330 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1580,6 +1580,38 @@ pub struct RuntimeSummary { pub diagnostics: Vec, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[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)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeAuthenticationMode { + LegacyServerIssuer, + WorkspaceIdentity, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeBindingSummary { + pub state: WorkspaceRuntimeBindingState, + pub authentication_mode: WorkspaceRuntimeAuthenticationMode, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub workspace_key_generation: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -1589,6 +1621,8 @@ pub struct RuntimeManagementSummary { pub removable: bool, pub endpoint_configured: bool, pub token_ref_configured: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1711,12 +1745,24 @@ pub struct RuntimeTrustConflictResponse { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimePublicIdentityBundle { + pub identity_id: String, + pub public_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CreateRemoteRuntimeRequest { - pub runtime_id: String, + pub public_bundle: RuntimePublicIdentityBundle, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, pub endpoint: String, - pub token_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -2930,6 +2976,9 @@ pub fn catalog_typescript() -> String { RuntimeIdentityAuthority::decl(&config), RuntimeSourceSummary::decl(&config), RuntimeSummary::decl(&config), + WorkspaceRuntimeBindingState::decl(&config), + WorkspaceRuntimeAuthenticationMode::decl(&config), + WorkspaceRuntimeBindingSummary::decl(&config), RuntimeManagementSummary::decl(&config), WorkspaceRuntimeResource::decl(&config), RuntimeTrustKeyStatus::decl(&config), @@ -2942,6 +2991,8 @@ pub fn catalog_typescript() -> String { RevokeRuntimeTrustKeyRequest::decl(&config), RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictResponse::decl(&config), + RuntimePublicIdentityBundle::decl(&config), + CreateRemoteRuntimeRequest::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 27d1c4e6..c00adf25 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -69,6 +69,7 @@ const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host"; const MAX_DIAGNOSTICS: usize = 16; const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024; +const MAX_REMOTE_RUNTIME_RESPONSE_BYTES: usize = 16 * 1024 * 1024; // Runtime creation can spend up to 60s bootstrapping; durable Submit // acceptance is acknowledged before the potentially long run preparation. const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80); @@ -3060,18 +3061,25 @@ impl RemoteWorkerRuntime { validate_backend_identifier("runtime_id", &config.runtime_id)?; let base_url = config.base_url.trim_end_matches('/').to_string(); let timeout = config.timeout; - let http = - run_blocking_http(move || BlockingHttpClient::builder().timeout(timeout).build()) - .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { - runtime_id: config.runtime_id.clone(), - code: "remote_runtime_client_build_failed".to_string(), - message: err.to_string(), - })?; + let http = run_blocking_http(move || { + BlockingHttpClient::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + }) + .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_client_build_failed".to_string(), + message: err.to_string(), + })?; // Workdir command-output waits are bounded to 20 seconds by Runtime; // leave transport margin while retaining a finite client timeout. let workdir_timeout = timeout.max(Duration::from_secs(30)); let async_http = AsyncHttpClient::builder() .timeout(workdir_timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() .build() .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { runtime_id: config.runtime_id.clone(), @@ -3312,12 +3320,36 @@ impl RemoteWorkerRuntime { .map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?; let status = response.status(); if status.is_success() { - response.json::().map_err(|err| { + let mut body = Vec::new(); + response + .take((MAX_REMOTE_RUNTIME_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| { + diagnostic( + "remote_runtime_response_read_failed", + DiagnosticSeverity::Error, + format!( + "Remote Runtime response could not be read for '{}'", + runtime_id + ), + ) + })?; + if body.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES { + return Err(diagnostic( + "remote_runtime_response_too_large", + DiagnosticSeverity::Error, + format!( + "Remote Runtime response exceeded the allowed size for '{}'", + runtime_id + ), + )); + } + serde_json::from_slice::(&body).map_err(|_| { diagnostic( "remote_runtime_malformed_response", DiagnosticSeverity::Error, format!( - "Remote Runtime returned malformed JSON for '{}': {err}", + "Remote Runtime returned malformed JSON for '{}'", runtime_id ), ) @@ -4516,7 +4548,13 @@ fn remote_http_status_diagnostic( status: StatusCode, response: reqwest::blocking::Response, ) -> RuntimeDiagnostic { - let error = response.json::().ok(); + let mut body = Vec::new(); + let error = response + .take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .ok() + .filter(|_| body.len() <= MAX_RUNTIME_PING_RESPONSE_BYTES) + .and_then(|_| serde_json::from_slice::(&body).ok()); let remote_code = error .as_ref() .map(|error| error.error.code.as_str()) diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index f918dafe..fb0aca3e 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -441,12 +441,26 @@ CREATE TABLE workspace_runtime_bindings ( public_key TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 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_key_generation INTEGER CHECK (workspace_key_generation > 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 + 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_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) ); CREATE TABLE workspace_runtime_binding_audit ( workspace_id TEXT NOT NULL, diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 2c44f905..eb50e29c 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; -use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; +use yoi_workspace_server::store::{ + SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, + WorkspaceRuntimeBindingState, +}; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, @@ -335,6 +338,10 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box ApiError { async fn scoped_create_remote_runtime( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { validate_workspace_scope(&api, &path.workspace_id)?; - create_remote_runtime(State(api), Json(request)).await + create_remote_runtime(State(api), Extension(actor), Json(request)).await } async fn scoped_get_runtime_detail( @@ -11838,6 +11848,27 @@ async fn scoped_put_runtime_trust_key( public_key: request.public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: existing.as_ref().map_or( + StoredRuntimeBindingState::Verified, + |binding| match binding.authentication_mode { + StoredRuntimeAuthenticationMode::LegacyServerIssuer => { + StoredRuntimeBindingState::Verified + } + StoredRuntimeAuthenticationMode::WorkspaceIdentity => { + StoredRuntimeBindingState::Configured + } + }, + ), + authentication_mode: existing.as_ref().map_or( + StoredRuntimeAuthenticationMode::LegacyServerIssuer, + |binding| binding.authentication_mode, + ), + workspace_key_id: existing + .as_ref() + .and_then(|binding| binding.workspace_key_id.clone()), + workspace_key_generation: existing + .as_ref() + .and_then(|binding| binding.workspace_key_generation), created_at: existing .as_ref() .map_or_else(|| now.clone(), |binding| binding.created_at.clone()), @@ -11857,14 +11888,23 @@ async fn scoped_put_runtime_trust_key( return Err(error.into()); } }; - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - (path.workspace_id.clone(), path.runtime_id.clone()), - binding, - ); - if let Some(source) = source { + { + let mut expectations = api + .runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if binding.state == StoredRuntimeBindingState::Verified { + expectations.insert( + (path.workspace_id.clone(), path.runtime_id.clone()), + binding.clone(), + ); + } else { + expectations.remove(&(path.workspace_id.clone(), path.runtime_id.clone())); + } + } + if binding.state == StoredRuntimeBindingState::Verified + && let Some(source) = source + { api.runtime_subscription_broker .register_remote_runtime(source); } @@ -11984,6 +12024,30 @@ async fn scoped_test_runtime_connection( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; + let binding = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await? + .ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?; + if binding.state != StoredRuntimeBindingState::Verified { + return Err(Error::RuntimeBindingConflict(format!( + "Runtime `{}` is not an authenticated verified connection candidate", + path.runtime_id + )) + .into()); + } + if binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity { + validate_runtime_connection_request(&CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: binding.runtime_id.clone(), + public_key: binding.public_key.clone(), + }, + display_name: Some(binding.display_name.clone()), + endpoint: binding.base_url.clone(), + expected_revision: Some(binding.binding_revision), + }) + .await?; + } test_runtime_connection(State(api), AxumPath(path.runtime_id)).await } @@ -13447,31 +13511,73 @@ async fn list_workers( } async fn create_remote_runtime( - State(_api): State, + State(api): State, + Extension(actor): Extension, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { - validate_runtime_connection_request(&request)?; - let id = request.runtime_id.trim().to_string(); - if id == EMBEDDED_WORKER_RUNTIME_ID { + require_workspace_owner( + &api, + &api.config.workspace_id, + &actor, + "manage Workspace Runtimes", + ) + .await?; + let endpoint = validate_runtime_connection_request(&request).await?; + let runtime_id = request.public_bundle.identity_id.trim().to_string(); + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { return Err(settings_bad_request( "embedded_runtime_not_config_managed", "the embedded Runtime is built in and cannot be managed as a remote Runtime", )); } - if request - .token_ref - .as_ref() - .is_some_and(|value| !value.trim().is_empty()) - { + let identity = api + .signing_identities + .get_validated(&api.config.workspace_id)?; + if identity.state != "active" { return Err(settings_bad_request( - "remote_runtime_token_ref_unsupported", - "remote Runtime token_ref persistence is not supported", + "workspace_signing_identity_unavailable", + "an active Workspace signing identity is required before registering a Runtime", )); } - Err(settings_bad_request( - "runtime_public_key_required", - "remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered", - )) + let now = Utc::now().to_rfc3339(); + let record = WorkspaceRuntimeBinding { + workspace_id: api.config.workspace_id.clone(), + runtime_id: runtime_id.clone(), + display_name: request + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&runtime_id) + .to_string(), + base_url: endpoint.to_string().trim_end_matches('/').to_string(), + public_key: request.public_bundle.public_key.trim().to_string(), + public_key_fingerprint: String::new(), + binding_revision: request.expected_revision.unwrap_or(0), + state: StoredRuntimeBindingState::Configured, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(identity.key_id.clone()), + workspace_key_generation: Some(identity.revision), + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }; + let (mutation, _) = api + .store + .put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id) + .await?; + let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id) + .await? + .items + .into_iter() + .find(|resource| resource.runtime.runtime_id == runtime_id) + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + let status = if mutation == WorkspaceRuntimeBindingMutation::Created { + StatusCode::CREATED + } else { + StatusCode::OK + }; + Ok((status, Json(resource))) } async fn delete_remote_runtime( @@ -15667,8 +15773,12 @@ async fn workspace_runtime_resources_response( .iter() .find(|binding| binding.runtime_id == runtime.runtime_id); let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; + let mut runtime: workspace_api::RuntimeSummary = runtime.into(); + if binding.is_some_and(|binding| binding.state != StoredRuntimeBindingState::Verified) { + runtime.worker_creation_available = false; + } WorkspaceRuntimeResource { - runtime: runtime.into(), + runtime, management: RuntimeManagementSummary { built_in, config_managed: binding.is_some(), @@ -15676,6 +15786,7 @@ async fn workspace_runtime_resources_response( endpoint_configured: binding .is_some_and(|binding| !binding.base_url.trim().is_empty()), token_ref_configured: false, + binding: binding.map(runtime_binding_summary), }, } }) @@ -15721,6 +15832,7 @@ async fn workspace_runtime_resources_response( removable: true, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, + binding: Some(runtime_binding_summary(&binding)), }, }); } @@ -15734,6 +15846,27 @@ async fn workspace_runtime_resources_response( }) } +fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeBindingSummary { + WorkspaceRuntimeBindingSummary { + state: match binding.state { + StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured, + StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified, + StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked, + }, + authentication_mode: match binding.authentication_mode { + StoredRuntimeAuthenticationMode::LegacyServerIssuer => { + WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer + } + StoredRuntimeAuthenticationMode::WorkspaceIdentity => { + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + } + }, + revision: binding.binding_revision, + workspace_key_id: binding.workspace_key_id.clone(), + workspace_key_generation: binding.workspace_key_generation, + } +} + async fn workspace_runtime_detail( api: &WorkspaceApi, workspace_id: &str, @@ -15775,6 +15908,7 @@ async fn workspace_runtime_detail( removable: false, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, + binding: Some(runtime_binding_summary(&binding)), }, }); } @@ -15858,27 +15992,118 @@ fn project_runtime_trust_audit( }) } -fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> { - validate_public_runtime_id(request.runtime_id.trim())?; - let endpoint = request.endpoint.trim(); - if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) +async fn validate_runtime_connection_request( + request: &CreateRemoteRuntimeRequest, +) -> ApiResult { + validate_public_runtime_id(request.public_bundle.identity_id.trim())?; + if request.public_bundle.public_key.trim().is_empty() { + return Err(settings_bad_request( + "runtime_public_key_required", + "Runtime public bundle must contain a public key", + )); + } + let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| { + settings_bad_request( + "invalid_remote_runtime_endpoint", + "endpoint must be an absolute https URL", + ) + })?; + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() { return Err(settings_bad_request( - "invalid_remote_runtime_endpoint", - "endpoint must be an absolute http or https URL", + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint must be an https origin without credentials, query, or fragment", + )); + } + let host = endpoint.host_str().expect("checked above"); + if host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(is_disallowed_runtime_address) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", + )); + } + let port = endpoint.port_or_known_default().unwrap_or(443); + let addresses = tokio::time::timeout( + std::time::Duration::from_secs(3), + tokio::net::lookup_host((host, port)), + ) + .await + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_timeout", + "Runtime endpoint DNS resolution timed out", + ) + })? + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_failed", + "Runtime endpoint DNS resolution failed", + ) + })? + .collect::>(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| is_disallowed_runtime_address(address.ip())) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", )); } if request .display_name .as_deref() - .is_some_and(|value| value.chars().any(char::is_control)) + .is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) { return Err(settings_bad_request( "invalid_remote_runtime_display_name", - "display_name cannot contain control characters", + "display_name must be non-empty when supplied and cannot contain control characters", )); } - Ok(()) + Ok(endpoint) +} + +fn is_disallowed_runtime_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_unspecified() + || address.is_broadcast() + || address.is_documentation() + || address.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || octets[0] >= 240 + } + IpAddr::V6(address) => { + let segments = address.segments(); + address.is_loopback() + || address.is_unspecified() + || address.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || address + .to_ipv4_mapped() + .is_some_and(|address| is_disallowed_runtime_address(IpAddr::V4(address))) + } + } } fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { @@ -18062,6 +18287,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -19730,21 +19959,158 @@ mod tests { assert!(!serialized.contains("materialized_path")); } - #[test] - fn runtime_connection_request_validation_bounds_browser_input() { - let ok = CreateRemoteRuntimeRequest { - runtime_id: "team-runtime_1".to_string(), - display_name: Some("Team Runtime".to_string()), - endpoint: "https://runtime.example".to_string(), - token_ref: None, + #[tokio::test] + async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let actor = test_owner_actor(); + api.signing_identities + .provision_existing(&api.config.workspace_id, &actor.account_id) + .unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); + let request = CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "configured-runtime".to_string(), + public_key: runtime_identity.public_key, + }, + display_name: Some("Configured Runtime".to_string()), + endpoint: "https://8.8.8.8".to_string(), + expected_revision: None, }; - assert!(validate_runtime_connection_request(&ok).is_ok()); + let mut non_owner = actor.clone(); + non_owner.user_id = "other-user".to_string(); + non_owner.account_id = "other-account".to_string(); + non_owner.handle = "other".to_string(); + let denied = create_remote_runtime( + State(api.clone()), + Extension(non_owner), + Json(request.clone()), + ) + .await + .unwrap_err(); + assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); + + let (status, Json(created)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(request.clone()), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + let binding = created.management.binding.as_ref().unwrap(); + assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured); + assert_eq!( + binding.authentication_mode, + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + ); + assert_eq!(binding.revision, 1); + assert!(binding.workspace_key_id.is_some()); + assert_eq!(binding.workspace_key_generation, Some(1)); + assert!(!created.runtime.worker_creation_available); + let configured_test = scoped_test_runtime_connection( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "configured-runtime".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!( + configured_test.into_response().status(), + StatusCode::CONFLICT + ); + + let (status, Json(replayed)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(request.clone()), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!(replayed.management.binding.unwrap().revision, 1); + + let mut mismatched = request.clone(); + mismatched.display_name = Some("Different Runtime".to_string()); + let error = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(mismatched.clone()), + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::CONFLICT); + + mismatched.expected_revision = Some(1); + let (status, Json(replaced)) = + create_remote_runtime(State(api.clone()), Extension(actor), Json(mismatched)) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!(replaced.management.binding.unwrap().revision, 2); + + let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap(); + let unknown_revision = create_remote_runtime( + State(api.clone()), + Extension(test_owner_actor()), + Json(CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "unknown-runtime".to_string(), + public_key: unknown_identity.public_key, + }, + display_name: None, + endpoint: "https://8.8.4.4".to_string(), + expected_revision: Some(9), + }), + ) + .await + .unwrap_err(); + assert_eq!( + unknown_revision.into_response().status(), + StatusCode::CONFLICT + ); + + let cross_workspace = scoped_create_remote_runtime( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: "another-workspace".to_string(), + }), + Extension(test_owner_actor()), + Json(request), + ) + .await + .unwrap_err(); + assert_eq!( + cross_workspace.into_response().status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn runtime_connection_request_validation_bounds_browser_input() { + let identity = RuntimeIdentityMaterial::generate("team-runtime_1").unwrap(); + let ok = CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "team-runtime_1".to_string(), + public_key: identity.public_key, + }, + display_name: Some("Team Runtime".to_string()), + endpoint: "https://8.8.8.8".to_string(), + expected_revision: None, + }; + assert!(validate_runtime_connection_request(&ok).await.is_ok()); let bad_endpoint = CreateRemoteRuntimeRequest { - endpoint: "/tmp/socket".to_string(), + endpoint: "http://169.254.169.254/latest/meta-data".to_string(), ..ok }; - assert!(validate_runtime_connection_request(&bad_endpoint).is_err()); + assert!( + validate_runtime_connection_request(&bad_endpoint) + .await + .is_err() + ); } #[test] @@ -23752,6 +24118,10 @@ mod tests { public_key: first.public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: now.clone(), updated_at: now, revoked_at: None, @@ -24450,6 +24820,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -24717,6 +25091,7 @@ mod tests { assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED); let mut revoked = trust; + revoked.state = StoredRuntimeBindingState::Revoked; revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); authority @@ -25913,6 +26288,10 @@ mod tests { .public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -27043,6 +27422,10 @@ mod tests { public_key: identity.public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -27094,9 +27477,12 @@ mod tests { "POST", &runtimes_uri, Some(serde_json::json!({ - "runtime_id": "keyless-runtime", + "public_bundle": { + "identity_id": "keyless-runtime", + "public_key": "" + }, "display_name": "Keyless Runtime", - "endpoint": "https://keyless.runtime.invalid" + "endpoint": "https://8.8.8.8" })), StatusCode::BAD_REQUEST, ) @@ -27203,6 +27589,10 @@ mod tests { .public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b7e72337..ca9ea392 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -18,12 +18,14 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 54; +const LATEST_SCHEMA_VERSION: i64 = 55; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations"; const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority"; +const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str = + "Workspace Runtime binding state and identity mode"; const MIGRATIONS: &[Migration] = &[ Migration { @@ -46,6 +48,11 @@ const MIGRATIONS: &[Migration] = &[ name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME, apply: migrate_workspace_signing_identity_v53_to_v54, }, + Migration { + version: 55, + name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME, + apply: migrate_workspace_runtime_binding_state_v54_to_v55, + }, ]; #[derive(Clone, Copy)] @@ -210,11 +217,49 @@ pub struct WorkspaceRuntimeBinding { pub public_key: String, pub public_key_fingerprint: String, pub binding_revision: u64, + pub state: WorkspaceRuntimeBindingState, + pub authentication_mode: WorkspaceRuntimeAuthenticationMode, + pub workspace_key_id: Option, + pub workspace_key_generation: Option, pub created_at: String, pub updated_at: String, pub revoked_at: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeBindingState { + Configured, + Verified, + Revoked, +} + +impl WorkspaceRuntimeBindingState { + fn as_str(self) -> &'static str { + match self { + Self::Configured => "configured", + Self::Verified => "verified", + Self::Revoked => "revoked", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeAuthenticationMode { + LegacyServerIssuer, + WorkspaceIdentity, +} + +impl WorkspaceRuntimeAuthenticationMode { + fn as_str(self) -> &'static str { + match self { + Self::LegacyServerIssuer => "legacy_server_issuer", + Self::WorkspaceIdentity => "workspace_identity", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceRuntimeBindingUpsert { Created, @@ -1716,13 +1761,15 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let sql = if include_revoked { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 ORDER BY runtime_id ASC"# } else { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND revoked_at IS NULL ORDER BY runtime_id ASC"# @@ -1744,7 +1791,8 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -1770,7 +1818,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1783,7 +1832,11 @@ impl SqliteWorkspaceStore { && existing.display_name == record.display_name && existing.base_url == record.base_url && existing.public_key == record.public_key - && existing.public_key_fingerprint == record.public_key_fingerprint; + && existing.public_key_fingerprint == record.public_key_fingerprint + && existing.state == record.state + && existing.authentication_mode == record.authentication_mode + && existing.workspace_key_id == record.workspace_key_id + && existing.workspace_key_generation == record.workspace_key_generation; if exact_active_match { tx.commit()?; return Ok(WorkspaceRuntimeBindingUpsert::Unchanged); @@ -1798,7 +1851,9 @@ impl SqliteWorkspaceStore { r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, public_key_fingerprint = ?6, binding_revision = binding_revision + 1, - updated_at = ?7, revoked_at = ?8 + state = ?7, authentication_mode = ?8, + workspace_key_id = ?9, workspace_key_generation = ?10, + updated_at = ?11, revoked_at = ?12 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -1807,6 +1862,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.updated_at, record.revoked_at, ], @@ -1818,8 +1877,9 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#, params![ record.workspace_id, record.runtime_id, @@ -1827,6 +1887,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.created_at, record.updated_at, record.revoked_at, @@ -1850,7 +1914,7 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let changed = conn.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3, + SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = binding_revision + 1 WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, params![workspace_id, runtime_id, revoked_at], @@ -1877,7 +1941,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1887,8 +1952,14 @@ impl SqliteWorkspaceStore { if let Some(existing) = existing { if existing.revoked_at.is_none() + && existing.display_name == record.display_name + && existing.base_url == record.base_url && existing.public_key == record.public_key && existing.public_key_fingerprint == record.public_key_fingerprint + && existing.state == record.state + && existing.authentication_mode == record.authentication_mode + && existing.workspace_key_id == record.workspace_key_id + && existing.workspace_key_generation == record.workspace_key_generation { tx.commit()?; return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); @@ -1932,14 +2003,23 @@ impl SqliteWorkspaceStore { })?; tx.execute( r#"UPDATE workspace_runtime_bindings - SET public_key = ?3, public_key_fingerprint = ?4, - binding_revision = ?5, updated_at = ?6, revoked_at = NULL + SET display_name = ?3, base_url = ?4, + public_key = ?5, public_key_fingerprint = ?6, + state = ?7, authentication_mode = ?8, + workspace_key_id = ?9, workspace_key_generation = ?10, + binding_revision = ?11, updated_at = ?12, revoked_at = NULL WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, record.runtime_id, + record.display_name, + record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, next_revision, record.updated_at, ], @@ -1957,7 +2037,8 @@ impl SqliteWorkspaceStore { )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1991,8 +2072,9 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, NULL)"#, params![ record.workspace_id, record.runtime_id, @@ -2000,6 +2082,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.created_at, record.updated_at, ], @@ -2037,7 +2123,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -2063,7 +2150,7 @@ impl SqliteWorkspaceStore { .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; tx.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3, binding_revision = ?4 + SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = ?4 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id, revoked_at, next_revision], )?; @@ -2080,7 +2167,8 @@ impl SqliteWorkspaceStore { )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -6339,6 +6427,29 @@ fn account_select_sql(where_clause: &str) -> String { fn read_workspace_runtime_binding( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { + let state = match row.get::<_, String>(7)?.as_str() { + "configured" => WorkspaceRuntimeBindingState::Configured, + "verified" => WorkspaceRuntimeBindingState::Verified, + "revoked" => WorkspaceRuntimeBindingState::Revoked, + value => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + format!("unknown Workspace Runtime binding state {value}").into(), + )); + } + }; + let authentication_mode = match row.get::<_, String>(8)?.as_str() { + "legacy_server_issuer" => WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + "workspace_identity" => WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + value => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 8, + rusqlite::types::Type::Text, + format!("unknown Workspace Runtime authentication mode {value}").into(), + )); + } + }; Ok(WorkspaceRuntimeBinding { workspace_id: row.get(0)?, runtime_id: row.get(1)?, @@ -6347,9 +6458,13 @@ fn read_workspace_runtime_binding( public_key: row.get(4)?, public_key_fingerprint: row.get(5)?, binding_revision: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - revoked_at: row.get(9)?, + state, + authentication_mode, + workspace_key_id: row.get(9)?, + workspace_key_generation: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + revoked_at: row.get(13)?, }) } @@ -6405,6 +6520,39 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) } record.public_key = canonical; record.public_key_fingerprint = fingerprint; + match record.authentication_mode { + WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => { + if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() { + return Err(Error::InvalidInput( + "legacy Server issuer Runtime bindings must not carry Workspace key metadata" + .into(), + )); + } + } + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity => { + let key_id = record.workspace_key_id.as_deref().ok_or_else(|| { + Error::InvalidInput( + "Workspace identity Runtime bindings require workspace_key_id".into(), + ) + })?; + validate_identifier("workspace_key_id", key_id)?; + if record + .workspace_key_generation + .is_none_or(|generation| generation == 0) + { + return Err(Error::InvalidInput( + "Workspace identity Runtime bindings require a positive workspace_key_generation" + .into(), + )); + } + } + } + let revoked = record.revoked_at.is_some(); + if (record.state == WorkspaceRuntimeBindingState::Revoked) != revoked { + return Err(Error::InvalidInput( + "Runtime binding state and revoked_at must agree".into(), + )); + } Ok(()) } @@ -7581,10 +7729,89 @@ fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<() verify_workspace_signing_identity_schema(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![ - LATEST_SCHEMA_VERSION, - WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME - ], + params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_binding_state_v54_to_v55(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 54 { + return Err(Error::Store(format!( + "expected schema version 54 before {WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let columns = table_columns(&tx, "workspace_runtime_bindings")?; + let lifecycle_columns = [ + "state", + "authentication_mode", + "workspace_key_id", + "workspace_key_generation", + ]; + let present = lifecycle_columns + .iter() + .filter(|column| columns.iter().any(|existing| existing == **column)) + .count(); + if present == 0 { + tx.execute_batch( + r#" + CREATE TABLE workspace_runtime_bindings_v55 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 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_key_generation INTEGER CHECK (workspace_key_generation > 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_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) + ); + INSERT INTO workspace_runtime_bindings_v55 ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) + SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, + CASE WHEN revoked_at IS NULL THEN 'verified' ELSE 'revoked' END, + 'legacy_server_issuer', NULL, NULL, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings; + DROP TABLE workspace_runtime_bindings; + ALTER TABLE workspace_runtime_bindings_v55 RENAME TO workspace_runtime_bindings; + CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); + "#, + )?; + } else if present != lifecycle_columns.len() { + return Err(Error::Store( + "workspace_runtime_bindings has a partially applied lifecycle schema".to_string(), + )); + } + verify_workspace_runtime_binding_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![55_i64, WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME], )?; tx.commit()?; Ok(()) @@ -7711,7 +7938,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { let columns = table_columns(conn, "workspace_runtime_bindings")? .into_iter() .collect::>(); - let expected = [ + let has_binding_state = columns.contains("state"); + let mut expected = [ "workspace_id", "runtime_id", "display_name", @@ -7726,11 +7954,42 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .into_iter() .map(str::to_string) .collect::>(); + if has_binding_state { + expected.extend( + [ + "state", + "authentication_mode", + "workspace_key_id", + "workspace_key_generation", + ] + .into_iter() + .map(str::to_string), + ); + } if columns != expected { return Err(Error::Store( - "workspace_runtime_bindings schema does not match schema-52".to_string(), + "workspace_runtime_bindings schema does not match the supported schema".to_string(), )); } + if has_binding_state { + let invalid_lifecycle_count = conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE state NOT IN ('configured', 'verified', 'revoked') + OR authentication_mode NOT IN ('legacy_server_issuer', 'workspace_identity') + OR (authentication_mode = 'workspace_identity' AND + (workspace_key_id IS NULL OR workspace_key_generation IS NULL)) + OR (state = 'revoked' AND revoked_at IS NULL) + OR (state <> 'revoked' AND revoked_at IS NOT NULL)", + [], + |row| row.get::<_, i64>(0), + )?; + if invalid_lifecycle_count != 0 { + return Err(Error::Store( + "workspace_runtime_bindings contains invalid binding lifecycle metadata" + .to_string(), + )); + } + } let revision_default = conn.query_row( "SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'", [], @@ -7807,24 +8066,62 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .to_string(), )); } - let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - FROM workspace_runtime_bindings"#, - )?; - let rows = stmt.query_map([], read_workspace_runtime_binding)?; - for row in rows { - let binding = row?; - let mut normalized = binding.clone(); - normalize_workspace_runtime_binding_key(&mut normalized)?; - if normalized.public_key != binding.public_key - || normalized.public_key_fingerprint != binding.public_key_fingerprint - { - return Err(Error::Store(format!( - "Runtime binding `{}/{}` has non-canonical trust content", - binding.workspace_id, binding.runtime_id - ))); + if has_binding_state { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings"#, + )?; + let rows = stmt.query_map([], read_workspace_runtime_binding)?; + for row in rows { + verify_canonical_workspace_runtime_binding(row?)?; } + } else { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(WorkspaceRuntimeBinding { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + display_name: row.get(2)?, + base_url: row.get(3)?, + public_key: row.get(4)?, + public_key_fingerprint: row.get(5)?, + binding_revision: row.get(6)?, + state: if row.get::<_, Option>(9)?.is_some() { + WorkspaceRuntimeBindingState::Revoked + } else { + WorkspaceRuntimeBindingState::Verified + }, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, + created_at: row.get(7)?, + updated_at: row.get(8)?, + revoked_at: row.get(9)?, + }) + })?; + for row in rows { + verify_canonical_workspace_runtime_binding(row?)?; + } + } + Ok(()) +} + +fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> { + let mut normalized = binding.clone(); + normalize_workspace_runtime_binding_key(&mut normalized)?; + if normalized.public_key != binding.public_key + || normalized.public_key_fingerprint != binding.public_key_fingerprint + { + return Err(Error::Store(format!( + "Runtime binding `{}/{}` has non-canonical trust content", + binding.workspace_id, binding.runtime_id + ))); } Ok(()) } @@ -8601,6 +8898,10 @@ mod tests { version: 54, name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 55, + name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(), + }, ] ); @@ -8623,6 +8924,10 @@ mod tests { (52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()), (53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()), (54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()), + ( + 55, + WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -8634,6 +8939,26 @@ mod tests { )?, 1 ); + assert_eq!( + conn.query_row( + "SELECT state || ':' || authentication_mode + FROM workspace_runtime_bindings + WHERE workspace_id='workspace-a' AND runtime_id='shared'", + [], + |row| row.get::<_, String>(0), + )?, + "verified:legacy_server_issuer" + ); + assert!( + conn.execute( + "UPDATE workspace_runtime_bindings + SET state='configured', authentication_mode='workspace_identity' + WHERE workspace_id='workspace-a' AND runtime_id='shared'", + [], + ) + .is_err(), + "migrated schema must reject Workspace identity mode without key metadata" + ); assert_eq!( conn.query_row( "SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE workspace_id='workspace-a' AND runtime_id='shared' AND jti='jti-1'", @@ -8672,7 +8997,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54] + vec![52, 53, 54, 55] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -8680,7 +9005,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 6); } #[test] @@ -8948,6 +9273,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -9068,6 +9397,10 @@ mod tests { public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Configured, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-a".to_string()), + workspace_key_generation: Some(1), created_at: at.to_string(), updated_at: at.to_string(), revoked_at: None, @@ -9082,11 +9415,30 @@ mod tests { .unwrap(); assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); assert_eq!(created_binding.binding_revision, 1); + assert_eq!( + created_binding.state, + WorkspaceRuntimeBindingState::Configured + ); + assert_eq!( + created_binding.authentication_mode, + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + ); + assert_eq!(created_binding.workspace_key_id.as_deref(), Some("WK-a")); + assert_eq!(created_binding.workspace_key_generation, Some(1)); let (replayed, replayed_binding) = store .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") .unwrap(); assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); assert_eq!(replayed_binding.binding_revision, 1); + let mut mismatched_replay = binding(replayed_binding.public_key.clone(), "2"); + mismatched_replay.base_url = "https://different.runtime.test".to_string(); + assert!(matches!( + store.put_workspace_runtime_binding_key(mismatched_replay, None, "owner"), + Err(Error::RuntimeBindingRevisionConflict { + expected: None, + actual: Some(1) + }) + )); let stale = store .put_workspace_runtime_binding_key( @@ -9117,6 +9469,7 @@ mod tests { assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); assert_eq!(revoked_binding.binding_revision, 3); assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); + assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked); let (reactivated, reactivated_binding) = store .put_workspace_runtime_binding_key( binding(second.public_key.clone(), "5"), @@ -9126,6 +9479,10 @@ mod tests { .unwrap(); assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); assert_eq!(reactivated_binding.binding_revision, 4); + assert_eq!( + reactivated_binding.state, + WorkspaceRuntimeBindingState::Configured + ); let mut duplicate = binding(second.public_key, "6"); duplicate.runtime_id = "runtime-b".to_string(); @@ -9176,6 +9533,10 @@ mod tests { public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -10330,13 +10691,13 @@ INSERT INTO worker_registry ( let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (56, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 55 is newer"), "{error}"); + assert!(error.contains("schema version 56 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 7dfee19a..2cd5f0a9 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -344,12 +344,30 @@ export type RuntimeSummary = { diagnostics: Array; }; +export type WorkspaceRuntimeBindingState = + | "configured" + | "verified" + | "revoked"; + +export type WorkspaceRuntimeAuthenticationMode = + | "legacy_server_issuer" + | "workspace_identity"; + +export type WorkspaceRuntimeBindingSummary = { + state: WorkspaceRuntimeBindingState; + authentication_mode: WorkspaceRuntimeAuthenticationMode; + revision: number; + workspace_key_id?: string | null; + workspace_key_generation?: number | null; +}; + export type RuntimeManagementSummary = { built_in: boolean; config_managed: boolean; removable: boolean; endpoint_configured: boolean; token_ref_configured: boolean; + binding?: WorkspaceRuntimeBindingSummary | null; }; export type WorkspaceRuntimeResource = { @@ -418,6 +436,18 @@ export type RuntimeTrustConflictResponse = { current_fingerprint?: string | null; }; +export type RuntimePublicIdentityBundle = { + identity_id: string; + public_key: string; +}; + +export type CreateRemoteRuntimeRequest = { + public_bundle: RuntimePublicIdentityBundle; + display_name?: string | null; + endpoint: string; + expected_revision?: number | null; +}; + export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestFailureKind =