fix: restore remote Runtime management contracts

This commit is contained in:
2026-09-09 00:26:04 +09:00
parent a072562034
commit 18fd6a1f5e
23 changed files with 1144 additions and 529 deletions
+43
View File
@@ -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()
+32 -2
View File
@@ -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,
+108 -61
View File
@@ -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(
+354 -39
View File
@@ -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}");
}