diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 6de8b825..a7e5030f 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -438,14 +438,13 @@ CREATE TABLE workspace_runtime_bindings ( runtime_id TEXT NOT NULL, display_name TEXT NOT NULL, base_url TEXT NOT NULL, - public_key TEXT, - public_key_fingerprint TEXT, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, 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), - CHECK ((public_key IS NULL) = (public_key_fingerprint IS NULL)), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT ); CREATE TABLE typed_ticket_artifacts ( diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index ecfc7e1a..b1e1d7f5 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -322,8 +322,8 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box) -> Result<(), Box>(); + let expected_runtime_bindings = Arc::new( + store + .list_workspace_runtime_bindings(&config.workspace_id, false) + .await? + .into_iter() + .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) + .filter(|binding| { + configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) + }) + .map(|binding| (binding.runtime_id.clone(), binding)) + .collect::>(), + ); let api = Self::new_with_execution_backend_and_broker( config, store, @@ -1615,9 +1634,13 @@ impl WorkspaceApi { ) .await?; api.runtime.set_runtime_binding_gate(move |runtime_id| { - runtime_binding_store - .workspace_runtime_binding_is_active(&runtime_binding_workspace_id, runtime_id) - .unwrap_or(false) + expected_runtime_bindings + .get(runtime_id) + .is_some_and(|expected| { + runtime_binding_store + .workspace_runtime_binding_matches(expected) + .unwrap_or(false) + }) }); Ok(api) } @@ -12404,7 +12427,7 @@ async fn list_workers( } async fn create_remote_runtime( - State(api): State, + State(_api): State, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { validate_runtime_connection_request(&request)?; @@ -12425,52 +12448,10 @@ async fn create_remote_runtime( "remote Runtime token_ref persistence is not supported", )); } - let now = Utc::now().to_rfc3339(); - let binding = WorkspaceRuntimeBinding { - workspace_id: api.config.workspace_id.clone(), - runtime_id: id.clone(), - display_name: request - .display_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(&id) - .to_owned(), - base_url: request.endpoint.trim().to_string(), - public_key: None, - public_key_fingerprint: None, - created_at: now.clone(), - updated_at: now, - revoked_at: None, - }; - api.store - .upsert_workspace_runtime_binding_record(binding.clone(), false) - .await?; - let active_config = remote_runtime_config_from_binding(&binding).map_err(|diagnostic| { - ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id: binding.runtime_id.clone(), - code: diagnostic.code.clone(), - message: diagnostic.message.clone(), - }, - vec![diagnostic], - ) - })?; - let active_runtime = RemoteWorkerRuntime::new( - active_config, - api.config.workspace_id.clone(), - api.config - .backend_base_url - .clone() - .unwrap_or_else(|| "http://127.0.0.1:8787".to_string()), - ) - .map(|host| host.with_resource_broker(api.resource_broker.clone())) - .map_err(|err| err.into_error())?; - api.runtime.register_or_replace(active_runtime); - let resource = workspace_runtime_resource_by_id(&api, &id) - .await? - .ok_or_else(|| Error::UnknownRuntime(id.clone()))?; - Ok((StatusCode::CREATED, Json(resource))) + Err(settings_bad_request( + "runtime_public_key_required", + "remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available", + )) } async fn delete_remote_runtime( @@ -14482,19 +14463,6 @@ async fn workspace_runtime_resources_response( }) } -async fn workspace_runtime_resource_by_id( - api: &WorkspaceApi, - runtime_id: &str, -) -> ApiResult> { - Ok( - workspace_runtime_resources_response(api, &api.config.workspace_id) - .await? - .items - .into_iter() - .find(|resource| resource.runtime.runtime_id == runtime_id), - ) -} - fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> { validate_public_runtime_id(request.runtime_id.trim())?; let endpoint = request.endpoint.trim(); @@ -14538,6 +14506,7 @@ fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { Ok(()) } +#[cfg(test)] fn remote_runtime_config_from_binding( binding: &WorkspaceRuntimeBinding, ) -> std::result::Result { @@ -16030,12 +15999,14 @@ fn worker_create_not_accepted_error( } fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: code.to_string(), - message: message.to_string(), - } - .into() + ApiError::with_diagnostics( + Error::InvalidInput(format!("{code}: {message}")), + vec![settings_diagnostic( + code, + DiagnosticSeverity::Error, + message, + )], + ) } fn settings_diagnostic( @@ -16879,8 +16850,8 @@ mod tests { runtime_id: runtime_id.to_owned(), display_name: runtime_id.to_owned(), base_url: "https://runtime.test".to_owned(), - public_key: Some(identity.public_key.clone()), - public_key_fingerprint: None, + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -22908,8 +22879,8 @@ mod tests { runtime_id: "runtime-remote".to_string(), display_name: "Remote Runtime".to_string(), base_url: "https://runtime.invalid".to_string(), - public_key: Some(identity.public_key.clone()), - public_key_fingerprint: None, + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -25421,6 +25392,31 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let store = api.store.clone(); + let identity = RuntimeIdentityMaterial::generate("team-runtime").unwrap(); + let binding = WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "team-runtime".to_string(), + display_name: "Team Runtime".to_string(), + base_url: "https://runtime.example.invalid".to_string(), + public_key: identity.public_key, + public_key_fingerprint: String::new(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }; + store + .upsert_workspace_runtime_binding_record(binding.clone(), false) + .await + .unwrap(); + let remote = remote_runtime_config_from_binding(&binding).unwrap(); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + remote, + TEST_WORKSPACE_ID.to_string(), + "http://127.0.0.1:8787".to_string(), + ) + .unwrap(), + ); let app = build_inner_router(api); let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"); @@ -25450,19 +25446,31 @@ mod tests { ) .await; - let added = request_json( + let rejected = request_json( app.clone(), "POST", &runtimes_uri, Some(serde_json::json!({ - "runtime_id": "team-runtime", - "display_name": "Team Runtime", - "endpoint": "https://runtime.example.invalid" + "runtime_id": "keyless-runtime", + "display_name": "Keyless Runtime", + "endpoint": "https://keyless.runtime.invalid" })), - StatusCode::CREATED, + StatusCode::BAD_REQUEST, ) .await; - assert_eq!(added["runtime_id"], "team-runtime"); + assert!( + rejected["message"] + .as_str() + .unwrap_or_default() + .contains("runtime_public_key_required") + ); + let listed = get_json(app.clone(), &runtimes_uri).await; + let added = listed["items"] + .as_array() + .unwrap() + .iter() + .find(|runtime| runtime["runtime_id"] == "team-runtime") + .unwrap(); assert_eq!(added["management"]["config_managed"], true); assert_eq!(added["management"]["endpoint_configured"], true); let projected = serde_json::to_string(&added).unwrap(); @@ -25530,20 +25538,34 @@ mod tests { }); let dir = tempfile::tempdir().unwrap(); - let app = test_app(dir.path()).await; - let added = request_json( - app.clone(), - "POST", - &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"), - Some(serde_json::json!({ - "runtime_id": "busy-runtime", - "display_name": "Busy Runtime", - "endpoint": format!("http://{runtime_addr}") - })), - StatusCode::CREATED, - ) - .await; - assert_eq!(added["runtime_id"], "busy-runtime"); + let endpoint = format!("http://{runtime_addr}"); + let api = test_api(dir.path()).await; + let binding = WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "busy-runtime".to_string(), + display_name: "Busy Runtime".to_string(), + base_url: endpoint, + public_key: RuntimeIdentityMaterial::generate("busy-runtime") + .unwrap() + .public_key, + public_key_fingerprint: String::new(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }; + api.store + .upsert_workspace_runtime_binding_record(binding.clone(), false) + .await + .unwrap(); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + remote_runtime_config_from_binding(&binding).unwrap(), + TEST_WORKSPACE_ID.to_string(), + "http://127.0.0.1:8787".to_string(), + ) + .unwrap(), + ); + let app = build_inner_router(api); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] @@ -25602,8 +25624,10 @@ mod tests { runtime_id: "probe-runtime".to_string(), display_name: "Probe Runtime".to_string(), base_url: endpoint.clone(), - public_key: None, - public_key_fingerprint: None, + public_key: RuntimeIdentityMaterial::generate("probe-runtime") + .unwrap() + .public_key, + public_key_fingerprint: String::new(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), revoked_at: None, @@ -25672,8 +25696,10 @@ mod tests { runtime_id: "control-only-runtime".to_string(), display_name: "Control-only Runtime".to_string(), base_url: endpoint, - public_key: None, - public_key_fingerprint: None, + public_key: RuntimeIdentityMaterial::generate("control-only-runtime") + .unwrap() + .public_key, + public_key_fingerprint: String::new(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), revoked_at: None, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 6d04b945..db52cae2 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -104,8 +104,8 @@ pub struct WorkspaceRuntimeBinding { pub runtime_id: String, pub display_name: String, pub base_url: String, - pub public_key: Option, - pub public_key_fingerprint: Option, + pub public_key: String, + pub public_key_fingerprint: String, pub created_at: String, pub updated_at: String, pub revoked_at: Option, @@ -544,11 +544,8 @@ pub trait ControlPlaneStore: Send + Sync { &self, record: &WorkspaceBootstrapRecord, ) -> Result; - fn workspace_runtime_binding_is_active( - &self, - workspace_id: &str, - runtime_id: &str, - ) -> Result; + fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding) + -> Result; async fn get_workspace_runtime_binding( &self, workspace_id: &str, @@ -1840,15 +1837,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn workspace_runtime_binding_is_active( + fn workspace_runtime_binding_matches( &self, - workspace_id: &str, - runtime_id: &str, + expected: &WorkspaceRuntimeBinding, ) -> Result { - Ok( - SqliteWorkspaceStore::get_workspace_runtime_binding(self, workspace_id, runtime_id)? - .is_some_and(|binding| binding.revoked_at.is_none()), - ) + Ok(SqliteWorkspaceStore::get_workspace_runtime_binding( + self, + &expected.workspace_id, + &expected.runtime_id, + )? + .is_some_and(|binding| binding == *expected && binding.revoked_at.is_none())) } async fn get_workspace_runtime_binding( @@ -5316,28 +5314,15 @@ pub fn normalize_runtime_public_key(public_key: &str) -> Result<(String, String) } fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) -> Result<()> { - match (&record.public_key, &record.public_key_fingerprint) { - (None, None) => Ok(()), - (Some(public_key), None) => { - let (canonical, fingerprint) = normalize_runtime_public_key(public_key)?; - record.public_key = Some(canonical); - record.public_key_fingerprint = Some(fingerprint); - Ok(()) - } - (Some(public_key), Some(public_key_fingerprint)) => { - let (canonical, fingerprint) = normalize_runtime_public_key(public_key)?; - if public_key_fingerprint != &fingerprint { - return Err(Error::InvalidInput( - "Runtime public key fingerprint does not match the public key".into(), - )); - } - record.public_key = Some(canonical); - Ok(()) - } - (None, Some(_)) => Err(Error::InvalidInput( - "Runtime public key fingerprint requires a public key".into(), - )), + let (canonical, fingerprint) = normalize_runtime_public_key(&record.public_key)?; + if !record.public_key_fingerprint.is_empty() && record.public_key_fingerprint != fingerprint { + return Err(Error::InvalidInput( + "Runtime public key fingerprint does not match the public key".into(), + )); } + record.public_key = canonical; + record.public_key_fingerprint = fingerprint; + Ok(()) } fn map_runtime_binding_write_error(err: rusqlite::Error) -> Error { @@ -6034,6 +6019,19 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( } fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { + migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( + conn, + verify_workspace_runtime_binding_schema, + ) +} + +fn migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( + conn: &Connection, + verify: F, +) -> Result<()> +where + F: FnOnce(&Connection) -> Result<()>, +{ let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; let legacy_columns = table_columns(&tx, "trusted_runtime_records")? .into_iter() @@ -6108,8 +6106,8 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() runtime_id, display_name, base_url, - public_key: Some(public_key), - public_key_fingerprint: Some(fingerprint), + public_key, + public_key_fingerprint: fingerprint, created_at, updated_at, revoked_at, @@ -6126,10 +6124,7 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() binding.workspace_id, binding.runtime_id ))); } - let fingerprint = binding - .public_key_fingerprint - .clone() - .expect("normalized key"); + let fingerprint = binding.public_key_fingerprint.clone(); if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { return Err(Error::Store(format!( "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", @@ -6184,14 +6179,13 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() runtime_id TEXT NOT NULL, display_name TEXT NOT NULL, base_url TEXT NOT NULL, - public_key TEXT, - public_key_fingerprint TEXT, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, 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), - CHECK ((public_key IS NULL) = (public_key_fingerprint IS NULL)), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT ); CREATE INDEX idx_workspace_runtime_bindings_workspace @@ -6246,10 +6240,12 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() "schema-{LATEST_SCHEMA_VERSION} migration produced {foreign_key_failures} foreign-key violation(s)" ))); } + verify(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![LATEST_SCHEMA_VERSION, RUNTIME_BINDINGS_MIGRATION_NAME], )?; + verify_current_schema_history(&tx)?; tx.commit()?; Ok(()) } @@ -6997,13 +6993,8 @@ mod tests { .unwrap() .unwrap(); assert_eq!(binding.revoked_at.as_deref(), Some("2")); - assert!(binding.public_key.is_some()); - assert!( - binding - .public_key_fingerprint - .as_deref() - .is_some_and(|value| value.starts_with("sha256:")) - ); + assert!(!binding.public_key.is_empty()); + assert!(binding.public_key_fingerprint.starts_with("sha256:")); store .with_conn(|conn| { let jti_workspace: String = conn.query_row( @@ -7073,6 +7064,53 @@ mod tests { ); } + #[test] + fn schema_v50_runtime_migration_rolls_back_when_final_verification_fails() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + + let error = migrate_workspace_runtime_bindings_v50_to_v51_with_verifier(&conn, |_| { + Err(Error::Store( + "forced final verification failure".to_string(), + )) + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("forced final verification failure") + ); + let version: i64 = conn + .query_row( + "SELECT MAX(version) FROM __yoi_schema_migrations", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, PREVIOUS_SCHEMA_VERSION); + assert!( + !table_columns(&conn, "trusted_runtime_records") + .unwrap() + .is_empty() + ); + assert!( + table_columns(&conn, "workspace_runtime_bindings") + .unwrap() + .is_empty() + ); + let jti: String = conn + .query_row( + "SELECT jti FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(jti, "jti-1"); + } + #[test] fn runtime_binding_identity_and_trust_uniqueness_are_workspace_scoped() { let temp = tempfile::tempdir().unwrap(); @@ -7099,8 +7137,8 @@ mod tests { runtime_id: runtime_id.to_string(), display_name: runtime_id.to_string(), base_url: format!("https://{workspace_id}.runtime.test"), - public_key: Some(identity.public_key.clone()), - public_key_fingerprint: None, + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7141,9 +7179,32 @@ mod tests { .len(), 1 ); + let expected_workspace_a = store + .get_workspace_runtime_binding("workspace-a", "shared") + .unwrap() + .unwrap(); assert!( store - .revoke_workspace_runtime_binding("workspace-a", "shared", "2") + .workspace_runtime_binding_matches(&expected_workspace_a) + .unwrap() + ); + let mut replaced_workspace_a = expected_workspace_a.clone(); + replaced_workspace_a.base_url = "https://replacement.runtime.test".to_string(); + replaced_workspace_a.updated_at = "2".to_string(); + assert_eq!( + store + .upsert_workspace_runtime_binding(replaced_workspace_a, true) + .unwrap(), + WorkspaceRuntimeBindingUpsert::Replaced + ); + assert!( + !store + .workspace_runtime_binding_matches(&expected_workspace_a) + .unwrap() + ); + assert!( + store + .revoke_workspace_runtime_binding("workspace-a", "shared", "3") .unwrap() ); drop(store); @@ -7197,8 +7258,8 @@ mod tests { runtime_id: crate::hosts::EMBEDDED_RUNTIME_ID.to_string(), display_name: "Embedded Runtime".to_string(), base_url: "in-process://embedded".to_string(), - public_key: Some(public_key), - public_key_fingerprint: None, + public_key, + public_key_fingerprint: String::new(), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7220,10 +7281,7 @@ mod tests { .get_workspace_runtime_binding("workspace-a", crate::hosts::EMBEDDED_RUNTIME_ID) .unwrap() .unwrap(); - assert_eq!( - persisted.public_key.as_deref(), - Some(second.public_key.as_str()) - ); + assert_eq!(persisted.public_key, second.public_key); } #[test] diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 363ab80d..308b116a 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -60,12 +60,9 @@ pub async fn verify_runtime_request_source_proof_with_store( .get_workspace_runtime_binding(workspace_id, &unverified.iss) .await .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - .filter(|record| record.revoked_at.is_none() && record.public_key.is_some()) - .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - let public_key = trusted - .public_key - .as_deref() + .filter(|record| record.revoked_at.is_none()) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + let public_key = trusted.public_key.as_str(); let expected = RuntimeRequestSourceExpectation { identity_id: &unverified.iss, audience: audience.as_ref(), @@ -209,12 +206,9 @@ async fn verify_worker_remove_source_with( .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .await .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - .filter(|record| record.revoked_at.is_none() && record.public_key.is_some()) - .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - let public_key = trusted - .public_key - .as_deref() + .filter(|record| record.revoked_at.is_none()) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + let public_key = trusted.public_key.as_str(); let expected = WorkerMutationSourceExpectation { runtime_id: &unverified.iss, audience: audience.as_ref(),