fix: restore remote Runtime management contracts
This commit is contained in:
@@ -3277,6 +3277,28 @@ pub(crate) fn resolve_strict_remote_runtime_endpoint(
|
||||
Ok((host, addresses))
|
||||
}
|
||||
|
||||
/// Returns whether `endpoint` is a credential-free HTTP(S) origin on a literal
|
||||
/// IPv4 or IPv6 loopback address.
|
||||
pub fn is_loopback_runtime_origin(endpoint: &str) -> bool {
|
||||
let Ok(endpoint) = reqwest::Url::parse(endpoint.trim()) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(endpoint.scheme(), "http" | "https")
|
||||
|| !endpoint.username().is_empty()
|
||||
|| endpoint.password().is_some()
|
||||
|| endpoint.query().is_some()
|
||||
|| endpoint.fragment().is_some()
|
||||
|| endpoint.path() != "/"
|
||||
{
|
||||
return false;
|
||||
}
|
||||
endpoint
|
||||
.host_str()
|
||||
.map(|host| host.trim_start_matches('[').trim_end_matches(']'))
|
||||
.and_then(|host| host.parse::<IpAddr>().ok())
|
||||
.is_some_and(|address| address.is_loopback())
|
||||
}
|
||||
|
||||
pub(crate) fn is_disallowed_remote_runtime_address(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => {
|
||||
@@ -5325,6 +5347,23 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_runtime_origin_accepts_only_literal_clean_http_or_https_origins() {
|
||||
assert!(is_loopback_runtime_origin("http://127.0.0.1:8788"));
|
||||
assert!(is_loopback_runtime_origin("https://127.0.0.1"));
|
||||
assert!(is_loopback_runtime_origin("http://[::1]:8788"));
|
||||
for endpoint in [
|
||||
"http://localhost:8788",
|
||||
"http://10.0.0.1:8788",
|
||||
"http://127.0.0.1:8788/path",
|
||||
"http://127.0.0.1:8788/?query=1",
|
||||
"http://user@127.0.0.1:8788",
|
||||
"ftp://127.0.0.1:8788",
|
||||
] {
|
||||
assert!(!is_loopback_runtime_origin(endpoint), "{endpoint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_worker_create_timeout_covers_runtime_phase_budgets() {
|
||||
assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5));
|
||||
@@ -5712,6 +5751,10 @@ mod tests {
|
||||
request.worker_ref,
|
||||
self.backend_id(),
|
||||
),
|
||||
worker_state: protocol::WorkerStateSnapshot {
|
||||
execution_generation: request.run_generation,
|
||||
..protocol::WorkerStatus::Idle.into()
|
||||
},
|
||||
working_directory: request
|
||||
.working_directory
|
||||
.as_ref()
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::net::TcpListener;
|
||||
use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig};
|
||||
use yoi_workspace_server::hosts::{
|
||||
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
WorkspaceRuntimeBindingState,
|
||||
@@ -165,6 +167,7 @@ fn remote_runtime_config_from_binding(
|
||||
binding.workspace_id, binding.runtime_id
|
||||
)));
|
||||
}
|
||||
let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url);
|
||||
Ok(Some(
|
||||
RemoteRuntimeConfig::new(
|
||||
binding.runtime_id,
|
||||
@@ -173,7 +176,7 @@ fn remote_runtime_config_from_binding(
|
||||
None,
|
||||
)
|
||||
.with_workspace_id(binding.workspace_id)
|
||||
.with_strict_public_egress(true),
|
||||
.with_strict_public_egress(strict_public_egress),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -621,6 +624,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() {
|
||||
let binding = WorkspaceRuntimeBinding {
|
||||
workspace_id: "workspace-a".to_owned(),
|
||||
runtime_id: "arcadia".to_owned(),
|
||||
display_name: "Arcadia".to_owned(),
|
||||
base_url: "http://127.0.0.1:8788".to_owned(),
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_revision: 1,
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("WK-test".to_owned()),
|
||||
workspace_key_generation: Some(1),
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
};
|
||||
|
||||
let config = remote_runtime_config_from_binding(binding)
|
||||
.unwrap()
|
||||
.expect("remote Runtime config");
|
||||
|
||||
assert_eq!(config.base_url, "http://127.0.0.1:8788");
|
||||
assert!(!config.strict_public_egress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
|
||||
for command in ["identity", "trust-runtime"] {
|
||||
|
||||
@@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend {
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||
WorkerExecutionSpawnResult::connected(
|
||||
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
protocol::WorkerStateSnapshot {
|
||||
execution_generation: request.run_generation,
|
||||
..protocol::WorkerStatus::Idle.into()
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
|
||||
.await;
|
||||
assert_eq!(status.desired_selectors, 1);
|
||||
|
||||
let mut running = worker
|
||||
.worker_state
|
||||
.clone()
|
||||
.expect("connected test Worker must expose its initial state");
|
||||
running.revision += 1;
|
||||
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
));
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::WorkerState {
|
||||
snapshot: protocol::WorkerStatus::Running.into(),
|
||||
},
|
||||
protocol::Event::WorkerState { snapshot: running },
|
||||
)
|
||||
.unwrap();
|
||||
for subscription in [&mut first, &mut second] {
|
||||
@@ -334,12 +344,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
|
||||
workers[0].runtime_id.as_deref(),
|
||||
Some("embedded-worker-runtime")
|
||||
);
|
||||
let mut running = worker
|
||||
.worker_state
|
||||
.clone()
|
||||
.expect("connected test Worker must expose its initial state");
|
||||
running.revision += 1;
|
||||
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
));
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::WorkerState {
|
||||
snapshot: protocol::WorkerStatus::Running.into(),
|
||||
},
|
||||
protocol::Event::WorkerState { snapshot: running },
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(next_event(&mut subscription).await,
|
||||
|
||||
@@ -135,7 +135,7 @@ use crate::hosts::{
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
|
||||
WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
|
||||
WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
|
||||
worker_spawn_create_fingerprint, workspace_worker_summary,
|
||||
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
|
||||
};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||
@@ -3014,15 +3014,15 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
.route("/api/workspace", get(get_workspace))
|
||||
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/workspace",
|
||||
"/api/w/{workspace_id}/settings",
|
||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/workspace/signing-identity",
|
||||
"/api/w/{workspace_id}/settings/signing-identity",
|
||||
get(scoped_get_workspace_signing_identity),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/workspace/signing-identity/provision",
|
||||
"/api/w/{workspace_id}/settings/signing-identity/provision",
|
||||
post(scoped_provision_workspace_signing_identity),
|
||||
)
|
||||
.route(
|
||||
@@ -16312,61 +16312,65 @@ async fn validate_runtime_connection_request(
|
||||
let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| {
|
||||
settings_bad_request(
|
||||
"invalid_remote_runtime_endpoint",
|
||||
"endpoint must be an absolute https URL",
|
||||
"endpoint must be an absolute HTTP or HTTPS URL",
|
||||
)
|
||||
})?;
|
||||
if endpoint.scheme() != "https"
|
||||
|| endpoint.host_str().is_none()
|
||||
let local_loopback = is_loopback_runtime_origin(endpoint.as_str());
|
||||
if endpoint.host_str().is_none()
|
||||
|| !endpoint.username().is_empty()
|
||||
|| endpoint.password().is_some()
|
||||
|| endpoint.query().is_some()
|
||||
|| endpoint.fragment().is_some()
|
||||
|| endpoint.path() != "/"
|
||||
|| (!local_loopback && endpoint.scheme() != "https")
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_endpoint_not_allowed",
|
||||
"Runtime endpoint must be an https origin without credentials, query, or fragment",
|
||||
"Runtime endpoint must be a public HTTPS origin or a loopback HTTP origin without credentials, path, query, or fragment",
|
||||
));
|
||||
}
|
||||
let host = endpoint.host_str().expect("checked above");
|
||||
if host.eq_ignore_ascii_case("localhost")
|
||||
|| host.ends_with(".localhost")
|
||||
|| host
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(is_disallowed_remote_runtime_address)
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_endpoint_not_allowed",
|
||||
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
|
||||
));
|
||||
}
|
||||
let port = endpoint.port_or_known_default().unwrap_or(443);
|
||||
let addresses = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
tokio::net::lookup_host((host, port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
settings_bad_request(
|
||||
"remote_runtime_endpoint_dns_timeout",
|
||||
"Runtime endpoint DNS resolution timed out",
|
||||
if !local_loopback {
|
||||
let host = endpoint.host_str().expect("checked above");
|
||||
if host.eq_ignore_ascii_case("localhost")
|
||||
|| host.ends_with(".localhost")
|
||||
|| host
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(is_disallowed_remote_runtime_address)
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_endpoint_not_allowed",
|
||||
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
|
||||
));
|
||||
}
|
||||
let port = endpoint.port_or_known_default().unwrap_or(443);
|
||||
let addresses = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
tokio::net::lookup_host((host, port)),
|
||||
)
|
||||
})?
|
||||
.map_err(|_| {
|
||||
settings_bad_request(
|
||||
"remote_runtime_endpoint_dns_failed",
|
||||
"Runtime endpoint DNS resolution failed",
|
||||
)
|
||||
})?
|
||||
.collect::<Vec<_>>();
|
||||
if addresses.is_empty()
|
||||
|| addresses
|
||||
.iter()
|
||||
.any(|address| is_disallowed_remote_runtime_address(address.ip()))
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_endpoint_not_allowed",
|
||||
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
|
||||
));
|
||||
.await
|
||||
.map_err(|_| {
|
||||
settings_bad_request(
|
||||
"remote_runtime_endpoint_dns_timeout",
|
||||
"Runtime endpoint DNS resolution timed out",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| {
|
||||
settings_bad_request(
|
||||
"remote_runtime_endpoint_dns_failed",
|
||||
"Runtime endpoint DNS resolution failed",
|
||||
)
|
||||
})?
|
||||
.collect::<Vec<_>>();
|
||||
if addresses.is_empty()
|
||||
|| addresses
|
||||
.iter()
|
||||
.any(|address| is_disallowed_remote_runtime_address(address.ip()))
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_endpoint_not_allowed",
|
||||
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
|
||||
));
|
||||
}
|
||||
}
|
||||
if request
|
||||
.display_name
|
||||
@@ -16412,7 +16416,8 @@ fn remote_runtime_config_from_binding(
|
||||
)
|
||||
.with_workspace_id(binding.workspace_id.clone())
|
||||
.with_strict_public_egress(
|
||||
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity
|
||||
&& !is_loopback_runtime_origin(&binding.base_url),
|
||||
);
|
||||
Ok(remote)
|
||||
}
|
||||
@@ -20505,15 +20510,29 @@ mod tests {
|
||||
};
|
||||
assert!(validate_runtime_connection_request(&ok).await.is_ok());
|
||||
|
||||
let bad_endpoint = CreateRemoteRuntimeRequest {
|
||||
endpoint: "http://169.254.169.254/latest/meta-data".to_string(),
|
||||
..ok
|
||||
let loopback = CreateRemoteRuntimeRequest {
|
||||
endpoint: "http://127.0.0.1:8788".to_string(),
|
||||
..ok.clone()
|
||||
};
|
||||
assert!(
|
||||
validate_runtime_connection_request(&bad_endpoint)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_runtime_connection_request(&loopback).await.is_ok());
|
||||
|
||||
for endpoint in [
|
||||
"http://10.0.0.1:8788",
|
||||
"http://localhost:8788",
|
||||
"http://127.0.0.1:8788/runtime",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
] {
|
||||
let bad_endpoint = CreateRemoteRuntimeRequest {
|
||||
endpoint: endpoint.to_string(),
|
||||
..ok.clone()
|
||||
};
|
||||
assert!(
|
||||
validate_runtime_connection_request(&bad_endpoint)
|
||||
.await
|
||||
.is_err(),
|
||||
"{endpoint}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -20683,6 +20702,10 @@ mod tests {
|
||||
request.worker_ref,
|
||||
self.backend_id(),
|
||||
),
|
||||
worker_state: protocol::WorkerStateSnapshot {
|
||||
execution_generation: request.run_generation,
|
||||
..protocol::WorkerStatus::Idle.into()
|
||||
},
|
||||
working_directory,
|
||||
}
|
||||
}
|
||||
@@ -21480,7 +21503,7 @@ mod tests {
|
||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
||||
|
||||
let identity_uri = format!(
|
||||
"/api/w/{}/settings/workspace/signing-identity",
|
||||
"/api/w/{}/settings/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
);
|
||||
let identity_response = app
|
||||
@@ -21511,6 +21534,33 @@ mod tests {
|
||||
let identity_text = String::from_utf8(identity_body.to_vec()).unwrap();
|
||||
assert!(!identity_text.contains("private_key"));
|
||||
assert!(!identity_text.contains("private_material_ref"));
|
||||
for legacy_uri in [
|
||||
format!(
|
||||
"/api/w/{}/settings/workspace",
|
||||
workspace.workspace.workspace_id
|
||||
),
|
||||
format!(
|
||||
"/api/w/{}/settings/workspace/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
),
|
||||
] {
|
||||
let legacy_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(legacy_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(legacy_response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
let identity_non_owner = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -21582,10 +21632,7 @@ mod tests {
|
||||
pending_identity.key_id
|
||||
);
|
||||
|
||||
let settings_uri = format!(
|
||||
"/api/w/{}/settings/workspace",
|
||||
workspace.workspace.workspace_id
|
||||
);
|
||||
let settings_uri = format!("/api/w/{}/settings", workspace.workspace.workspace_id);
|
||||
let csrf_rejected = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 56;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 57;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -28,6 +28,8 @@ const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str =
|
||||
"Workspace Runtime binding state and identity mode";
|
||||
const WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME: &str =
|
||||
"Workspace-signed Runtime verification evidence";
|
||||
const LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME: &str =
|
||||
"convert legacy Server-issued Runtime bindings to Workspace identity";
|
||||
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
@@ -60,6 +62,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME,
|
||||
apply: migrate_workspace_runtime_verification_v55_to_v56,
|
||||
},
|
||||
Migration {
|
||||
version: 57,
|
||||
name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME,
|
||||
apply: migrate_legacy_external_runtime_bindings_v56_to_v57,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -6970,7 +6977,13 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding)
|
||||
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => {
|
||||
if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"legacy Server issuer Runtime bindings must not carry Workspace key metadata"
|
||||
"legacy Runtime bindings must not carry Workspace key metadata".into(),
|
||||
));
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
if record.runtime_id != crate::hosts::EMBEDDED_RUNTIME_ID {
|
||||
return Err(Error::InvalidInput(
|
||||
"legacy Server issuer authentication is reserved for the embedded Runtime"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
@@ -7789,14 +7802,11 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()
|
||||
));
|
||||
}
|
||||
all_workspace_ids.clone()
|
||||
} else if let Some(workspace_id) = workspace_id.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
vec![workspace_id]
|
||||
} else {
|
||||
vec![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-50 migration"
|
||||
))
|
||||
})?]
|
||||
continue;
|
||||
};
|
||||
let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?;
|
||||
for workspace_id in workspace_ids {
|
||||
@@ -7871,11 +7881,9 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()
|
||||
})?;
|
||||
for row in rows {
|
||||
let (runtime_id, jti, expires_at, consumed_at) = row?;
|
||||
let workspace_ids = 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"
|
||||
))
|
||||
})?;
|
||||
let Some(workspace_ids) = runtime_workspaces.get(runtime_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
for workspace_id in workspace_ids {
|
||||
consumed_jtis.push((
|
||||
(*workspace_id).to_string(),
|
||||
@@ -8307,6 +8315,93 @@ fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_legacy_external_runtime_bindings_v56_to_v57(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 56 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 56 before {LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
let missing_workspace_identity_count = tx.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_runtime_bindings binding
|
||||
LEFT JOIN workspace_signing_identities identity
|
||||
ON identity.workspace_id = binding.workspace_id
|
||||
WHERE binding.authentication_mode = 'legacy_server_issuer'
|
||||
AND binding.runtime_id <> ?1
|
||||
AND identity.workspace_id IS NULL",
|
||||
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
if missing_workspace_identity_count != 0 {
|
||||
return Err(Error::Store(
|
||||
"legacy external Runtime binding migration requires Workspace signing identity metadata"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM workspace_runtime_verifications
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM workspace_runtime_bindings binding
|
||||
WHERE binding.workspace_id = workspace_runtime_verifications.workspace_id
|
||||
AND binding.runtime_id = workspace_runtime_verifications.runtime_id
|
||||
AND binding.authentication_mode = 'legacy_server_issuer'
|
||||
AND binding.runtime_id <> ?1
|
||||
)",
|
||||
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE workspace_runtime_bindings
|
||||
SET state = CASE WHEN state = 'verified' THEN 'configured' ELSE state END,
|
||||
authentication_mode = 'workspace_identity',
|
||||
workspace_key_id = (
|
||||
SELECT identity.key_id FROM workspace_signing_identities identity
|
||||
WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id
|
||||
),
|
||||
workspace_key_generation = (
|
||||
SELECT identity.revision FROM workspace_signing_identities identity
|
||||
WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id
|
||||
)
|
||||
WHERE authentication_mode = 'legacy_server_issuer'
|
||||
AND runtime_id <> ?1",
|
||||
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|
||||
)?;
|
||||
let remaining_external_legacy_bindings = tx.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_bindings
|
||||
WHERE authentication_mode = 'legacy_server_issuer'
|
||||
AND runtime_id <> ?1",
|
||||
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
if remaining_external_legacy_bindings != 0 {
|
||||
return Err(Error::Store(
|
||||
"legacy Server-issued external Runtime bindings remain after schema-57 migration"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
verify_workspace_runtime_binding_schema(&tx)?;
|
||||
verify_workspace_runtime_verification_schema(&tx)?;
|
||||
let foreign_key_violations =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
if foreign_key_violations != 0 {
|
||||
return Err(Error::Store(format!(
|
||||
"schema-57 Runtime binding migration left {foreign_key_violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![
|
||||
57_i64,
|
||||
LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> {
|
||||
let actual = table_columns(conn, "workspace_runtime_verifications")?
|
||||
.into_iter()
|
||||
@@ -8496,6 +8591,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
|
||||
"SELECT COUNT(*) FROM workspace_runtime_bindings
|
||||
WHERE state NOT IN ('configured', 'verified', 'revoked')
|
||||
OR authentication_mode NOT IN ('legacy_server_issuer', 'workspace_identity')
|
||||
OR (authentication_mode = 'legacy_server_issuer' AND
|
||||
(workspace_key_id IS NOT NULL OR workspace_key_generation IS NOT NULL))
|
||||
OR (authentication_mode = 'workspace_identity' AND
|
||||
(workspace_key_id IS NULL OR workspace_key_generation IS NULL))
|
||||
OR (state = 'revoked' AND revoked_at IS NULL)
|
||||
@@ -8633,6 +8730,22 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> {
|
||||
if binding.authentication_mode == WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer {
|
||||
if binding.workspace_key_id.is_some() || binding.workspace_key_generation.is_some() {
|
||||
return Err(Error::Store(format!(
|
||||
"legacy Runtime binding `{}/{}` carries Workspace key metadata",
|
||||
binding.workspace_id, binding.runtime_id
|
||||
)));
|
||||
}
|
||||
let (canonical_key, fingerprint) = normalize_runtime_public_key(&binding.public_key)?;
|
||||
if canonical_key != binding.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
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut normalized = binding.clone();
|
||||
normalize_workspace_runtime_binding_key(&mut normalized)?;
|
||||
if normalized.public_key != binding.public_key
|
||||
@@ -9427,6 +9540,10 @@ mod tests {
|
||||
version: 56,
|
||||
name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 57,
|
||||
name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -9457,6 +9574,10 @@ mod tests {
|
||||
56,
|
||||
WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
57,
|
||||
LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -9476,17 +9597,18 @@ mod tests {
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?,
|
||||
"verified:legacy_server_issuer"
|
||||
"configured:workspace_identity"
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE workspace_runtime_bindings
|
||||
SET state='configured', authentication_mode='workspace_identity'
|
||||
WHERE workspace_id='workspace-a' AND runtime_id='shared'",
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_bindings
|
||||
WHERE workspace_id='workspace-a' AND runtime_id='shared'
|
||||
AND workspace_key_id IS NOT NULL
|
||||
AND workspace_key_generation IS NOT NULL",
|
||||
[],
|
||||
)
|
||||
.is_err(),
|
||||
"migrated schema must reject Workspace identity mode without key metadata"
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
@@ -9526,7 +9648,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![52, 53, 54, 55, 56]
|
||||
vec![52, 53, 54, 55, 56, 57]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
@@ -9534,7 +9656,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 7);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9594,23 +9716,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v50_dry_run_rejects_unscoped_external_runtime_without_mutating_source() {
|
||||
fn schema_v50_discards_unscoped_external_runtime_without_guessing_ownership() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, None);
|
||||
|
||||
let error = SqliteWorkspaceStore::migration_plan(&path).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("has no persisted Workspace ownership; refusing to guess"),
|
||||
"{error}"
|
||||
);
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 50);
|
||||
assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION);
|
||||
|
||||
let unchanged = Connection::open(&path).unwrap();
|
||||
assert_eq!(current_schema_version(&unchanged).unwrap(), 50);
|
||||
assert!(table_exists(&unchanged, "trusted_runtime_records").unwrap());
|
||||
assert!(!table_exists(&unchanged, "workspace_runtime_bindings").unwrap());
|
||||
drop(unchanged);
|
||||
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
let migrated = Connection::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
current_schema_version(&migrated).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_bindings WHERE runtime_id = 'shared'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9803,9 +9949,9 @@ mod tests {
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_key_generation: None,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some(format!("WK-{}", &workspace_id["workspace-".len()..])),
|
||||
workspace_key_generation: Some(1),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -10082,6 +10228,171 @@ mod tests {
|
||||
assert!(table_exists(&migrated, "workspace_runtime_verifications").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v56_converts_legacy_external_binding_and_preserves_embedded() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version > 50 && migration.version <= 56)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 56);
|
||||
let external_identity =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("legacy-external").unwrap();
|
||||
let embedded_identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(
|
||||
crate::hosts::EMBEDDED_RUNTIME_ID,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, external_fingerprint) =
|
||||
normalize_runtime_public_key(&external_identity.public_key).unwrap();
|
||||
let (_, embedded_fingerprint) =
|
||||
normalize_runtime_public_key(&embedded_identity.public_key).unwrap();
|
||||
conn.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, state,
|
||||
authentication_mode, workspace_key_id, workspace_key_generation,
|
||||
binding_revision
|
||||
) VALUES (
|
||||
'workspace-a', 'legacy-external', 'Legacy External', 'https://runtime.test',
|
||||
?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1
|
||||
)"#,
|
||||
params![external_identity.public_key, external_fingerprint],
|
||||
)
|
||||
.unwrap();
|
||||
conn.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, state,
|
||||
authentication_mode, workspace_key_id, workspace_key_generation,
|
||||
binding_revision
|
||||
) VALUES (
|
||||
'workspace-a', 'embedded-worker-runtime', 'Embedded', 'embedded://runtime',
|
||||
?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1
|
||||
)"#,
|
||||
params![embedded_identity.public_key, embedded_fingerprint],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
INSERT INTO workspace_runtime_binding_audit(
|
||||
workspace_id, runtime_id, actor_account_id, action,
|
||||
old_fingerprint, new_fingerprint, binding_revision, at
|
||||
) VALUES
|
||||
('workspace-a', 'legacy-external', 'owner', 'created', NULL, 'legacy', 1, '1'),
|
||||
('workspace-a', 'embedded-worker-runtime', 'owner', 'created', NULL, 'embedded', 1, '1');
|
||||
INSERT INTO worker_mutation_source_proof_jtis(
|
||||
workspace_id, runtime_id, jti, expires_at, consumed_at
|
||||
) VALUES
|
||||
('workspace-a', 'legacy-external', 'legacy-jti', 10, '1'),
|
||||
('workspace-a', 'embedded-worker-runtime', 'embedded-jti', 10, '1');
|
||||
INSERT INTO workspace_runtime_verifications(
|
||||
workspace_id, runtime_id, binding_revision, workspace_key_id,
|
||||
workspace_identity_revision, workspace_trust_generation,
|
||||
runtime_public_key_fingerprint, runtime_identity_revision,
|
||||
challenge_id, state, last_outcome, verified_at, checked_at
|
||||
) VALUES (
|
||||
'workspace-a', 'legacy-external', 1, 'legacy-workspace-key',
|
||||
1, 1, 'legacy-runtime-key', 1,
|
||||
'legacy-challenge', 'verified', 'legacy', '1', '1'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_legacy_external_runtime_bindings_v56_to_v57(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 57);
|
||||
let workspace_identity: (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT key_id, revision FROM workspace_signing_identities WHERE workspace_id = 'workspace-a'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
let external_binding: (String, String, String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT state, authentication_mode, workspace_key_id, workspace_key_generation
|
||||
FROM workspace_runtime_bindings WHERE runtime_id = 'legacy-external'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
external_binding,
|
||||
(
|
||||
"configured".to_string(),
|
||||
"workspace_identity".to_string(),
|
||||
workspace_identity.0,
|
||||
workspace_identity.1,
|
||||
)
|
||||
);
|
||||
let embedded_binding: (String, String, Option<String>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT state, authentication_mode, workspace_key_id, workspace_key_generation
|
||||
FROM workspace_runtime_bindings WHERE runtime_id = 'embedded-worker-runtime'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
embedded_binding,
|
||||
(
|
||||
"verified".to_string(),
|
||||
"legacy_server_issuer".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
);
|
||||
for table in [
|
||||
"workspace_runtime_binding_audit",
|
||||
"worker_mutation_source_proof_jtis",
|
||||
] {
|
||||
for runtime_id in ["legacy-external", "embedded-worker-runtime"] {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {table} WHERE runtime_id = ?1"),
|
||||
params![runtime_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1, "{table}:{runtime_id}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_verifications WHERE runtime_id = 'legacy-external'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_runtime_bindings
|
||||
WHERE authentication_mode = 'legacy_server_issuer'
|
||||
AND runtime_id <> 'embedded-worker-runtime'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
@@ -11401,14 +11712,18 @@ INSERT INTO worker_registry (
|
||||
fn server_refuses_a_database_from_a_newer_schema_generation() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
let future_version = LATEST_SCHEMA_VERSION + 1;
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (57, 'future')",
|
||||
[],
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, 'future')",
|
||||
params![future_version],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||
assert!(error.contains("schema version 57 is newer"), "{error}");
|
||||
assert!(
|
||||
error.contains(&format!("schema version {future_version} is newer")),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("refusing to serve"), "{error}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user