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