diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index af52b7d0..ec7ce820 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -5,12 +5,10 @@ use std::{fs, io}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; -pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173"; @@ -51,31 +49,6 @@ fn default_browser_public_url() -> String { DEFAULT_BROWSER_PUBLIC_URL.to_string() } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct BackendRuntimesConfigFile { - #[serde(default)] - pub runtimes: WorkspaceBackendRuntimesConfig, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendRuntimesConfig { - #[serde(default)] - pub remote: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RemoteRuntimeConfigFile { - pub id: String, - pub endpoint: String, - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub token_ref: Option, -} - #[derive(Clone)] pub struct ResolvedWorkspaceBackendConfig { pub server: ServerConfig, @@ -124,80 +97,11 @@ impl ServerHostConfigFile { } } -impl BackendRuntimesConfigFile { - pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { - config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) - } - - pub fn default_path() -> Option { - manifest::paths::config_dir().map(Self::path_for_config_dir) - } - - pub fn load_default() -> Result { - match Self::default_path() { - Some(path) => Self::load_from_path(path), - None => Ok(Self::default()), - } - } - - pub fn load_from_config_dir(config_dir: impl AsRef) -> Result { - Self::load_from_path(Self::path_for_config_dir(config_dir)) - } - - pub fn load_from_path(path: impl AsRef) -> Result { - let path = path.as_ref(); - match fs::read_to_string(path) { - Ok(raw) => Self::parse_str(&raw, path), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn write_default(&self) -> Result { - let path = Self::default_path().ok_or_else(|| { - Error::Config( - "YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config" - .to_string(), - ) - })?; - self.write_to_path(&path)?; - Ok(path) - } - - pub fn write_to_config_dir(&self, config_dir: impl AsRef) -> Result<()> { - self.write_to_path(Self::path_for_config_dir(config_dir)) - } - - pub fn write_to_path(&self, path: impl AsRef) -> Result<()> { - let path = path.as_ref(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self).map_err(|error| { - Error::Config(format!( - "failed to serialize Backend runtimes config: {error}" - )) - })?; - fs::write(path, raw)?; - Ok(()) - } - - pub fn parse_str(raw: &str, path: impl AsRef) -> Result { - toml::from_str(raw).map_err(|error| { - Error::Config(format!( - "failed to parse Backend runtimes config `{}`: {error}", - path.as_ref().display() - )) - }) - } -} - impl ResolvedWorkspaceBackendConfig { pub fn local_dev( workspace_root: impl AsRef, identity: WorkspaceIdentity, host_config: &ServerHostConfigFile, - runtime_config: &BackendRuntimesConfigFile, ) -> Result { let workspace_root = workspace_root.as_ref(); let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id); @@ -208,12 +112,7 @@ impl ResolvedWorkspaceBackendConfig { server.database_path = database_path.clone(); server.embedded_runtime_store_root = data_root.join("embedded-runtime"); server.max_records = DEFAULT_MAX_RECORDS; - server.remote_runtime_sources = runtime_config - .runtimes - .remote - .iter() - .map(resolve_remote_runtime) - .collect::>>()?; + server.remote_runtime_sources = Vec::new(); server.auth = AuthConfig::Passkey { rp_id: browser_rp_id, origin: browser_public_url.clone(), @@ -252,26 +151,6 @@ fn normalize_required_string(field: &str, value: &str) -> Result { Ok(trimmed.to_string()) } -pub(crate) fn resolve_remote_runtime( - config: &RemoteRuntimeConfigFile, -) -> Result { - if let Some(token_ref) = config.token_ref.as_deref() { - return Err(Error::Config(format!( - "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet", - config.id - ))); - } - Ok(RemoteRuntimeConfig::new( - config.id.clone(), - config - .display_name - .clone() - .unwrap_or_else(|| config.id.clone()), - config.endpoint.clone(), - None, - )) -} - fn resolve_browser_public_url(value: &str) -> Result<(String, String)> { let value = normalize_required_string("browser.public_url", value)?; let url = Url::parse(&value).map_err(|error| { @@ -314,22 +193,19 @@ mod tests { } } - fn resolved_with_runtimes( - runtimes: &BackendRuntimesConfigFile, - ) -> ResolvedWorkspaceBackendConfig { + fn resolved() -> ResolvedWorkspaceBackendConfig { let dir = tempfile::tempdir().unwrap(); ResolvedWorkspaceBackendConfig::local_dev( dir.path(), identity(), &ServerHostConfigFile::default(), - runtimes, ) .unwrap() } #[test] fn default_settings_resolve_without_a_repository_file() { - let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()); + let resolved = resolved(); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); let AuthConfig::Passkey { @@ -354,7 +230,7 @@ mod tests { #[test] fn backend_base_url_is_explicit_and_normalized() { let listen = "127.0.0.1:48787".parse().unwrap(); - let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()) + let resolved = resolved() .with_listen(listen) .with_backend_base_url("http://127.0.0.1:48787/"); @@ -376,7 +252,6 @@ mod tests { tempfile::tempdir().unwrap().path(), identity(), &host_config, - &BackendRuntimesConfigFile::default(), ) .unwrap(); @@ -407,7 +282,6 @@ mod tests { tempfile::tempdir().unwrap().path(), identity(), &host_config, - &BackendRuntimesConfigFile::default(), ); let error = match result { Ok(_) => panic!("expected {value} to be rejected"), @@ -446,92 +320,8 @@ mod tests { } #[test] - fn backend_runtimes_config_loads_from_config_dir() { - let dir = tempfile::tempdir().unwrap(); - let config = BackendRuntimesConfigFile { - runtimes: WorkspaceBackendRuntimesConfig { - remote: vec![RemoteRuntimeConfigFile { - id: "arc".to_string(), - endpoint: "http://127.0.0.1:38800".to_string(), - display_name: Some("arc".to_string()), - token_ref: None, - }], - }, - }; - config.write_to_config_dir(dir.path()).unwrap(); - let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap(); - assert_eq!(loaded, config); - assert_eq!( - BackendRuntimesConfigFile::path_for_config_dir(dir.path()), - dir.path().join("runtimes.toml") - ); - } - - #[test] - fn backend_runtimes_config_is_the_only_runtime_source() { - let runtime_config = BackendRuntimesConfigFile::parse_str( - r#" -[[runtimes.remote]] -id = "arc" -endpoint = "http://xdg.example.test" -display_name = "xdg arc" -"#, - "runtimes.toml", - ) - .unwrap(); - let resolved = resolved_with_runtimes(&runtime_config); - assert_eq!(resolved.server.remote_runtime_sources.len(), 1); - assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); - assert_eq!( - resolved.server.remote_runtime_sources[0].base_url.as_str(), - "http://xdg.example.test" - ); - } - - #[test] - fn token_value_field_is_not_in_runtime_schema() { - let error = BackendRuntimesConfigFile::parse_str( - r#" -[[runtimes.remote]] -id = "remote" -endpoint = "http://127.0.0.1:8790" -token = "secret" -"#, - "runtimes.toml", - ) - .unwrap_err(); - assert!( - error.to_string().contains("unknown field"), - "unexpected error: {error}" - ); - } - - #[test] - fn token_ref_fails_closed_until_secret_resolution_exists() { - let runtime_config = BackendRuntimesConfigFile::parse_str( - r#" -[[runtimes.remote]] -id = "remote" -endpoint = "http://127.0.0.1:8790" -token_ref = "local:remote-token" -"#, - "runtimes.toml", - ) - .unwrap(); - let error = match ResolvedWorkspaceBackendConfig::local_dev( - tempfile::tempdir().unwrap().path(), - identity(), - &ServerHostConfigFile::default(), - &runtime_config, - ) { - Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), - Err(error) => error, - }; - assert!( - error - .to_string() - .contains("secret ref resolution is not implemented"), - "unexpected error: {error}" - ); + fn local_host_config_does_not_supply_runtime_authority() { + let resolved = resolved(); + assert!(resolved.server.remote_runtime_sources.is_empty()); } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 7f530a8d..e7708b27 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -60,7 +60,7 @@ use worker_runtime::retention::{ WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory, }; -pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; +pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host"; const MAX_DIAGNOSTICS: usize = 16; diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 038dd530..6de8b825 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -433,15 +433,21 @@ CREATE TABLE ticket_worker_assignments ( (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) ) ); -CREATE TABLE trusted_runtime_records ( - runtime_id TEXT PRIMARY KEY, +CREATE TABLE workspace_runtime_bindings ( + 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 TEXT, + public_key_fingerprint TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - revoked_at TEXT -, workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT); + 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 ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, PRIMARY KEY (workspace_id, ticket_id, relative_path), @@ -651,11 +657,12 @@ CREATE TABLE worker_diagnostics_archives ( FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); CREATE TABLE worker_mutation_source_proof_jtis ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, jti TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at TEXT NOT NULL, - PRIMARY KEY (runtime_id, jti) + PRIMARY KEY (workspace_id, runtime_id, jti) ); CREATE TABLE worker_orphan_diagnostics ( diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, @@ -857,8 +864,8 @@ CREATE INDEX idx_ticket_worker_assignments_principal ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); CREATE INDEX idx_ticket_worker_assignments_ticket ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); -CREATE INDEX idx_trusted_runtime_records_workspace - ON trusted_runtime_records(workspace_id, revoked_at, runtime_id); +CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); CREATE INDEX idx_typed_ticket_relations_workspace_target ON typed_ticket_relations(workspace_id, target, at DESC); CREATE INDEX idx_typed_tickets_workspace_state_updated diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 87d47955..7f1cc685 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -40,7 +40,7 @@ pub use authority::{ ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, WorkspaceAuthority, }; -pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; +pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader}; @@ -118,6 +118,8 @@ pub enum Error { WorkspacePermissionDenied(String), #[error("Workspace config update conflict: {0}")] WorkspaceConfigConflict(String), + #[error("Runtime binding conflict: {0}")] + RuntimeBindingConflict(String), #[error("Repository conflict: {0}")] RepositoryConflict(String), #[error("Registry inconsistency: {0}")] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index a20981e7..ecfc7e1a 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,10 +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, TrustedRuntimeRecord}; +use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; use yoi_workspace_server::{ - BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, - ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, + ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, + WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, }; #[derive(Debug)] @@ -315,40 +315,47 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box + "created", + yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged => + "unchanged", + yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced => + "replaced", + } + ); println!("server_db={}", database_path.display()); Ok(()) } "list" => { + let mut workspace_id = None; let mut json = false; let mut include_revoked = false; while let Some(arg) = args.pop_front() { let (flag, inline_value) = split_flag_value(arg)?; match flag.as_str() { + "--workspace-id" => { + workspace_id = Some(take_value(&flag, inline_value, &mut args)?) + } "--json" => { ensure_no_inline_value(&flag, inline_value.as_deref())?; json = true; @@ -364,17 +371,20 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box) -> Result<(), Box { + let mut workspace_id = None; let mut runtime_id = None; while let Some(arg) = args.pop_front() { let (flag, inline_value) = split_flag_value(arg)?; match flag.as_str() { + "--workspace-id" => { + workspace_id = Some(take_value(&flag, inline_value, &mut args)?) + } "--runtime-id" => { runtime_id = Some(take_value(&flag, inline_value, &mut args)?) } @@ -396,11 +410,14 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box) -> Result<(), Box Result<(), Box> { - if store - .list_trusted_runtimes(true)? - .iter() - .any(|runtime| runtime.runtime_id == runtime_id) - && !replace - { - return Err(Box::new(CliError(format!( - "trusted runtime `{runtime_id}` already exists; pass --replace to update it" - )))); - } - Ok(()) -} - fn split_flag_value(arg: String) -> Result<(String, Option), CliError> { if let Some((flag, value)) = arg.split_once('=') { if flag.is_empty() { @@ -543,16 +542,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box ServerHostConfigFile::load_from_path(path)?, None => ServerHostConfigFile::load_default()?, }; - let runtime_config = BackendRuntimesConfigFile::load_default()?; - let mut resolved = ResolvedWorkspaceBackendConfig::local_dev( - &workspace_root, - identity, - &host_config, - &runtime_config, - )?; + let mut resolved = + ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?; resolved.database_path = database_path.clone(); resolved.server.database_path = database_path.clone(); - append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?; + append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?; if let Some(listen) = options.listen { resolved = resolved.with_listen(listen); } @@ -572,22 +566,38 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box, ) -> Result<(), Box> { + let workspaces = store.list_workspaces()?; + let bindings = workspaces + .iter() + .map(|workspace| { + store + .list_workspace_runtime_bindings(&workspace.workspace_id, false) + .map(|bindings| { + bindings + .into_iter() + .filter(|binding| { + binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID + }) + .collect::>() + }) + }) + .collect::, _>>()? + .into_iter() + .flatten() + .collect::>(); let Some(server_identity) = read_server_identity_file(&server_identity_path())? else { - if !store.list_trusted_runtimes(false)?.is_empty() { + if !bindings.is_empty() { return Err(Box::new(CliError( - "trusted runtimes are registered but server identity is not initialized; run `yoi-server identity init`".to_string(), + "Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(), ))); } return Ok(()); }; - for runtime in store.list_trusted_runtimes(false)? { - let Some(workspace_id) = runtime.workspace_id.clone() else { - continue; - }; + for runtime in bindings { let auth = RemoteRuntimeAuthConfig { server_id: server_identity.identity.identity_id.clone(), server_private_key: server_identity.identity.private_key.clone(), @@ -598,9 +608,12 @@ fn append_trusted_runtime_sources( runtime.base_url, None, ) - .with_workspace_id(workspace_id) + .with_workspace_id(runtime.workspace_id.clone()) .with_auth(auth); - remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id); + remote_runtime_sources.retain(|existing| { + existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str()) + || existing.runtime_id != runtime.runtime_id + }); remote_runtime_sources.push(remote); } Ok(()) @@ -731,7 +744,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list --workspace-id [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -743,7 +756,7 @@ fn print_skills_help() { fn print_serve_help() { println!( - "yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" + "yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and Runtime bindings are loaded from the Server DB.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" ); } @@ -823,30 +836,51 @@ mod tests { } #[test] - fn trusted_runtime_add_requires_replace_for_existing_record() { + fn runtime_binding_requires_explicit_replace_for_changed_authority() { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap(); + let path = temp.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + rusqlite::Connection::open(&path) + .unwrap() + .execute_batch( + "INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) + VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');", + ) + .unwrap(); let public_key = RuntimeIdentityMaterial::generate("runtime-a") .unwrap() .public_key; + let binding = WorkspaceRuntimeBinding { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "Runtime A".to_string(), + base_url: "http://127.0.0.1:18080".to_string(), + public_key: Some(public_key), + public_key_fingerprint: None, + created_at: "2026-07-26T00:00:00Z".to_string(), + updated_at: "2026-07-26T00:00:00Z".to_string(), + revoked_at: None, + }; store - .upsert_trusted_runtime(&TrustedRuntimeRecord { - runtime_id: "runtime-a".to_string(), - workspace_id: None, - display_name: "Runtime A".to_string(), - base_url: "http://127.0.0.1:18080".to_string(), - public_key, - created_at: "2026-07-26T00:00:00Z".to_string(), - updated_at: "2026-07-26T00:00:00Z".to_string(), - revoked_at: None, - }) + .upsert_workspace_runtime_binding(binding.clone(), false) .unwrap(); - - let error = ensure_trusted_runtime_replace_allowed(&store, "runtime-a", false).unwrap_err(); - assert_eq!( - error.to_string(), - "trusted runtime `runtime-a` already exists; pass --replace to update it" + assert!(matches!( + store + .upsert_workspace_runtime_binding(binding.clone(), false) + .unwrap(), + yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged + )); + let mut changed = binding; + changed.base_url = "http://127.0.0.1:18081".to_string(); + assert!( + store + .upsert_workspace_runtime_binding(changed.clone(), false) + .is_err() ); - ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap(); + store + .upsert_workspace_runtime_binding(changed, true) + .unwrap(); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3599f26d..f674cc9f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -102,7 +102,6 @@ use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, }; -use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime}; use crate::config_source::ConfigCommitRequest; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, @@ -149,7 +148,7 @@ use crate::store::{ RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, - WorkspaceResourceKind, + WorkspaceResourceKind, WorkspaceRuntimeBinding, }; use crate::workdir_removal::{ WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation, @@ -189,7 +188,6 @@ pub struct ServerConfig { pub repositories: Vec, pub runtime_event_sources: Vec, pub remote_runtime_sources: Vec, - pub runtime_config_path: Option, pub backend_base_url: Option, } @@ -217,7 +215,6 @@ impl ServerConfig { repositories: Vec::new(), runtime_event_sources: Vec::new(), remote_runtime_sources: Vec::new(), - runtime_config_path: BackendRuntimesConfigFile::default_path(), backend_base_url: None, } } @@ -1561,18 +1558,28 @@ impl WorkspaceApi { pub async fn new(config: ServerConfig, store: Arc) -> Result { let resource_broker = BackendResourceBroker::default(); + if store.get_workspace(&config.workspace_id).await?.is_none() { + return Err(crate::Error::Config(format!( + "Workspace {} is not registered in the Server DB", + config.workspace_id + ))); + } let embedded_identity = (*EMBEDDED_RUNTIME_REQUEST_IDENTITY).clone(); store - .upsert_trusted_runtime_record(&crate::store::TrustedRuntimeRecord { - runtime_id: EMBEDDED_RUNTIME_ID.to_owned(), - workspace_id: None, - display_name: "Embedded Runtime".to_owned(), - base_url: "in-process://embedded".to_owned(), - public_key: embedded_identity.public_key.clone(), - created_at: config.workspace_created_at.clone(), - updated_at: config.workspace_created_at.clone(), - revoked_at: None, - }) + .upsert_workspace_runtime_binding_record( + crate::store::WorkspaceRuntimeBinding { + workspace_id: config.workspace_id.clone(), + runtime_id: EMBEDDED_RUNTIME_ID.to_owned(), + display_name: "Embedded Runtime".to_owned(), + base_url: "in-process://embedded".to_owned(), + public_key: Some(embedded_identity.public_key.clone()), + public_key_fingerprint: None, + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + revoked_at: None, + }, + false, + ) .await?; let embedded_audience = format!("embedded:{}", config.workspace_id); let worker_remove_dispatcher = Arc::new( @@ -8528,11 +8535,9 @@ async fn scoped_list_runtimes( AxumPath(path): AxumPath, ) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; - let runtime_config = load_backend_runtimes_config_for_settings(&api)?; - Ok(Json(workspace_runtime_resources_response( - &api, - &runtime_config, - ))) + Ok(Json( + workspace_runtime_resources_response(&api, &api.config.workspace_id).await?, + )) } async fn scoped_workspace_protocol_ws( @@ -12395,7 +12400,6 @@ async fn create_remote_runtime( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { validate_runtime_connection_request(&request)?; - let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; let id = request.runtime_id.trim().to_string(); if id == EMBEDDED_WORKER_RUNTIME_ID { return Err(settings_bad_request( @@ -12413,32 +12417,31 @@ async fn create_remote_runtime( "remote Runtime token_ref persistence is not supported", )); } - if runtime_config - .runtimes - .remote - .iter() - .any(|remote| remote.id == id) - { - return Err(settings_bad_request( - "remote_runtime_already_exists", - "a remote Runtime with that id already exists", - )); - } - let remote_config = RemoteRuntimeConfigFile { - id: id.clone(), - endpoint: request.endpoint.trim().to_string(), + 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()) - .map(ToOwned::to_owned), - token_ref: None, + .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, }; - let active_config = remote_runtime_config_from_file(&remote_config).map_err(|diagnostic| { + 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: remote_config.id.clone(), + runtime_id: binding.runtime_id.clone(), code: diagnostic.code.clone(), message: diagnostic.message.clone(), }, @@ -12455,10 +12458,9 @@ async fn create_remote_runtime( ) .map(|host| host.with_resource_broker(api.resource_broker.clone())) .map_err(|err| err.into_error())?; - runtime_config.runtimes.remote.push(remote_config); - write_backend_runtimes_config_for_settings(&api, &runtime_config)?; api.runtime.register_or_replace(active_runtime); - let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id) + let resource = workspace_runtime_resource_by_id(&api, &id) + .await? .ok_or_else(|| Error::UnknownRuntime(id.clone()))?; Ok((StatusCode::CREATED, Json(resource))) } @@ -12473,15 +12475,12 @@ async fn delete_remote_runtime( "the embedded Runtime is built in and cannot be deleted", )); } - let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; - let before = runtime_config.runtimes.remote.len(); - runtime_config - .runtimes - .remote - .retain(|remote| remote.id != runtime_id); - if before == runtime_config.runtimes.remote.len() { - return Err(Error::UnknownRuntime(runtime_id).into()); - } + let binding = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) + .await? + .filter(|binding| binding.revoked_at.is_none()) + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; match api .runtime .unregister_if_idle(&runtime_id, api.config.max_records.min(200)) @@ -12510,7 +12509,14 @@ async fn delete_remote_runtime( )); } } - write_backend_runtimes_config_for_settings(&api, &runtime_config)?; + let now = Utc::now().to_rfc3339(); + if !api + .store + .revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now) + .await? + { + return Err(Error::UnknownRuntime(runtime_id).into()); + } Ok(StatusCode::NO_CONTENT) } @@ -12518,14 +12524,13 @@ async fn test_runtime_connection( State(api): State, AxumPath(runtime_id): AxumPath, ) -> ApiResult> { - let runtime_config = load_backend_runtimes_config_for_settings(&api)?; - let remote = runtime_config - .runtimes - .remote - .iter() - .find(|remote| remote.id == runtime_id) + let binding = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) + .await? + .filter(|binding| binding.revoked_at.is_none()) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; - Ok(Json(test_remote_runtime_config(&api, remote).await)) + Ok(Json(test_remote_runtime_binding(&api, &binding).await)) } async fn get_worker_launch_options( @@ -14384,92 +14389,49 @@ fn workers_response( }) } -fn load_backend_runtimes_config_for_settings( +async fn workspace_runtime_resources_response( api: &WorkspaceApi, -) -> ApiResult { - api.config - .runtime_config_path - .as_ref() - .map(BackendRuntimesConfigFile::load_from_path) - .transpose() - .map_err(|error| { - Error::Config(format!( - "failed to read Backend runtimes config for Runtime connections: {}", - sanitize_backend_error(&error.to_string()) - )) - .into() - }) - .map(|config| config.unwrap_or_default()) -} - -fn write_backend_runtimes_config_for_settings( - api: &WorkspaceApi, - runtime_config: &BackendRuntimesConfigFile, -) -> ApiResult<()> { - let path = api.config.runtime_config_path.as_ref().ok_or_else(|| { - Error::Config( - "Backend runtimes config path is unavailable; set YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME" - .to_string(), - ) - })?; - runtime_config.write_to_path(path).map_err(|error| { - Error::Config(format!( - "failed to write Backend runtimes config for Runtime connections: {}", - sanitize_backend_error(&error.to_string()) - )) - .into() - }) -} - -fn workspace_runtime_resources_response( - api: &WorkspaceApi, - runtime_config: &BackendRuntimesConfigFile, -) -> workspace_api::ListResponse { + workspace_id: &str, +) -> ApiResult> { let limit = api.config.max_records.min(200); let runtimes = api.runtime.list_runtimes(limit); + let bindings = api + .store + .list_workspace_runtime_bindings(workspace_id, false) + .await?; let mut items = runtimes .items .into_iter() .map(|runtime| { - let remote = runtime_config - .runtimes - .remote + let binding = bindings .iter() - .find(|remote| remote.id == runtime.runtime_id); + .find(|binding| binding.runtime_id == runtime.runtime_id); let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; WorkspaceRuntimeResource { runtime: runtime.into(), management: RuntimeManagementSummary { built_in, - config_managed: remote.is_some(), - removable: remote.is_some() && !built_in, - endpoint_configured: remote - .is_some_and(|remote| !remote.endpoint.trim().is_empty()), - token_ref_configured: remote.is_some_and(|remote| { - remote - .token_ref - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - }), + config_managed: binding.is_some(), + removable: binding.is_some() && !built_in, + endpoint_configured: binding + .is_some_and(|binding| !binding.base_url.trim().is_empty()), + token_ref_configured: false, }, } }) .collect::>(); - for remote in &runtime_config.runtimes.remote { + for binding in &bindings { if items .iter() - .any(|resource| resource.runtime.runtime_id == remote.id) + .any(|resource| resource.runtime.runtime_id == binding.runtime_id) { continue; } items.push(WorkspaceRuntimeResource { runtime: workspace_api::RuntimeSummary { - runtime_id: remote.id.clone(), - label: remote - .display_name - .clone() - .unwrap_or_else(|| remote.id.clone()), + runtime_id: binding.runtime_id.clone(), + label: binding.display_name.clone(), kind: "remote_http".to_string(), status: "unavailable".to_string(), source: workspace_api::RuntimeSourceSummary { @@ -14477,7 +14439,7 @@ fn workspace_runtime_resources_response( status: workspace_api::RuntimeSourceStatus::Reserved, identity_authority: workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, - note: "The configured Runtime is not present in the active Runtime registry." + note: "The registered Runtime is not present in the active Runtime registry." .to_string(), }, host_ids: Vec::new(), @@ -14486,9 +14448,9 @@ fn workspace_runtime_resources_response( arch: String::new(), diagnostics: vec![ settings_diagnostic( - "configured_runtime_unavailable", + "registered_runtime_unavailable", DiagnosticSeverity::Warning, - "The configured Runtime is not present in the active Runtime registry.", + "The registered Runtime is not present in the active Runtime registry.", ) .into(), ], @@ -14497,33 +14459,32 @@ fn workspace_runtime_resources_response( built_in: false, config_managed: true, removable: true, - endpoint_configured: !remote.endpoint.trim().is_empty(), - token_ref_configured: remote - .token_ref - .as_deref() - .is_some_and(|value| !value.trim().is_empty()), + endpoint_configured: !binding.base_url.trim().is_empty(), + token_ref_configured: false, }, }); } - workspace_api::ListResponse { - workspace_id: api.config.workspace_id.clone(), + Ok(workspace_api::ListResponse { + workspace_id: workspace_id.to_string(), limit, items, - source: "workspace-runtime-resources".to_string(), + source: "workspace-runtime-bindings".to_string(), diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(), - } + }) } -fn workspace_runtime_resource_by_id( +async fn workspace_runtime_resource_by_id( api: &WorkspaceApi, - runtime_config: &BackendRuntimesConfigFile, runtime_id: &str, -) -> Option { - workspace_runtime_resources_response(api, runtime_config) - .items - .into_iter() - .find(|resource| resource.runtime.runtime_id == runtime_id) +) -> 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<()> { @@ -14569,46 +14530,24 @@ fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { Ok(()) } -fn remote_runtime_config_from_file( - remote: &RemoteRuntimeConfigFile, +fn remote_runtime_config_from_binding( + binding: &WorkspaceRuntimeBinding, ) -> std::result::Result { - resolve_remote_runtime(remote).map_err(|err| { - settings_diagnostic( - "remote_runtime_apply_failed", - DiagnosticSeverity::Error, - err.to_string(), - ) - }) + let remote = RemoteRuntimeConfig::new( + binding.runtime_id.clone(), + binding.display_name.clone(), + binding.base_url.clone(), + None, + ) + .with_workspace_id(binding.workspace_id.clone()); + Ok(remote) } -async fn test_remote_runtime_config( +async fn test_remote_runtime_binding( api: &WorkspaceApi, - remote: &RemoteRuntimeConfigFile, + remote: &WorkspaceRuntimeBinding, ) -> RuntimeConnectionTestResponse { let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - if remote - .token_ref - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - return RuntimeConnectionTestResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), - checked_at, - state: "rejected".to_string(), - protocol_version: None, - compatibility_basis: "not_checked_token_ref_unsupported".to_string(), - capabilities: Vec::new(), - health_result: "not_checked".to_string(), - diagnostics: vec![settings_diagnostic( - "remote_runtime_token_ref_unsupported", - DiagnosticSeverity::Error, - "Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.", - ) - .into()], - }; - } - let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() @@ -14866,7 +14805,7 @@ async fn test_remote_runtime_config( RuntimeConnectionTestResponse { workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), + runtime_id: remote.runtime_id.clone(), checked_at, state: observation.state().to_string(), protocol_version, @@ -14889,14 +14828,14 @@ async fn test_remote_runtime_config( fn remote_runtime_test_failed( api: &WorkspaceApi, - remote: &RemoteRuntimeConfigFile, + remote: &WorkspaceRuntimeBinding, checked_at: String, code: impl Into, message: impl Into, ) -> RuntimeConnectionTestResponse { RuntimeConnectionTestResponse { workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), + runtime_id: remote.runtime_id.clone(), checked_at, state: "failed".to_string(), protocol_version: None, @@ -14953,10 +14892,10 @@ impl RuntimeCompatibilityObservation { } fn remote_probe_url( - remote: &RemoteRuntimeConfigFile, + remote: &WorkspaceRuntimeBinding, path: &str, ) -> std::result::Result { - let endpoint = remote.endpoint.trim(); + let endpoint = remote.base_url.trim(); if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) { return Err(settings_diagnostic( "remote_runtime_endpoint_invalid", @@ -16400,6 +16339,7 @@ impl IntoResponse for ApiError { Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) | Error::WorkspaceConfigConflict(_) + | Error::RuntimeBindingConflict(_) | Error::RepositoryConflict(_) => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { @@ -16539,7 +16479,6 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { use super::*; - use crate::config::WorkspaceBackendRuntimesConfig; use axum::body::{Body, to_bytes}; use axum::http::Request; use futures::{SinkExt, StreamExt}; @@ -16565,7 +16504,7 @@ mod tests { use crate::store::{ AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord, - SqliteWorkspaceStore, TrustedRuntimeRecord, UserRecord, WorkspaceRecord, + SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, }; fn handler_source<'a>(source: &'a str, name: &str) -> &'a str { @@ -16926,16 +16865,20 @@ mod tests { }); SqliteWorkspaceStore::open(&api.config.database_path) .unwrap() - .upsert_trusted_runtime(&TrustedRuntimeRecord { - runtime_id: runtime_id.to_owned(), - workspace_id: Some(api.workspace_id().to_owned()), - display_name: runtime_id.to_owned(), - base_url: "https://runtime.test".to_owned(), - public_key: identity.public_key.clone(), - created_at: "2026-01-01T00:00:00Z".to_owned(), - updated_at: "2026-01-01T00:00:00Z".to_owned(), - revoked_at: None, - }) + .upsert_workspace_runtime_binding( + WorkspaceRuntimeBinding { + workspace_id: api.workspace_id().to_owned(), + 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, + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + revoked_at: None, + }, + false, + ) .unwrap(); } @@ -18901,13 +18844,59 @@ mod tests { ); } + #[test] + fn catalog_runtime_sources_are_scoped_by_workspace_and_runtime_id() { + let mut base = test_server_config(tempfile::tempdir().unwrap().path()); + base.remote_runtime_sources = vec![ + RemoteRuntimeConfig::new("shared", "A", "https://a.runtime.test", None) + .with_workspace_id("workspace-a"), + RemoteRuntimeConfig::new("shared", "B", "https://b.runtime.test", None) + .with_workspace_id("workspace-b"), + ]; + let workspace = WorkspaceRecord { + workspace_id: "workspace-b".to_string(), + display_name: "Workspace B".to_string(), + state: "active".to_string(), + owner_account_id: "owner-account".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + let source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Https, + uri: "https://example.test/org/repository.git".to_string(), + }; + let repositories = vec![RepositoryRecord { + workspace_id: "workspace-b".to_string(), + repository_id: "main".to_string(), + repository_key: "main".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + default_ref: Some("main".to_string()), + source_revision: 1, + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }]; + let scoped = base + .for_catalog_workspace(&workspace, repositories) + .unwrap(); + assert_eq!(scoped.remote_runtime_sources.len(), 1); + assert_eq!(scoped.remote_runtime_sources[0].runtime_id, "shared"); + assert_eq!( + scoped.remote_runtime_sources[0].base_url, + "https://b.runtime.test" + ); + } + fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity()) .with_embedded_runtime_store_root(store_root); config.database_path = workspace_root.join(".test-yoi-server.db"); - config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml")); let source = workspace_api::RepositorySource { kind: workspace_api::RepositorySourceKind::LocalPath, uri: workspace_root.display().to_string(), @@ -22906,17 +22895,20 @@ mod tests { }) .await .unwrap(); - let trust = crate::store::TrustedRuntimeRecord { + let trust = crate::store::WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), runtime_id: "runtime-remote".to_string(), - workspace_id: Some(TEST_WORKSPACE_ID.to_string()), display_name: "Remote Runtime".to_string(), base_url: "https://runtime.invalid".to_string(), - public_key: identity.public_key.clone(), + public_key: Some(identity.public_key.clone()), + public_key_fingerprint: None, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, }; - store.upsert_trusted_runtime(&trust).unwrap(); + store + .upsert_workspace_runtime_binding(trust.clone(), false) + .unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -23179,7 +23171,9 @@ mod tests { let mut revoked = trust; revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); - authority.upsert_trusted_runtime(&revoked).unwrap(); + authority + .upsert_workspace_runtime_binding(revoked, true) + .unwrap(); let revoked_token = signer .issue_worker_remove( "server-main", @@ -23190,16 +23184,20 @@ mod tests { 60, ) .unwrap(); - assert!(matches!( - crate::worker_source::verify_worker_remove_source( - &api, - crate::worker_source::PresentedWorkerMutationSourceProof::Remote(&revoked_token), - "runtime-target", - "target-worker", - ) - .await, - Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust) - )); + let revoked_result = crate::worker_source::verify_worker_remove_source( + &api, + crate::worker_source::PresentedWorkerMutationSourceProof::Remote(&revoked_token), + "runtime-target", + "target-worker", + ) + .await; + assert!( + matches!( + revoked_result, + Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust) + ), + "unexpected revoked trust result: {revoked_result:?}" + ); } fn seed_worker_source_member(api: &WorkspaceApi, runtime_id: &str, worker_id: &str) { @@ -24431,7 +24429,7 @@ mod tests { .await; assert!(matches!( result, - Err(crate::worker_source::WorkerMutationSourceProofError::WrongWorkspace) + Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust) )); } @@ -25413,7 +25411,9 @@ mod tests { #[tokio::test] async fn runtime_rest_resource_create_list_and_delete_apply_live_registry() { let dir = tempfile::tempdir().unwrap(); - let app = test_app(dir.path()).await; + let api = test_api(dir.path()).await; + let store = api.store.clone(); + let app = build_inner_router(api); let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"); let initial = get_json(app.clone(), &runtimes_uri).await; @@ -25460,16 +25460,14 @@ mod tests { let projected = serde_json::to_string(&added).unwrap(); assert!(!projected.contains("runtime.example.invalid")); - let persisted = BackendRuntimesConfigFile::load_from_path( - dir.path().join(".test-config/runtimes.toml"), - ) - .unwrap(); - assert_eq!(persisted.runtimes.remote.len(), 1); - assert_eq!(persisted.runtimes.remote[0].id, "team-runtime"); - assert_eq!( - persisted.runtimes.remote[0].endpoint, - "https://runtime.example.invalid" - ); + let persisted = store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime") + .await + .unwrap() + .unwrap(); + assert_eq!(persisted.runtime_id, "team-runtime"); + assert_eq!(persisted.base_url, "https://runtime.example.invalid"); + assert!(persisted.revoked_at.is_none()); let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; let runtimes = launch_options["runtimes"].as_array().unwrap(); @@ -25501,11 +25499,12 @@ mod tests { .iter() .any(|runtime| runtime["runtime_id"] == "team-runtime") ); - let persisted = BackendRuntimesConfigFile::load_from_path( - dir.path().join(".test-config/runtimes.toml"), - ) - .unwrap(); - assert!(persisted.runtimes.remote.is_empty()); + let persisted = store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime") + .await + .unwrap() + .unwrap(); + assert!(persisted.revoked_at.is_some()); } #[tokio::test(flavor = "multi_thread")] @@ -25568,11 +25567,6 @@ mod tests { .iter() .any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" }) ); - let persisted = BackendRuntimesConfigFile::load_from_path( - dir.path().join(".test-config/runtimes.toml"), - ) - .unwrap(); - assert_eq!(persisted.runtimes.remote.len(), 1); } #[tokio::test(flavor = "multi_thread")] @@ -25592,19 +25586,25 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let endpoint = format!("http://{runtime_addr}"); - BackendRuntimesConfigFile { - runtimes: WorkspaceBackendRuntimesConfig { - remote: vec![RemoteRuntimeConfigFile { - id: "probe-runtime".to_string(), - endpoint: endpoint.clone(), - display_name: Some("Probe Runtime".to_string()), - token_ref: None, - }], - }, - } - .write_to_path(dir.path().join(".test-config/runtimes.toml")) - .unwrap(); - let app = test_app(dir.path()).await; + let api = test_api(dir.path()).await; + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "probe-runtime".to_string(), + display_name: "Probe Runtime".to_string(), + base_url: endpoint.clone(), + public_key: None, + public_key_fingerprint: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let app = build_inner_router(api); let response = post_json( app, @@ -25656,19 +25656,25 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let endpoint = format!("http://{runtime_addr}"); - BackendRuntimesConfigFile { - runtimes: WorkspaceBackendRuntimesConfig { - remote: vec![RemoteRuntimeConfigFile { - id: "control-only-runtime".to_string(), - display_name: Some("Control-only Runtime".to_string()), - endpoint, - token_ref: None, - }], - }, - } - .write_to_path(dir.path().join(".test-config/runtimes.toml")) - .unwrap(); - let app = test_app(dir.path()).await; + let api = test_api(dir.path()).await; + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "control-only-runtime".to_string(), + display_name: "Control-only Runtime".to_string(), + base_url: endpoint, + public_key: None, + public_key_fingerprint: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let app = build_inner_router(api); let response = post_json( app, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index efca949f..37bc6f9b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -14,7 +15,9 @@ use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::{Error, Result}; -const LATEST_SCHEMA_VERSION: i64 = 50; +const PREVIOUS_SCHEMA_VERSION: i64 = 50; +const LATEST_SCHEMA_VERSION: i64 = 51; +const RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const MIGRATIONS: &[Migration] = &[Migration { version: LATEST_SCHEMA_VERSION, @@ -96,17 +99,25 @@ pub struct WorkspaceBootstrapResult { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TrustedRuntimeRecord { +pub struct WorkspaceRuntimeBinding { + pub workspace_id: String, pub runtime_id: String, - pub workspace_id: Option, pub display_name: String, pub base_url: String, - pub public_key: String, + pub public_key: Option, + pub public_key_fingerprint: Option, pub created_at: String, pub updated_at: String, pub revoked_at: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceRuntimeBindingUpsert { + Created, + Unchanged, + Replaced, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AccountRecord { pub account_id: String, @@ -533,10 +544,30 @@ pub trait ControlPlaneStore: Send + Sync { &self, record: &WorkspaceBootstrapRecord, ) -> Result; - async fn get_trusted_runtime(&self, runtime_id: &str) -> Result>; - async fn upsert_trusted_runtime_record(&self, record: &TrustedRuntimeRecord) -> Result<()>; + async fn get_workspace_runtime_binding( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result>; + async fn list_workspace_runtime_bindings( + &self, + workspace_id: &str, + include_revoked: bool, + ) -> Result>; + async fn upsert_workspace_runtime_binding_record( + &self, + record: WorkspaceRuntimeBinding, + replace: bool, + ) -> Result; + async fn revoke_workspace_runtime_binding_record( + &self, + workspace_id: &str, + runtime_id: &str, + revoked_at: &str, + ) -> Result; async fn consume_worker_mutation_source_jti( &self, + workspace_id: &str, runtime_id: &str, jti: &str, expires_at: u64, @@ -1337,58 +1368,151 @@ impl SqliteWorkspaceStore { }) } - pub fn upsert_trusted_runtime(&self, record: &TrustedRuntimeRecord) -> Result<()> { + pub fn list_workspace_runtime_bindings( + &self, + workspace_id: &str, + include_revoked: bool, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; self.with_conn(|conn| { - conn.execute( - r#"INSERT INTO trusted_runtime_records ( - runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(runtime_id) DO UPDATE SET - display_name = excluded.display_name, - base_url = excluded.base_url, - public_key = excluded.public_key, - updated_at = excluded.updated_at, - revoked_at = excluded.revoked_at"#, + let sql = if include_revoked { + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, 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, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND revoked_at IS NULL + ORDER BY runtime_id ASC"# + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![workspace_id], read_workspace_runtime_binding)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + + pub fn get_workspace_runtime_binding( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + ) + .optional() + .map_err(Error::from) + }) + } + + pub fn upsert_workspace_runtime_binding( + &self, + mut record: WorkspaceRuntimeBinding, + replace: bool, + ) -> Result { + validate_identifier("workspace_id", &record.workspace_id)?; + validate_identifier("runtime_id", &record.runtime_id)?; + validate_non_empty("runtime display_name", &record.display_name)?; + validate_runtime_base_url(&record.base_url)?; + normalize_workspace_runtime_binding_key(&mut record)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, 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], + read_workspace_runtime_binding, + ) + .optional()?; + if let Some(existing) = existing { + let exact_active_match = existing.revoked_at.is_none() + && record.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; + if exact_active_match { + tx.commit()?; + return Ok(WorkspaceRuntimeBindingUpsert::Unchanged); + } + if !replace { + return Err(Error::RuntimeBindingConflict(format!( + "binding {}/{} already exists with different endpoint, trust, or lifecycle state; retry with explicit replacement", + record.workspace_id, record.runtime_id + ))); + } + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET display_name = ?3, base_url = ?4, public_key = ?5, + public_key_fingerprint = ?6, updated_at = ?7, revoked_at = ?8 + 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.updated_at, + record.revoked_at, + ], + ) + .map_err(map_runtime_binding_write_error)?; + tx.commit()?; + return Ok(WorkspaceRuntimeBindingUpsert::Replaced); + } + tx.execute( + r#"INSERT INTO workspace_runtime_bindings ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, params![ - record.runtime_id, record.workspace_id, + record.runtime_id, record.display_name, record.base_url, record.public_key, + record.public_key_fingerprint, record.created_at, record.updated_at, record.revoked_at, ], - )?; - Ok(()) + ) + .map_err(map_runtime_binding_write_error)?; + tx.commit()?; + Ok(WorkspaceRuntimeBindingUpsert::Created) }) } - pub fn list_trusted_runtimes( + pub fn revoke_workspace_runtime_binding( &self, - include_revoked: bool, - ) -> Result> { - self.with_conn(|conn| { - let sql = if include_revoked { - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at - FROM trusted_runtime_records ORDER BY runtime_id ASC"# - } else { - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at - FROM trusted_runtime_records WHERE revoked_at IS NULL ORDER BY runtime_id ASC"# - }; - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], read_trusted_runtime_record)?; - rows.collect::, _>>().map_err(Error::from) - }) - } - - pub fn revoke_trusted_runtime(&self, runtime_id: &str, revoked_at: &str) -> Result { + workspace_id: &str, + runtime_id: &str, + revoked_at: &str, + ) -> Result { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + validate_non_empty("revoked_at", revoked_at)?; self.with_conn(|conn| { let changed = conn.execute( - r#"UPDATE trusted_runtime_records - SET revoked_at = ?2, updated_at = ?2 - WHERE runtime_id = ?1 AND revoked_at IS NULL"#, - params![runtime_id, revoked_at], + r#"UPDATE workspace_runtime_bindings + SET revoked_at = ?3, updated_at = ?3 + WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, + params![workspace_id, runtime_id, revoked_at], )?; Ok(changed > 0) }) @@ -1711,25 +1835,47 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - async fn get_trusted_runtime(&self, runtime_id: &str) -> Result> { - self.with_conn(|conn| { - conn.query_row( - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at - FROM trusted_runtime_records WHERE runtime_id = ?1"#, - params![runtime_id], - read_trusted_runtime_record, - ) - .optional() - .map_err(Error::from) - }) + async fn get_workspace_runtime_binding( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result> { + SqliteWorkspaceStore::get_workspace_runtime_binding(self, workspace_id, runtime_id) } - async fn upsert_trusted_runtime_record(&self, record: &TrustedRuntimeRecord) -> Result<()> { - SqliteWorkspaceStore::upsert_trusted_runtime(self, record) + async fn list_workspace_runtime_bindings( + &self, + workspace_id: &str, + include_revoked: bool, + ) -> Result> { + SqliteWorkspaceStore::list_workspace_runtime_bindings(self, workspace_id, include_revoked) + } + + async fn upsert_workspace_runtime_binding_record( + &self, + record: WorkspaceRuntimeBinding, + replace: bool, + ) -> Result { + SqliteWorkspaceStore::upsert_workspace_runtime_binding(self, record, replace) + } + + async fn revoke_workspace_runtime_binding_record( + &self, + workspace_id: &str, + runtime_id: &str, + revoked_at: &str, + ) -> Result { + SqliteWorkspaceStore::revoke_workspace_runtime_binding( + self, + workspace_id, + runtime_id, + revoked_at, + ) } async fn consume_worker_mutation_source_jti( &self, + workspace_id: &str, runtime_id: &str, jti: &str, expires_at: u64, @@ -1744,9 +1890,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let inserted = transaction.execute( r#"INSERT OR IGNORE INTO worker_mutation_source_proof_jtis ( - runtime_id, jti, expires_at, consumed_at - ) VALUES (?1, ?2, ?3, ?4)"#, - params![runtime_id, jti, expires_at, consumed_at], + workspace_id, runtime_id, jti, expires_at, consumed_at + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![workspace_id, runtime_id, jti, expires_at, consumed_at], )?; transaction.commit()?; Ok(inserted == 1) @@ -5094,19 +5240,104 @@ fn account_select_sql(where_clause: &str) -> String { ) } -fn read_trusted_runtime_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(TrustedRuntimeRecord { - runtime_id: row.get(0)?, - workspace_id: row.get(1)?, +fn read_workspace_runtime_binding( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + 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)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, - revoked_at: row.get(7)?, + public_key_fingerprint: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + revoked_at: row.get(8)?, }) } +fn validate_identifier(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() || value.trim() != value { + return Err(Error::InvalidInput(format!( + "{field} must be non-empty and must not contain surrounding whitespace" + ))); + } + Ok(()) +} + +fn validate_non_empty(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(Error::InvalidInput(format!("{field} must not be empty"))); + } + Ok(()) +} + +fn validate_runtime_base_url(base_url: &str) -> Result<()> { + let base_url = base_url.trim(); + if base_url.starts_with("http://") + || base_url.starts_with("https://") + || base_url == "in-process://embedded" + { + Ok(()) + } else { + Err(Error::InvalidInput( + "Runtime base_url must be an absolute http/https URL or the embedded Runtime endpoint" + .to_string(), + )) + } +} + +pub fn normalize_runtime_public_key(public_key: &str) -> Result<(String, String)> { + let bytes = worker_runtime::auth::decode_public_key(public_key) + .map_err(|err| Error::InvalidInput(format!("invalid Runtime public key: {err}")))?; + let canonical = worker_runtime::auth::encode_public_key(&bytes); + let digest = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let fingerprint = format!("sha256:{digest}"); + Ok((canonical, fingerprint)) +} + +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(), + )), + } +} + +fn map_runtime_binding_write_error(err: rusqlite::Error) -> Error { + if matches!( + &err, + rusqlite::Error::SqliteFailure(error, _) + if error.code == rusqlite::ErrorCode::ConstraintViolation + ) { + Error::RuntimeBindingConflict( + "the Runtime id or public key fingerprint is already bound in this Workspace".into(), + ) + } else { + Error::from(err) + } +} + fn read_account_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(AccountRecord { account_id: row.get(0)?, @@ -5786,6 +6017,306 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( Ok(()) } +fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let legacy_columns = table_columns(&tx, "trusted_runtime_records")? + .into_iter() + .collect::>(); + let expected_columns = [ + "runtime_id", + "display_name", + "base_url", + "public_key", + "created_at", + "updated_at", + "revoked_at", + "workspace_id", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if legacy_columns != expected_columns { + return Err(Error::Store(format!( + "schema-{PREVIOUS_SCHEMA_VERSION} trusted_runtime_records columns are not canonical" + ))); + } + + let mut bindings = Vec::new(); + { + let mut stmt = tx.prepare( + r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, + created_at, updated_at, revoked_at + FROM trusted_runtime_records ORDER BY runtime_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>(7)?, + )) + })?; + for row in rows { + let ( + runtime_id, + workspace_id, + display_name, + base_url, + public_key, + created_at, + updated_at, + revoked_at, + ) = row?; + let workspace_id = workspace_id.filter(|value| !value.trim().is_empty()).ok_or_else(|| { + Error::Store(format!( + "Runtime `{runtime_id}` has no persisted Workspace ownership; refusing to guess during schema-{PREVIOUS_SCHEMA_VERSION} migration" + )) + })?; + let workspace_exists = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + params![workspace_id], + |row| row.get::<_, i64>(0), + )? != 0; + if !workspace_exists { + return Err(Error::Store(format!( + "Runtime `{runtime_id}` references unknown Workspace `{workspace_id}`" + ))); + } + let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; + bindings.push(WorkspaceRuntimeBinding { + workspace_id, + runtime_id, + display_name, + base_url, + public_key: Some(public_key), + public_key_fingerprint: Some(fingerprint), + created_at, + updated_at, + revoked_at, + }); + } + } + + let mut binding_keys = HashSet::new(); + let mut trust_keys = HashSet::new(); + for binding in &bindings { + if !binding_keys.insert((binding.workspace_id.clone(), binding.runtime_id.clone())) { + return Err(Error::Store(format!( + "duplicate Runtime binding `{}/{}` in schema-{PREVIOUS_SCHEMA_VERSION}", + binding.workspace_id, binding.runtime_id + ))); + } + let fingerprint = binding + .public_key_fingerprint + .clone() + .expect("normalized key"); + if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { + return Err(Error::Store(format!( + "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", + binding.workspace_id + ))); + } + } + + let mut consumed_jtis = Vec::new(); + { + let runtime_workspaces = bindings + .iter() + .map(|binding| (binding.runtime_id.as_str(), binding.workspace_id.as_str())) + .collect::>(); + let mut stmt = tx.prepare( + "SELECT runtime_id, jti, expires_at, consumed_at FROM worker_mutation_source_proof_jtis", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + let (runtime_id, jti, expires_at, consumed_at) = row?; + let workspace_id = runtime_workspaces.get(runtime_id.as_str()).ok_or_else(|| { + Error::Store(format!( + "consumed Worker mutation proof for Runtime `{runtime_id}` has no provable Workspace binding" + )) + })?; + consumed_jtis.push(( + (*workspace_id).to_string(), + runtime_id, + jti, + expires_at, + consumed_at, + )); + } + } + + tx.execute_batch( + r#" + ALTER TABLE worker_mutation_source_proof_jtis + RENAME TO worker_mutation_source_proof_jtis_v50; + ALTER TABLE trusted_runtime_records + RENAME TO trusted_runtime_records_v50; + + CREATE TABLE workspace_runtime_bindings ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT, + public_key_fingerprint TEXT, + 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 + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); + CREATE TABLE worker_mutation_source_proof_jtis ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, jti) + ); + "#, + )?; + for binding in bindings { + tx.execute( + r#"INSERT INTO workspace_runtime_bindings ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + params![ + binding.workspace_id, + binding.runtime_id, + binding.display_name, + binding.base_url, + binding.public_key, + binding.public_key_fingerprint, + binding.created_at, + binding.updated_at, + binding.revoked_at, + ], + )?; + } + for (workspace_id, runtime_id, jti, expires_at, consumed_at) in consumed_jtis { + tx.execute( + "INSERT INTO worker_mutation_source_proof_jtis ( + workspace_id, runtime_id, jti, expires_at, consumed_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![workspace_id, runtime_id, jti, expires_at, consumed_at], + )?; + } + tx.execute_batch( + "DROP TABLE worker_mutation_source_proof_jtis_v50; + DROP TABLE trusted_runtime_records_v50;", + )?; + let foreign_key_failures = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_failures != 0 { + return Err(Error::Store(format!( + "schema-{LATEST_SCHEMA_VERSION} migration produced {foreign_key_failures} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![LATEST_SCHEMA_VERSION, RUNTIME_BINDINGS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { + let columns = table_columns(conn, "workspace_runtime_bindings")? + .into_iter() + .collect::>(); + let expected = [ + "workspace_id", + "runtime_id", + "display_name", + "base_url", + "public_key", + "public_key_fingerprint", + "created_at", + "updated_at", + "revoked_at", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if columns != expected { + return Err(Error::Store( + "workspace_runtime_bindings schema does not match schema-51".to_string(), + )); + } + let sql = conn.query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_runtime_bindings'", + [], + |row| row.get::<_, String>(0), + )?; + if !sql.contains("PRIMARY KEY (workspace_id, runtime_id)") + || !sql.contains("UNIQUE (workspace_id, public_key_fingerprint)") + { + return Err(Error::Store( + "workspace_runtime_bindings is missing composite identity or trust uniqueness" + .to_string(), + )); + } + let index_exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_runtime_bindings_workspace')", + [], + |row| row.get::<_, i64>(0), + )? != 0; + if !index_exists { + return Err(Error::Store( + "workspace_runtime_bindings is missing its Workspace lookup index".to_string(), + )); + } + let jti_sql = conn.query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'worker_mutation_source_proof_jtis'", + [], + |row| row.get::<_, String>(0), + )?; + if !jti_sql.contains("PRIMARY KEY (workspace_id, runtime_id, jti)") { + return Err(Error::Store( + "worker_mutation_source_proof_jtis is missing Workspace-scoped replay identity" + .to_string(), + )); + } + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, 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 + ))); + } + } + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("latest_schema.sql"))?; Ok(()) @@ -6233,24 +6764,51 @@ fn allocate_resource_key( Ok(resource_key) } -fn verify_baseline_history(conn: &Connection) -> Result<()> { - let rows = conn.query_row( - "SELECT COUNT(*), COALESCE(MAX(version), 0), COALESCE(MAX(name), '') \ - FROM __yoi_schema_migrations", - [], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, String>(2)?, - )) - }, - )?; - let baseline = &MIGRATIONS[0]; - if rows != (1, baseline.version, baseline.name.to_string()) { +fn verify_current_schema_history(conn: &Connection) -> Result<()> { + let mut stmt = + conn.prepare("SELECT version, name FROM __yoi_schema_migrations ORDER BY version ASC")?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + let fresh = vec![( + LATEST_SCHEMA_VERSION, + "workspace schema baseline".to_string(), + )]; + let upgraded = vec![ + ( + PREVIOUS_SCHEMA_VERSION, + "workspace schema baseline".to_string(), + ), + ( + LATEST_SCHEMA_VERSION, + RUNTIME_BINDINGS_MIGRATION_NAME.to_string(), + ), + ]; + if rows != fresh && rows != upgraded { return Err(Error::Store(format!( - "database migration history is not the canonical schema baseline: expected only version {} ({:?}), found {} row(s) ending at version {} ({:?})", - baseline.version, baseline.name, rows.0, rows.1, rows.2 + "database migration history is not canonical for schema version {LATEST_SCHEMA_VERSION}: found {rows:?}" + ))); + } + Ok(()) +} + +fn verify_previous_schema_history(conn: &Connection) -> Result<()> { + let rows = conn.query_row( + "SELECT COUNT(*), COALESCE(MAX(version), 0), COALESCE(MAX(name), '') FROM __yoi_schema_migrations", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?)), + )?; + if rows + != ( + 1, + PREVIOUS_SCHEMA_VERSION, + "workspace schema baseline".to_string(), + ) + { + return Err(Error::Store(format!( + "database migration history is not the canonical schema-{PREVIOUS_SCHEMA_VERSION} baseline" ))); } Ok(()) @@ -6261,23 +6819,30 @@ fn apply_migrations(conn: &Connection) -> Result<()> { let current = current_schema_version(conn)?; match current { 0 => { - let tx = conn.unchecked_transaction()?; + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; (baseline.apply)(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![baseline.version, baseline.name], )?; tx.commit()?; - Ok(()) + verify_workspace_runtime_binding_schema(conn) } - version if version == baseline.version => verify_baseline_history(conn), - version if version > baseline.version => Err(Error::Store(format!( - "database schema version {version} is newer than this server supports ({}); refusing to serve with an older binary", - baseline.version + PREVIOUS_SCHEMA_VERSION => { + verify_previous_schema_history(conn)?; + migrate_workspace_runtime_bindings_v50_to_v51(conn)?; + verify_current_schema_history(conn)?; + verify_workspace_runtime_binding_schema(conn) + } + LATEST_SCHEMA_VERSION => { + verify_current_schema_history(conn)?; + verify_workspace_runtime_binding_schema(conn) + } + version if version > LATEST_SCHEMA_VERSION => Err(Error::Store(format!( + "database schema version {version} is newer than this server supports ({LATEST_SCHEMA_VERSION}); refusing to serve with an older binary" ))), version => Err(Error::Store(format!( - "database schema version {version} predates the canonical baseline ({}); migrate its data manually before starting this server", - baseline.version + "database schema version {version} predates the supported upgrade window ({PREVIOUS_SCHEMA_VERSION}); migrate its data manually before starting this server" ))), } } @@ -6340,6 +6905,200 @@ mod tests { .unwrap(); } + fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) { + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + create_latest_workspace_schema(&conn).unwrap(); + conn.execute_batch( + r#" + DROP TABLE worker_mutation_source_proof_jtis; + DROP TABLE workspace_runtime_bindings; + CREATE TABLE trusted_runtime_records ( + runtime_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT + ); + CREATE TABLE worker_mutation_source_proof_jtis ( + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (runtime_id, jti) + ); + DELETE FROM __yoi_schema_migrations; + INSERT INTO __yoi_schema_migrations(version, name) + VALUES (50, 'workspace schema baseline'); + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + "#, + ) + .unwrap(); + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + conn.execute( + r#"INSERT INTO trusted_runtime_records( + runtime_id, workspace_id, display_name, base_url, public_key, + created_at, updated_at, revoked_at + ) VALUES ('shared', ?1, 'Shared', 'https://runtime.test', ?2, '1', '1', NULL)"#, + params![workspace_id, identity.public_key], + ) + .unwrap(); + conn.execute( + r#"INSERT INTO worker_mutation_source_proof_jtis( + runtime_id, jti, expires_at, consumed_at + ) VALUES ('shared', 'jti-1', 10, '1')"#, + [], + ) + .unwrap(); + } + + #[test] + fn schema_v50_runtime_trust_migrates_to_workspace_binding_atomically() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v50(&path, Some("workspace-a")); + + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let binding = store + .get_workspace_runtime_binding("workspace-a", "shared") + .unwrap() + .unwrap(); + assert!(binding.public_key.is_some()); + assert!( + binding + .public_key_fingerprint + .as_deref() + .is_some_and(|value| value.starts_with("sha256:")) + ); + store + .with_conn(|conn| { + let jti_workspace: String = conn.query_row( + "SELECT workspace_id FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", + [], + |row| row.get(0), + )?; + assert_eq!(jti_workspace, "workspace-a"); + let violations: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_check", + [], + |row| row.get(0), + )?; + assert_eq!(violations, 0); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn schema_v50_runtime_without_workspace_fails_without_partial_mutation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v50(&path, None); + + let error = match SqliteWorkspaceStore::open(&path) { + Ok(_) => panic!("missing Workspace ownership must fail migration"), + Err(error) => error, + }; + assert!(error.to_string().contains("refusing to guess"), "{error}"); + let conn = Connection::open(&path).unwrap(); + 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() + ); + } + + #[test] + fn runtime_binding_identity_and_trust_uniqueness_are_workspace_scoped() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) + VALUES + ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'), + ('workspace-b', 'owner', 'Workspace B', 'active', '1', '1'); + "#, + )?; + Ok(()) + }) + .unwrap(); + let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate("shared").unwrap(); + let binding = |workspace_id: &str, runtime_id: &str| WorkspaceRuntimeBinding { + workspace_id: workspace_id.to_string(), + 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, + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }; + assert_eq!( + store + .upsert_workspace_runtime_binding(binding("workspace-a", "shared"), false) + .unwrap(), + WorkspaceRuntimeBindingUpsert::Created + ); + assert_eq!( + store + .upsert_workspace_runtime_binding(binding("workspace-b", "shared"), false) + .unwrap(), + WorkspaceRuntimeBindingUpsert::Created + ); + assert_eq!( + store + .upsert_workspace_runtime_binding(binding("workspace-a", "shared"), false) + .unwrap(), + WorkspaceRuntimeBindingUpsert::Unchanged + ); + let conflict = store + .upsert_workspace_runtime_binding(binding("workspace-a", "other"), false) + .unwrap_err(); + assert!(matches!(conflict, Error::RuntimeBindingConflict(_))); + assert_eq!( + store + .list_workspace_runtime_bindings("workspace-a", false) + .unwrap() + .len(), + 1 + ); + assert_eq!( + store + .list_workspace_runtime_bindings("workspace-b", false) + .unwrap() + .len(), + 1 + ); + } + #[test] fn startup_rejects_prebaseline_workspace_history() { let conn = Connection::open_in_memory().unwrap(); @@ -6353,7 +7112,7 @@ mod tests { assert!( error .to_string() - .contains("predates the canonical baseline") + .contains("predates the supported upgrade window") ); } @@ -6375,7 +7134,7 @@ mod tests { assert!( error .to_string() - .contains("migration history is not the canonical schema baseline") + .contains("migration history is not canonical") ); } @@ -6460,7 +7219,7 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 50); + assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -6477,7 +7236,10 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 50); + assert_eq!( + reopened.schema_version().await.unwrap(), + LATEST_SCHEMA_VERSION + ); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -7342,13 +8104,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 (51, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (52, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 51 is newer"), "{error}"); + assert!(error.contains("schema version 52 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -7555,7 +8317,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 50); + assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: "owner-account".to_string(), @@ -7636,7 +8398,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 50); + assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: "owner-account".to_string(), @@ -8049,7 +8811,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 50); + assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 1144a132..363ab80d 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -57,16 +57,15 @@ pub async fn verify_runtime_request_source_proof_with_store( .map_err(|_| WorkerMutationSourceProofError::Invalid)?; let audience = remote_audience(config, &unverified.iss, workspace_id)?; let trusted = store - .get_trusted_runtime(&unverified.iss) + .get_workspace_runtime_binding(workspace_id, &unverified.iss) .await .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - .filter(|record| record.revoked_at.is_none()) + .filter(|record| record.revoked_at.is_none() && record.public_key.is_some()) + .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + let public_key = trusted + .public_key + .as_deref() .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id) - || (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none()); - if !trusted_for_workspace { - return Err(WorkerMutationSourceProofError::WrongWorkspace); - } let expected = RuntimeRequestSourceExpectation { identity_id: &unverified.iss, audience: audience.as_ref(), @@ -78,8 +77,8 @@ pub async fn verify_runtime_request_source_proof_with_store( body_digest, now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), }; - let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected) - .map_err(map_auth_error)?; + let claims = + verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?; let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); let expires_at = u64::try_from(claims.exp).unwrap_or(0); let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) @@ -87,6 +86,7 @@ pub async fn verify_runtime_request_source_proof_with_store( .to_rfc3339(); if !store .consume_worker_mutation_source_jti( + workspace_id, &claims.iss, &claims.jti, expires_at, @@ -206,14 +206,15 @@ async fn verify_worker_remove_source_with( .map_err(|_| WorkerMutationSourceProofError::Invalid)?; let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; let trusted = store - .get_trusted_runtime(&unverified.iss) + .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()) + .filter(|record| record.revoked_at.is_none() && record.public_key.is_some()) + .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; + let public_key = trusted + .public_key + .as_deref() .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) { - return Err(WorkerMutationSourceProofError::WrongWorkspace); - } let expected = WorkerMutationSourceExpectation { runtime_id: &unverified.iss, audience: audience.as_ref(), @@ -225,7 +226,7 @@ async fn verify_worker_remove_source_with( target_worker_id, permission: required_permission, }; - verify_worker_mutation_source_proof(&trusted.public_key, token, &expected, now) + verify_worker_mutation_source_proof(public_key, token, &expected, now) .map_err(map_auth_error)? } PresentedWorkerMutationSourceProof::InProcess(proof) => { @@ -263,7 +264,14 @@ async fn verify_worker_remove_source_with( let consumed_at = chrono::Utc::now().to_rfc3339(); let consumed = store - .consume_worker_mutation_source_jti(&claims.iss, &claims.jti, claims.exp, now, &consumed_at) + .consume_worker_mutation_source_jti( + &config.workspace_id, + &claims.iss, + &claims.jti, + claims.exp, + now, + &consumed_at, + ) .await .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; if !consumed { diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index fd30919c..5439de6d 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -110,18 +110,19 @@ On the Workspace Server host, register the Runtime public key copied from `yoi-r ```bash yoi-server trust-runtime add \ + --workspace-id '' \ --runtime-id runtime-main \ --base-url http://127.0.0.1:38800 \ --public-key '' \ --display-name 'Runtime main' ``` -This writes a trusted Runtime record to the Server DB. During `yoi-server serve`, active trusted Runtime records are loaded as remote Runtime sources and receive signed capability tokens. You do not need to duplicate the same Runtime in `runtimes.toml` for this trust-backed path. +This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority. Verify: ```bash -yoi-server trust-runtime list --json +yoi-server trust-runtime list --workspace-id '' --json ``` ## 5. Start Runtime and Workspace Server @@ -160,7 +161,7 @@ An empty Server DB is valid. Open the Web UI, create or authenticate the Account Check both trust stores: ```bash -yoi-server trust-runtime list --json +yoi-server trust-runtime list --workspace-id '' --json yoi-runtime trust-server list --json ``` @@ -203,6 +204,7 @@ After Runtime identity rotation, Server must be updated with the new Runtime pub ```bash yoi-server trust-runtime add \ + --workspace-id '' \ --runtime-id runtime-main \ --base-url http://127.0.0.1:38800 \ --public-key '' \ @@ -214,7 +216,9 @@ yoi-server trust-runtime add \ Revoke a trusted Runtime on Server: ```bash -yoi-server trust-runtime revoke --runtime-id runtime-main +yoi-server trust-runtime revoke \ + --workspace-id '' \ + --runtime-id runtime-main ``` Remove a trusted Server from Runtime: @@ -252,7 +256,7 @@ Confirm the `--runtime-id` registered on Server exactly matches the Runtime iden ```bash yoi-runtime identity show --json -yoi-server trust-runtime list --json +yoi-server trust-runtime list --workspace-id '' --json ``` `RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.