feat: add configured Workspace Runtime bindings

This commit is contained in:
2026-09-08 04:22:32 +09:00
parent 04924cf796
commit 243a081874
7 changed files with 1007 additions and 112 deletions
+53 -2
View File
@@ -1580,6 +1580,38 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeAuthenticationMode {
LegacyServerIssuer,
WorkspaceIdentity,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub workspace_key_generation: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -1589,6 +1621,8 @@ pub struct RuntimeManagementSummary {
pub removable: bool,
pub endpoint_configured: bool,
pub token_ref_configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkspaceRuntimeBindingSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1711,12 +1745,24 @@ pub struct RuntimeTrustConflictResponse {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimePublicIdentityBundle {
pub identity_id: String,
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
pub runtime_id: String,
pub public_bundle: RuntimePublicIdentityBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub endpoint: String,
pub token_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -2930,6 +2976,9 @@ pub fn catalog_typescript() -> String {
RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config),
WorkspaceRuntimeBindingState::decl(&config),
WorkspaceRuntimeAuthenticationMode::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config),
@@ -2942,6 +2991,8 @@ pub fn catalog_typescript() -> String {
RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config),
+48 -10
View File
@@ -69,6 +69,7 @@ const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
const MAX_DIAGNOSTICS: usize = 16;
const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024;
const MAX_REMOTE_RUNTIME_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
// Runtime creation can spend up to 60s bootstrapping; durable Submit
// acceptance is acknowledged before the potentially long run preparation.
const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80);
@@ -3060,18 +3061,25 @@ impl RemoteWorkerRuntime {
validate_backend_identifier("runtime_id", &config.runtime_id)?;
let base_url = config.base_url.trim_end_matches('/').to_string();
let timeout = config.timeout;
let http =
run_blocking_http(move || BlockingHttpClient::builder().timeout(timeout).build())
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
code: "remote_runtime_client_build_failed".to_string(),
message: err.to_string(),
})?;
let http = run_blocking_http(move || {
BlockingHttpClient::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
})
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
code: "remote_runtime_client_build_failed".to_string(),
message: err.to_string(),
})?;
// Workdir command-output waits are bounded to 20 seconds by Runtime;
// leave transport margin while retaining a finite client timeout.
let workdir_timeout = timeout.max(Duration::from_secs(30));
let async_http = AsyncHttpClient::builder()
.timeout(workdir_timeout)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
@@ -3312,12 +3320,36 @@ impl RemoteWorkerRuntime {
.map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?;
let status = response.status();
if status.is_success() {
response.json::<T>().map_err(|err| {
let mut body = Vec::new();
response
.take((MAX_REMOTE_RUNTIME_RESPONSE_BYTES + 1) as u64)
.read_to_end(&mut body)
.map_err(|_| {
diagnostic(
"remote_runtime_response_read_failed",
DiagnosticSeverity::Error,
format!(
"Remote Runtime response could not be read for '{}'",
runtime_id
),
)
})?;
if body.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES {
return Err(diagnostic(
"remote_runtime_response_too_large",
DiagnosticSeverity::Error,
format!(
"Remote Runtime response exceeded the allowed size for '{}'",
runtime_id
),
));
}
serde_json::from_slice::<T>(&body).map_err(|_| {
diagnostic(
"remote_runtime_malformed_response",
DiagnosticSeverity::Error,
format!(
"Remote Runtime returned malformed JSON for '{}': {err}",
"Remote Runtime returned malformed JSON for '{}'",
runtime_id
),
)
@@ -4516,7 +4548,13 @@ fn remote_http_status_diagnostic(
status: StatusCode,
response: reqwest::blocking::Response,
) -> RuntimeDiagnostic {
let error = response.json::<RuntimeHttpErrorResponse>().ok();
let mut body = Vec::new();
let error = response
.take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64)
.read_to_end(&mut body)
.ok()
.filter(|_| body.len() <= MAX_RUNTIME_PING_RESPONSE_BYTES)
.and_then(|_| serde_json::from_slice::<RuntimeHttpErrorResponse>(&body).ok());
let remote_code = error
.as_ref()
.map(|error| error.error.code.as_str())
+15 -1
View File
@@ -441,12 +441,26 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
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),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
);
CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL,
+12 -1
View File
@@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
@@ -335,6 +338,10 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
@@ -984,6 +991,10 @@ mod tests {
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
revoked_at: None,
+439 -49
View File
@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::net::IpAddr;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, Weak};
@@ -96,8 +97,9 @@ use workspace_api::{
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
};
@@ -161,7 +163,9 @@ use crate::store::{
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord,
WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode,
WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation,
WorkspaceRuntimeBindingState as StoredRuntimeBindingState,
};
use crate::workdir_removal::{
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
@@ -2077,6 +2081,10 @@ impl WorkspaceApi {
public_key: embedded_identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: config.workspace_created_at.clone(),
updated_at: config.workspace_created_at.clone(),
revoked_at: None,
@@ -2121,6 +2129,7 @@ impl WorkspaceApi {
.await?
.into_iter()
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
.filter(|binding| binding.state == StoredRuntimeBindingState::Verified)
.filter(|binding| {
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
})
@@ -11727,10 +11736,11 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError {
async fn scoped_create_remote_runtime(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<CreateRemoteRuntimeRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
validate_workspace_scope(&api, &path.workspace_id)?;
create_remote_runtime(State(api), Json(request)).await
create_remote_runtime(State(api), Extension(actor), Json(request)).await
}
async fn scoped_get_runtime_detail(
@@ -11838,6 +11848,27 @@ async fn scoped_put_runtime_trust_key(
public_key: request.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: existing.as_ref().map_or(
StoredRuntimeBindingState::Verified,
|binding| match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
StoredRuntimeBindingState::Verified
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
StoredRuntimeBindingState::Configured
}
},
),
authentication_mode: existing.as_ref().map_or(
StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|binding| binding.authentication_mode,
),
workspace_key_id: existing
.as_ref()
.and_then(|binding| binding.workspace_key_id.clone()),
workspace_key_generation: existing
.as_ref()
.and_then(|binding| binding.workspace_key_generation),
created_at: existing
.as_ref()
.map_or_else(|| now.clone(), |binding| binding.created_at.clone()),
@@ -11857,14 +11888,23 @@ async fn scoped_put_runtime_trust_key(
return Err(error.into());
}
};
api.runtime_binding_expectations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
(path.workspace_id.clone(), path.runtime_id.clone()),
binding,
);
if let Some(source) = source {
{
let mut expectations = api
.runtime_binding_expectations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if binding.state == StoredRuntimeBindingState::Verified {
expectations.insert(
(path.workspace_id.clone(), path.runtime_id.clone()),
binding.clone(),
);
} else {
expectations.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
}
}
if binding.state == StoredRuntimeBindingState::Verified
&& let Some(source) = source
{
api.runtime_subscription_broker
.register_remote_runtime(source);
}
@@ -11984,6 +12024,30 @@ async fn scoped_test_runtime_connection(
AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let binding = api
.store
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
.await?
.ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?;
if binding.state != StoredRuntimeBindingState::Verified {
return Err(Error::RuntimeBindingConflict(format!(
"Runtime `{}` is not an authenticated verified connection candidate",
path.runtime_id
))
.into());
}
if binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity {
validate_runtime_connection_request(&CreateRemoteRuntimeRequest {
public_bundle: workspace_api::RuntimePublicIdentityBundle {
identity_id: binding.runtime_id.clone(),
public_key: binding.public_key.clone(),
},
display_name: Some(binding.display_name.clone()),
endpoint: binding.base_url.clone(),
expected_revision: Some(binding.binding_revision),
})
.await?;
}
test_runtime_connection(State(api), AxumPath(path.runtime_id)).await
}
@@ -13447,31 +13511,73 @@ async fn list_workers(
}
async fn create_remote_runtime(
State(_api): State<WorkspaceApi>,
State(api): State<WorkspaceApi>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<CreateRemoteRuntimeRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
validate_runtime_connection_request(&request)?;
let id = request.runtime_id.trim().to_string();
if id == EMBEDDED_WORKER_RUNTIME_ID {
require_workspace_owner(
&api,
&api.config.workspace_id,
&actor,
"manage Workspace Runtimes",
)
.await?;
let endpoint = validate_runtime_connection_request(&request).await?;
let runtime_id = request.public_bundle.identity_id.trim().to_string();
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_not_config_managed",
"the embedded Runtime is built in and cannot be managed as a remote Runtime",
));
}
if request
.token_ref
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
{
let identity = api
.signing_identities
.get_validated(&api.config.workspace_id)?;
if identity.state != "active" {
return Err(settings_bad_request(
"remote_runtime_token_ref_unsupported",
"remote Runtime token_ref persistence is not supported",
"workspace_signing_identity_unavailable",
"an active Workspace signing identity is required before registering a Runtime",
));
}
Err(settings_bad_request(
"runtime_public_key_required",
"remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered",
))
let now = Utc::now().to_rfc3339();
let record = WorkspaceRuntimeBinding {
workspace_id: api.config.workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: request
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&runtime_id)
.to_string(),
base_url: endpoint.to_string().trim_end_matches('/').to_string(),
public_key: request.public_bundle.public_key.trim().to_string(),
public_key_fingerprint: String::new(),
binding_revision: request.expected_revision.unwrap_or(0),
state: StoredRuntimeBindingState::Configured,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(identity.key_id.clone()),
workspace_key_generation: Some(identity.revision),
created_at: now.clone(),
updated_at: now,
revoked_at: None,
};
let (mutation, _) = api
.store
.put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id)
.await?;
let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id)
.await?
.items
.into_iter()
.find(|resource| resource.runtime.runtime_id == runtime_id)
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
let status = if mutation == WorkspaceRuntimeBindingMutation::Created {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((status, Json(resource)))
}
async fn delete_remote_runtime(
@@ -15667,8 +15773,12 @@ async fn workspace_runtime_resources_response(
.iter()
.find(|binding| binding.runtime_id == runtime.runtime_id);
let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
let mut runtime: workspace_api::RuntimeSummary = runtime.into();
if binding.is_some_and(|binding| binding.state != StoredRuntimeBindingState::Verified) {
runtime.worker_creation_available = false;
}
WorkspaceRuntimeResource {
runtime: runtime.into(),
runtime,
management: RuntimeManagementSummary {
built_in,
config_managed: binding.is_some(),
@@ -15676,6 +15786,7 @@ async fn workspace_runtime_resources_response(
endpoint_configured: binding
.is_some_and(|binding| !binding.base_url.trim().is_empty()),
token_ref_configured: false,
binding: binding.map(runtime_binding_summary),
},
}
})
@@ -15721,6 +15832,7 @@ async fn workspace_runtime_resources_response(
removable: true,
endpoint_configured: !binding.base_url.trim().is_empty(),
token_ref_configured: false,
binding: Some(runtime_binding_summary(&binding)),
},
});
}
@@ -15734,6 +15846,27 @@ async fn workspace_runtime_resources_response(
})
}
fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeBindingSummary {
WorkspaceRuntimeBindingSummary {
state: match binding.state {
StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured,
StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified,
StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked,
},
authentication_mode: match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
}
},
revision: binding.binding_revision,
workspace_key_id: binding.workspace_key_id.clone(),
workspace_key_generation: binding.workspace_key_generation,
}
}
async fn workspace_runtime_detail(
api: &WorkspaceApi,
workspace_id: &str,
@@ -15775,6 +15908,7 @@ async fn workspace_runtime_detail(
removable: false,
endpoint_configured: !binding.base_url.trim().is_empty(),
token_ref_configured: false,
binding: Some(runtime_binding_summary(&binding)),
},
});
}
@@ -15858,27 +15992,118 @@ fn project_runtime_trust_audit(
})
}
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
validate_public_runtime_id(request.runtime_id.trim())?;
let endpoint = request.endpoint.trim();
if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://"))
async fn validate_runtime_connection_request(
request: &CreateRemoteRuntimeRequest,
) -> ApiResult<Url> {
validate_public_runtime_id(request.public_bundle.identity_id.trim())?;
if request.public_bundle.public_key.trim().is_empty() {
return Err(settings_bad_request(
"runtime_public_key_required",
"Runtime public bundle must contain a public key",
));
}
let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| {
settings_bad_request(
"invalid_remote_runtime_endpoint",
"endpoint must be an absolute https URL",
)
})?;
if endpoint.scheme() != "https"
|| endpoint.host_str().is_none()
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
{
return Err(settings_bad_request(
"invalid_remote_runtime_endpoint",
"endpoint must be an absolute http or https URL",
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint must be an https origin without credentials, 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_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",
)
})?
.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_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
.as_deref()
.is_some_and(|value| value.chars().any(char::is_control))
.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control))
{
return Err(settings_bad_request(
"invalid_remote_runtime_display_name",
"display_name cannot contain control characters",
"display_name must be non-empty when supplied and cannot contain control characters",
));
}
Ok(())
Ok(endpoint)
}
fn is_disallowed_runtime_address(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
let octets = address.octets();
address.is_private()
|| address.is_loopback()
|| address.is_link_local()
|| address.is_unspecified()
|| address.is_broadcast()
|| address.is_documentation()
|| address.is_multicast()
|| octets[0] == 0
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|| (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))
|| octets[0] >= 240
}
IpAddr::V6(address) => {
let segments = address.segments();
address.is_loopback()
|| address.is_unspecified()
|| address.is_multicast()
|| (segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|| address
.to_ipv4_mapped()
.is_some_and(|address| is_disallowed_runtime_address(IpAddr::V4(address)))
}
}
}
fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> {
@@ -18062,6 +18287,10 @@ mod tests {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -19730,21 +19959,158 @@ mod tests {
assert!(!serialized.contains("materialized_path"));
}
#[test]
fn runtime_connection_request_validation_bounds_browser_input() {
let ok = CreateRemoteRuntimeRequest {
runtime_id: "team-runtime_1".to_string(),
display_name: Some("Team Runtime".to_string()),
endpoint: "https://runtime.example".to_string(),
token_ref: None,
#[tokio::test]
async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let actor = test_owner_actor();
api.signing_identities
.provision_existing(&api.config.workspace_id, &actor.account_id)
.unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap();
let request = CreateRemoteRuntimeRequest {
public_bundle: workspace_api::RuntimePublicIdentityBundle {
identity_id: "configured-runtime".to_string(),
public_key: runtime_identity.public_key,
},
display_name: Some("Configured Runtime".to_string()),
endpoint: "https://8.8.8.8".to_string(),
expected_revision: None,
};
assert!(validate_runtime_connection_request(&ok).is_ok());
let mut non_owner = actor.clone();
non_owner.user_id = "other-user".to_string();
non_owner.account_id = "other-account".to_string();
non_owner.handle = "other".to_string();
let denied = create_remote_runtime(
State(api.clone()),
Extension(non_owner),
Json(request.clone()),
)
.await
.unwrap_err();
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
let (status, Json(created)) = create_remote_runtime(
State(api.clone()),
Extension(actor.clone()),
Json(request.clone()),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
let binding = created.management.binding.as_ref().unwrap();
assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured);
assert_eq!(
binding.authentication_mode,
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
);
assert_eq!(binding.revision, 1);
assert!(binding.workspace_key_id.is_some());
assert_eq!(binding.workspace_key_generation, Some(1));
assert!(!created.runtime.worker_creation_available);
let configured_test = scoped_test_runtime_connection(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: api.config.workspace_id.clone(),
runtime_id: "configured-runtime".to_string(),
}),
)
.await
.unwrap_err();
assert_eq!(
configured_test.into_response().status(),
StatusCode::CONFLICT
);
let (status, Json(replayed)) = create_remote_runtime(
State(api.clone()),
Extension(actor.clone()),
Json(request.clone()),
)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(replayed.management.binding.unwrap().revision, 1);
let mut mismatched = request.clone();
mismatched.display_name = Some("Different Runtime".to_string());
let error = create_remote_runtime(
State(api.clone()),
Extension(actor.clone()),
Json(mismatched.clone()),
)
.await
.unwrap_err();
assert_eq!(error.into_response().status(), StatusCode::CONFLICT);
mismatched.expected_revision = Some(1);
let (status, Json(replaced)) =
create_remote_runtime(State(api.clone()), Extension(actor), Json(mismatched))
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(replaced.management.binding.unwrap().revision, 2);
let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap();
let unknown_revision = create_remote_runtime(
State(api.clone()),
Extension(test_owner_actor()),
Json(CreateRemoteRuntimeRequest {
public_bundle: workspace_api::RuntimePublicIdentityBundle {
identity_id: "unknown-runtime".to_string(),
public_key: unknown_identity.public_key,
},
display_name: None,
endpoint: "https://8.8.4.4".to_string(),
expected_revision: Some(9),
}),
)
.await
.unwrap_err();
assert_eq!(
unknown_revision.into_response().status(),
StatusCode::CONFLICT
);
let cross_workspace = scoped_create_remote_runtime(
State(api),
AxumPath(ScopedWorkspacePath {
workspace_id: "another-workspace".to_string(),
}),
Extension(test_owner_actor()),
Json(request),
)
.await
.unwrap_err();
assert_eq!(
cross_workspace.into_response().status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn runtime_connection_request_validation_bounds_browser_input() {
let identity = RuntimeIdentityMaterial::generate("team-runtime_1").unwrap();
let ok = CreateRemoteRuntimeRequest {
public_bundle: workspace_api::RuntimePublicIdentityBundle {
identity_id: "team-runtime_1".to_string(),
public_key: identity.public_key,
},
display_name: Some("Team Runtime".to_string()),
endpoint: "https://8.8.8.8".to_string(),
expected_revision: None,
};
assert!(validate_runtime_connection_request(&ok).await.is_ok());
let bad_endpoint = CreateRemoteRuntimeRequest {
endpoint: "/tmp/socket".to_string(),
endpoint: "http://169.254.169.254/latest/meta-data".to_string(),
..ok
};
assert!(validate_runtime_connection_request(&bad_endpoint).is_err());
assert!(
validate_runtime_connection_request(&bad_endpoint)
.await
.is_err()
);
}
#[test]
@@ -23752,6 +24118,10 @@ mod tests {
public_key: first.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
@@ -24450,6 +24820,10 @@ mod tests {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-08-11T00:00:00Z".to_string(),
updated_at: "2026-08-11T00:00:00Z".to_string(),
revoked_at: None,
@@ -24717,6 +25091,7 @@ mod tests {
assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED);
let mut revoked = trust;
revoked.state = StoredRuntimeBindingState::Revoked;
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
authority
@@ -25913,6 +26288,10 @@ mod tests {
.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
@@ -27043,6 +27422,10 @@ mod tests {
public_key: identity.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
@@ -27094,9 +27477,12 @@ mod tests {
"POST",
&runtimes_uri,
Some(serde_json::json!({
"runtime_id": "keyless-runtime",
"public_bundle": {
"identity_id": "keyless-runtime",
"public_key": ""
},
"display_name": "Keyless Runtime",
"endpoint": "https://keyless.runtime.invalid"
"endpoint": "https://8.8.8.8"
})),
StatusCode::BAD_REQUEST,
)
@@ -27203,6 +27589,10 @@ mod tests {
.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
+410 -49
View File
@@ -18,12 +18,14 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 54;
const LATEST_SCHEMA_VERSION: i64 = 55;
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";
const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations";
const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority";
const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str =
"Workspace Runtime binding state and identity mode";
const MIGRATIONS: &[Migration] = &[
Migration {
@@ -46,6 +48,11 @@ const MIGRATIONS: &[Migration] = &[
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME,
apply: migrate_workspace_signing_identity_v53_to_v54,
},
Migration {
version: 55,
name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME,
apply: migrate_workspace_runtime_binding_state_v54_to_v55,
},
];
#[derive(Clone, Copy)]
@@ -210,11 +217,49 @@ pub struct WorkspaceRuntimeBinding {
pub public_key: String,
pub public_key_fingerprint: String,
pub binding_revision: u64,
pub state: WorkspaceRuntimeBindingState,
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
pub workspace_key_id: Option<String>,
pub workspace_key_generation: Option<u64>,
pub created_at: String,
pub updated_at: String,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
impl WorkspaceRuntimeBindingState {
fn as_str(self) -> &'static str {
match self {
Self::Configured => "configured",
Self::Verified => "verified",
Self::Revoked => "revoked",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeAuthenticationMode {
LegacyServerIssuer,
WorkspaceIdentity,
}
impl WorkspaceRuntimeAuthenticationMode {
fn as_str(self) -> &'static str {
match self {
Self::LegacyServerIssuer => "legacy_server_issuer",
Self::WorkspaceIdentity => "workspace_identity",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceRuntimeBindingUpsert {
Created,
@@ -1716,13 +1761,15 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| {
let sql = if include_revoked {
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1
ORDER BY runtime_id ASC"#
} else {
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND revoked_at IS NULL
ORDER BY runtime_id ASC"#
@@ -1744,7 +1791,8 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id],
@@ -1770,7 +1818,8 @@ impl SqliteWorkspaceStore {
let existing = tx
.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id],
@@ -1783,7 +1832,11 @@ impl SqliteWorkspaceStore {
&& existing.display_name == record.display_name
&& existing.base_url == record.base_url
&& existing.public_key == record.public_key
&& existing.public_key_fingerprint == record.public_key_fingerprint;
&& existing.public_key_fingerprint == record.public_key_fingerprint
&& existing.state == record.state
&& existing.authentication_mode == record.authentication_mode
&& existing.workspace_key_id == record.workspace_key_id
&& existing.workspace_key_generation == record.workspace_key_generation;
if exact_active_match {
tx.commit()?;
return Ok(WorkspaceRuntimeBindingUpsert::Unchanged);
@@ -1798,7 +1851,9 @@ impl SqliteWorkspaceStore {
r#"UPDATE workspace_runtime_bindings
SET display_name = ?3, base_url = ?4, public_key = ?5,
public_key_fingerprint = ?6, binding_revision = binding_revision + 1,
updated_at = ?7, revoked_at = ?8
state = ?7, authentication_mode = ?8,
workspace_key_id = ?9, workspace_key_generation = ?10,
updated_at = ?11, revoked_at = ?12
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![
record.workspace_id,
@@ -1807,6 +1862,10 @@ impl SqliteWorkspaceStore {
record.base_url,
record.public_key,
record.public_key_fingerprint,
record.state.as_str(),
record.authentication_mode.as_str(),
record.workspace_key_id,
record.workspace_key_generation,
record.updated_at,
record.revoked_at,
],
@@ -1818,8 +1877,9 @@ impl SqliteWorkspaceStore {
tx.execute(
r#"INSERT INTO workspace_runtime_bindings (
workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#,
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#,
params![
record.workspace_id,
record.runtime_id,
@@ -1827,6 +1887,10 @@ impl SqliteWorkspaceStore {
record.base_url,
record.public_key,
record.public_key_fingerprint,
record.state.as_str(),
record.authentication_mode.as_str(),
record.workspace_key_id,
record.workspace_key_generation,
record.created_at,
record.updated_at,
record.revoked_at,
@@ -1850,7 +1914,7 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| {
let changed = conn.execute(
r#"UPDATE workspace_runtime_bindings
SET revoked_at = ?3, updated_at = ?3,
SET state = 'revoked', revoked_at = ?3, updated_at = ?3,
binding_revision = binding_revision + 1
WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#,
params![workspace_id, runtime_id, revoked_at],
@@ -1877,7 +1941,8 @@ impl SqliteWorkspaceStore {
let existing = tx
.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id],
@@ -1887,8 +1952,14 @@ impl SqliteWorkspaceStore {
if let Some(existing) = existing {
if existing.revoked_at.is_none()
&& existing.display_name == record.display_name
&& existing.base_url == record.base_url
&& existing.public_key == record.public_key
&& existing.public_key_fingerprint == record.public_key_fingerprint
&& existing.state == record.state
&& existing.authentication_mode == record.authentication_mode
&& existing.workspace_key_id == record.workspace_key_id
&& existing.workspace_key_generation == record.workspace_key_generation
{
tx.commit()?;
return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing));
@@ -1932,14 +2003,23 @@ impl SqliteWorkspaceStore {
})?;
tx.execute(
r#"UPDATE workspace_runtime_bindings
SET public_key = ?3, public_key_fingerprint = ?4,
binding_revision = ?5, updated_at = ?6, revoked_at = NULL
SET display_name = ?3, base_url = ?4,
public_key = ?5, public_key_fingerprint = ?6,
state = ?7, authentication_mode = ?8,
workspace_key_id = ?9, workspace_key_generation = ?10,
binding_revision = ?11, updated_at = ?12, revoked_at = NULL
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![
record.workspace_id,
record.runtime_id,
record.display_name,
record.base_url,
record.public_key,
record.public_key_fingerprint,
record.state.as_str(),
record.authentication_mode.as_str(),
record.workspace_key_id,
record.workspace_key_generation,
next_revision,
record.updated_at,
],
@@ -1957,7 +2037,8 @@ impl SqliteWorkspaceStore {
)?;
let updated = tx.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id],
@@ -1991,8 +2072,9 @@ impl SqliteWorkspaceStore {
tx.execute(
r#"INSERT INTO workspace_runtime_bindings (
workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#,
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, NULL)"#,
params![
record.workspace_id,
record.runtime_id,
@@ -2000,6 +2082,10 @@ impl SqliteWorkspaceStore {
record.base_url,
record.public_key,
record.public_key_fingerprint,
record.state.as_str(),
record.authentication_mode.as_str(),
record.workspace_key_id,
record.workspace_key_generation,
record.created_at,
record.updated_at,
],
@@ -2037,7 +2123,8 @@ impl SqliteWorkspaceStore {
let existing = tx
.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id],
@@ -2063,7 +2150,7 @@ impl SqliteWorkspaceStore {
.ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?;
tx.execute(
r#"UPDATE workspace_runtime_bindings
SET revoked_at = ?3, updated_at = ?3, binding_revision = ?4
SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = ?4
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id, revoked_at, next_revision],
)?;
@@ -2080,7 +2167,8 @@ impl SqliteWorkspaceStore {
)?;
let updated = tx.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id],
@@ -6339,6 +6427,29 @@ fn account_select_sql(where_clause: &str) -> String {
fn read_workspace_runtime_binding(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<WorkspaceRuntimeBinding> {
let state = match row.get::<_, String>(7)?.as_str() {
"configured" => WorkspaceRuntimeBindingState::Configured,
"verified" => WorkspaceRuntimeBindingState::Verified,
"revoked" => WorkspaceRuntimeBindingState::Revoked,
value => {
return Err(rusqlite::Error::FromSqlConversionFailure(
7,
rusqlite::types::Type::Text,
format!("unknown Workspace Runtime binding state {value}").into(),
));
}
};
let authentication_mode = match row.get::<_, String>(8)?.as_str() {
"legacy_server_issuer" => WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
"workspace_identity" => WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
value => {
return Err(rusqlite::Error::FromSqlConversionFailure(
8,
rusqlite::types::Type::Text,
format!("unknown Workspace Runtime authentication mode {value}").into(),
));
}
};
Ok(WorkspaceRuntimeBinding {
workspace_id: row.get(0)?,
runtime_id: row.get(1)?,
@@ -6347,9 +6458,13 @@ fn read_workspace_runtime_binding(
public_key: row.get(4)?,
public_key_fingerprint: row.get(5)?,
binding_revision: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
revoked_at: row.get(9)?,
state,
authentication_mode,
workspace_key_id: row.get(9)?,
workspace_key_generation: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
revoked_at: row.get(13)?,
})
}
@@ -6405,6 +6520,39 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding)
}
record.public_key = canonical;
record.public_key_fingerprint = fingerprint;
match record.authentication_mode {
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"
.into(),
));
}
}
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity => {
let key_id = record.workspace_key_id.as_deref().ok_or_else(|| {
Error::InvalidInput(
"Workspace identity Runtime bindings require workspace_key_id".into(),
)
})?;
validate_identifier("workspace_key_id", key_id)?;
if record
.workspace_key_generation
.is_none_or(|generation| generation == 0)
{
return Err(Error::InvalidInput(
"Workspace identity Runtime bindings require a positive workspace_key_generation"
.into(),
));
}
}
}
let revoked = record.revoked_at.is_some();
if (record.state == WorkspaceRuntimeBindingState::Revoked) != revoked {
return Err(Error::InvalidInput(
"Runtime binding state and revoked_at must agree".into(),
));
}
Ok(())
}
@@ -7581,10 +7729,89 @@ fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()
verify_workspace_signing_identity_schema(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![
LATEST_SCHEMA_VERSION,
WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME
],
params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn migrate_workspace_runtime_binding_state_v54_to_v55(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
if current != 54 {
return Err(Error::Store(format!(
"expected schema version 54 before {WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
let columns = table_columns(&tx, "workspace_runtime_bindings")?;
let lifecycle_columns = [
"state",
"authentication_mode",
"workspace_key_id",
"workspace_key_generation",
];
let present = lifecycle_columns
.iter()
.filter(|column| columns.iter().any(|existing| existing == **column))
.count();
if present == 0 {
tx.execute_batch(
r#"
CREATE TABLE workspace_runtime_bindings_v55 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
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),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
);
INSERT INTO workspace_runtime_bindings_v55 (
workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
)
SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision,
CASE WHEN revoked_at IS NULL THEN 'verified' ELSE 'revoked' END,
'legacy_server_issuer', NULL, NULL, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings;
DROP TABLE workspace_runtime_bindings;
ALTER TABLE workspace_runtime_bindings_v55 RENAME TO workspace_runtime_bindings;
CREATE INDEX idx_workspace_runtime_bindings_workspace
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);
"#,
)?;
} else if present != lifecycle_columns.len() {
return Err(Error::Store(
"workspace_runtime_bindings has a partially applied lifecycle schema".to_string(),
));
}
verify_workspace_runtime_binding_schema(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![55_i64, WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
@@ -7711,7 +7938,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
let columns = table_columns(conn, "workspace_runtime_bindings")?
.into_iter()
.collect::<BTreeSet<_>>();
let expected = [
let has_binding_state = columns.contains("state");
let mut expected = [
"workspace_id",
"runtime_id",
"display_name",
@@ -7726,11 +7954,42 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
.into_iter()
.map(str::to_string)
.collect::<BTreeSet<_>>();
if has_binding_state {
expected.extend(
[
"state",
"authentication_mode",
"workspace_key_id",
"workspace_key_generation",
]
.into_iter()
.map(str::to_string),
);
}
if columns != expected {
return Err(Error::Store(
"workspace_runtime_bindings schema does not match schema-52".to_string(),
"workspace_runtime_bindings schema does not match the supported schema".to_string(),
));
}
if has_binding_state {
let invalid_lifecycle_count = conn.query_row(
"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 = 'workspace_identity' AND
(workspace_key_id IS NULL OR workspace_key_generation IS NULL))
OR (state = 'revoked' AND revoked_at IS NULL)
OR (state <> 'revoked' AND revoked_at IS NOT NULL)",
[],
|row| row.get::<_, i64>(0),
)?;
if invalid_lifecycle_count != 0 {
return Err(Error::Store(
"workspace_runtime_bindings contains invalid binding lifecycle metadata"
.to_string(),
));
}
}
let revision_default = conn.query_row(
"SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'",
[],
@@ -7807,24 +8066,62 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
.to_string(),
));
}
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings"#,
)?;
let rows = stmt.query_map([], read_workspace_runtime_binding)?;
for row in rows {
let binding = row?;
let mut normalized = binding.clone();
normalize_workspace_runtime_binding_key(&mut normalized)?;
if normalized.public_key != binding.public_key
|| normalized.public_key_fingerprint != binding.public_key_fingerprint
{
return Err(Error::Store(format!(
"Runtime binding `{}/{}` has non-canonical trust content",
binding.workspace_id, binding.runtime_id
)));
if has_binding_state {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, state, authentication_mode,
workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings"#,
)?;
let rows = stmt.query_map([], read_workspace_runtime_binding)?;
for row in rows {
verify_canonical_workspace_runtime_binding(row?)?;
}
} else {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at
FROM workspace_runtime_bindings"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(WorkspaceRuntimeBinding {
workspace_id: row.get(0)?,
runtime_id: row.get(1)?,
display_name: row.get(2)?,
base_url: row.get(3)?,
public_key: row.get(4)?,
public_key_fingerprint: row.get(5)?,
binding_revision: row.get(6)?,
state: if row.get::<_, Option<String>>(9)?.is_some() {
WorkspaceRuntimeBindingState::Revoked
} else {
WorkspaceRuntimeBindingState::Verified
},
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: row.get(7)?,
updated_at: row.get(8)?,
revoked_at: row.get(9)?,
})
})?;
for row in rows {
verify_canonical_workspace_runtime_binding(row?)?;
}
}
Ok(())
}
fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> {
let mut normalized = binding.clone();
normalize_workspace_runtime_binding_key(&mut normalized)?;
if normalized.public_key != binding.public_key
|| normalized.public_key_fingerprint != binding.public_key_fingerprint
{
return Err(Error::Store(format!(
"Runtime binding `{}/{}` has non-canonical trust content",
binding.workspace_id, binding.runtime_id
)));
}
Ok(())
}
@@ -8601,6 +8898,10 @@ mod tests {
version: 54,
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 55,
name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(),
},
]
);
@@ -8623,6 +8924,10 @@ mod tests {
(52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()),
(53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()),
(54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()),
(
55,
WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(),
),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -8634,6 +8939,26 @@ mod tests {
)?,
1
);
assert_eq!(
conn.query_row(
"SELECT state || ':' || authentication_mode
FROM workspace_runtime_bindings
WHERE workspace_id='workspace-a' AND runtime_id='shared'",
[],
|row| row.get::<_, String>(0),
)?,
"verified:legacy_server_issuer"
);
assert!(
conn.execute(
"UPDATE workspace_runtime_bindings
SET state='configured', authentication_mode='workspace_identity'
WHERE workspace_id='workspace-a' AND runtime_id='shared'",
[],
)
.is_err(),
"migrated schema must reject Workspace identity mode without key metadata"
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE workspace_id='workspace-a' AND runtime_id='shared' AND jti='jti-1'",
@@ -8672,7 +8997,7 @@ mod tests {
.iter()
.map(|migration| migration.version)
.collect::<Vec<_>>(),
vec![52, 53, 54]
vec![52, 53, 54, 55]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap();
@@ -8680,7 +9005,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 6);
}
#[test]
@@ -8948,6 +9273,10 @@ mod tests {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
@@ -9068,6 +9397,10 @@ mod tests {
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Configured,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-a".to_string()),
workspace_key_generation: Some(1),
created_at: at.to_string(),
updated_at: at.to_string(),
revoked_at: None,
@@ -9082,11 +9415,30 @@ mod tests {
.unwrap();
assert_eq!(created, WorkspaceRuntimeBindingMutation::Created);
assert_eq!(created_binding.binding_revision, 1);
assert_eq!(
created_binding.state,
WorkspaceRuntimeBindingState::Configured
);
assert_eq!(
created_binding.authentication_mode,
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
);
assert_eq!(created_binding.workspace_key_id.as_deref(), Some("WK-a"));
assert_eq!(created_binding.workspace_key_generation, Some(1));
let (replayed, replayed_binding) = store
.put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner")
.unwrap();
assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged);
assert_eq!(replayed_binding.binding_revision, 1);
let mut mismatched_replay = binding(replayed_binding.public_key.clone(), "2");
mismatched_replay.base_url = "https://different.runtime.test".to_string();
assert!(matches!(
store.put_workspace_runtime_binding_key(mismatched_replay, None, "owner"),
Err(Error::RuntimeBindingRevisionConflict {
expected: None,
actual: Some(1)
})
));
let stale = store
.put_workspace_runtime_binding_key(
@@ -9117,6 +9469,7 @@ mod tests {
assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked);
assert_eq!(revoked_binding.binding_revision, 3);
assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4"));
assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked);
let (reactivated, reactivated_binding) = store
.put_workspace_runtime_binding_key(
binding(second.public_key.clone(), "5"),
@@ -9126,6 +9479,10 @@ mod tests {
.unwrap();
assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated);
assert_eq!(reactivated_binding.binding_revision, 4);
assert_eq!(
reactivated_binding.state,
WorkspaceRuntimeBindingState::Configured
);
let mut duplicate = binding(second.public_key, "6");
duplicate.runtime_id = "runtime-b".to_string();
@@ -9176,6 +9533,10 @@ mod tests {
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
@@ -10330,13 +10691,13 @@ INSERT INTO worker_registry (
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')",
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (56, 'future')",
[],
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 55 is newer"), "{error}");
assert!(error.contains("schema version 56 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}");
}