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>, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1589,6 +1621,8 @@ pub struct RuntimeManagementSummary {
pub removable: bool, pub removable: bool,
pub endpoint_configured: bool, pub endpoint_configured: bool,
pub token_ref_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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1711,12 +1745,24 @@ pub struct RuntimeTrustConflictResponse {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[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)] #[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest { 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 display_name: Option<String>,
pub endpoint: 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -2930,6 +2976,9 @@ pub fn catalog_typescript() -> String {
RuntimeIdentityAuthority::decl(&config), RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config), RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config), RuntimeSummary::decl(&config),
WorkspaceRuntimeBindingState::decl(&config),
WorkspaceRuntimeAuthenticationMode::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config), RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config), WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config), RuntimeTrustKeyStatus::decl(&config),
@@ -2942,6 +2991,8 @@ pub fn catalog_typescript() -> String {
RevokeRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config), RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::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 REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
const MAX_DIAGNOSTICS: usize = 16; const MAX_DIAGNOSTICS: usize = 16;
const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024; 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 // Runtime creation can spend up to 60s bootstrapping; durable Submit
// acceptance is acknowledged before the potentially long run preparation. // acceptance is acknowledged before the potentially long run preparation.
const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80); const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80);
@@ -3060,18 +3061,25 @@ impl RemoteWorkerRuntime {
validate_backend_identifier("runtime_id", &config.runtime_id)?; validate_backend_identifier("runtime_id", &config.runtime_id)?;
let base_url = config.base_url.trim_end_matches('/').to_string(); let base_url = config.base_url.trim_end_matches('/').to_string();
let timeout = config.timeout; let timeout = config.timeout;
let http = let http = run_blocking_http(move || {
run_blocking_http(move || BlockingHttpClient::builder().timeout(timeout).build()) BlockingHttpClient::builder()
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { .timeout(timeout)
runtime_id: config.runtime_id.clone(), .redirect(reqwest::redirect::Policy::none())
code: "remote_runtime_client_build_failed".to_string(), .no_proxy()
message: err.to_string(), .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; // Workdir command-output waits are bounded to 20 seconds by Runtime;
// leave transport margin while retaining a finite client timeout. // leave transport margin while retaining a finite client timeout.
let workdir_timeout = timeout.max(Duration::from_secs(30)); let workdir_timeout = timeout.max(Duration::from_secs(30));
let async_http = AsyncHttpClient::builder() let async_http = AsyncHttpClient::builder()
.timeout(workdir_timeout) .timeout(workdir_timeout)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build() .build()
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(), runtime_id: config.runtime_id.clone(),
@@ -3312,12 +3320,36 @@ impl RemoteWorkerRuntime {
.map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?; .map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?;
let status = response.status(); let status = response.status();
if status.is_success() { 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( diagnostic(
"remote_runtime_malformed_response", "remote_runtime_malformed_response",
DiagnosticSeverity::Error, DiagnosticSeverity::Error,
format!( format!(
"Remote Runtime returned malformed JSON for '{}': {err}", "Remote Runtime returned malformed JSON for '{}'",
runtime_id runtime_id
), ),
) )
@@ -4516,7 +4548,13 @@ fn remote_http_status_diagnostic(
status: StatusCode, status: StatusCode,
response: reqwest::blocking::Response, response: reqwest::blocking::Response,
) -> RuntimeDiagnostic { ) -> 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 let remote_code = error
.as_ref() .as_ref()
.map(|error| error.error.code.as_str()) .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 TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
revoked_at TEXT, revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id), PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint), 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 ( CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
+12 -1
View File
@@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; 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::{ use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, 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,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
revoked_at: None, revoked_at: None,
@@ -984,6 +991,10 @@ mod tests {
public_key, public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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(), created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(), updated_at: "2026-07-26T00:00:00Z".to_string(),
revoked_at: None, revoked_at: None,
+439 -49
View File
@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::net::IpAddr;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, Weak}; use std::sync::{Arc, Mutex, RwLock, Weak};
@@ -96,8 +97,9 @@ use workspace_api::{
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState, WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle, WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeAuthenticationMode,
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse, WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
}; };
@@ -161,7 +163,9 @@ use crate::store::{
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord, TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode,
WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation,
WorkspaceRuntimeBindingState as StoredRuntimeBindingState,
}; };
use crate::workdir_removal::{ use crate::workdir_removal::{
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation, WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
@@ -2077,6 +2081,10 @@ impl WorkspaceApi {
public_key: embedded_identity.public_key.clone(), public_key: embedded_identity.public_key.clone(),
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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(), created_at: config.workspace_created_at.clone(),
updated_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(),
revoked_at: None, revoked_at: None,
@@ -2121,6 +2129,7 @@ impl WorkspaceApi {
.await? .await?
.into_iter() .into_iter()
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
.filter(|binding| binding.state == StoredRuntimeBindingState::Verified)
.filter(|binding| { .filter(|binding| {
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) 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( async fn scoped_create_remote_runtime(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<CreateRemoteRuntimeRequest>, Json(request): Json<CreateRemoteRuntimeRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> { ) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
validate_workspace_scope(&api, &path.workspace_id)?; 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( async fn scoped_get_runtime_detail(
@@ -11838,6 +11848,27 @@ async fn scoped_put_runtime_trust_key(
public_key: request.public_key, public_key: request.public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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 created_at: existing
.as_ref() .as_ref()
.map_or_else(|| now.clone(), |binding| binding.created_at.clone()), .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()); return Err(error.into());
} }
}; };
api.runtime_binding_expectations {
.write() let mut expectations = api
.unwrap_or_else(std::sync::PoisonError::into_inner) .runtime_binding_expectations
.insert( .write()
(path.workspace_id.clone(), path.runtime_id.clone()), .unwrap_or_else(std::sync::PoisonError::into_inner);
binding, if binding.state == StoredRuntimeBindingState::Verified {
); expectations.insert(
if let Some(source) = source { (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 api.runtime_subscription_broker
.register_remote_runtime(source); .register_remote_runtime(source);
} }
@@ -11984,6 +12024,30 @@ async fn scoped_test_runtime_connection(
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<RuntimeConnectionTestResponse>> { ) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; 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 test_runtime_connection(State(api), AxumPath(path.runtime_id)).await
} }
@@ -13447,31 +13511,73 @@ async fn list_workers(
} }
async fn create_remote_runtime( async fn create_remote_runtime(
State(_api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<CreateRemoteRuntimeRequest>, Json(request): Json<CreateRemoteRuntimeRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> { ) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
validate_runtime_connection_request(&request)?; require_workspace_owner(
let id = request.runtime_id.trim().to_string(); &api,
if id == EMBEDDED_WORKER_RUNTIME_ID { &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( return Err(settings_bad_request(
"embedded_runtime_not_config_managed", "embedded_runtime_not_config_managed",
"the embedded Runtime is built in and cannot be managed as a remote Runtime", "the embedded Runtime is built in and cannot be managed as a remote Runtime",
)); ));
} }
if request let identity = api
.token_ref .signing_identities
.as_ref() .get_validated(&api.config.workspace_id)?;
.is_some_and(|value| !value.trim().is_empty()) if identity.state != "active" {
{
return Err(settings_bad_request( return Err(settings_bad_request(
"remote_runtime_token_ref_unsupported", "workspace_signing_identity_unavailable",
"remote Runtime token_ref persistence is not supported", "an active Workspace signing identity is required before registering a Runtime",
)); ));
} }
Err(settings_bad_request( let now = Utc::now().to_rfc3339();
"runtime_public_key_required", let record = WorkspaceRuntimeBinding {
"remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered", 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( async fn delete_remote_runtime(
@@ -15667,8 +15773,12 @@ async fn workspace_runtime_resources_response(
.iter() .iter()
.find(|binding| binding.runtime_id == runtime.runtime_id); .find(|binding| binding.runtime_id == runtime.runtime_id);
let built_in = runtime.runtime_id == EMBEDDED_WORKER_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 { WorkspaceRuntimeResource {
runtime: runtime.into(), runtime,
management: RuntimeManagementSummary { management: RuntimeManagementSummary {
built_in, built_in,
config_managed: binding.is_some(), config_managed: binding.is_some(),
@@ -15676,6 +15786,7 @@ async fn workspace_runtime_resources_response(
endpoint_configured: binding endpoint_configured: binding
.is_some_and(|binding| !binding.base_url.trim().is_empty()), .is_some_and(|binding| !binding.base_url.trim().is_empty()),
token_ref_configured: false, token_ref_configured: false,
binding: binding.map(runtime_binding_summary),
}, },
} }
}) })
@@ -15721,6 +15832,7 @@ async fn workspace_runtime_resources_response(
removable: true, removable: true,
endpoint_configured: !binding.base_url.trim().is_empty(), endpoint_configured: !binding.base_url.trim().is_empty(),
token_ref_configured: false, 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( async fn workspace_runtime_detail(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace_id: &str, workspace_id: &str,
@@ -15775,6 +15908,7 @@ async fn workspace_runtime_detail(
removable: false, removable: false,
endpoint_configured: !binding.base_url.trim().is_empty(), endpoint_configured: !binding.base_url.trim().is_empty(),
token_ref_configured: false, 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<()> { async fn validate_runtime_connection_request(
validate_public_runtime_id(request.runtime_id.trim())?; request: &CreateRemoteRuntimeRequest,
let endpoint = request.endpoint.trim(); ) -> ApiResult<Url> {
if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) 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( return Err(settings_bad_request(
"invalid_remote_runtime_endpoint", "remote_runtime_endpoint_not_allowed",
"endpoint must be an absolute http or https URL", "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 if request
.display_name .display_name
.as_deref() .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( return Err(settings_bad_request(
"invalid_remote_runtime_display_name", "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<()> { fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> {
@@ -18062,6 +18287,10 @@ mod tests {
public_key: identity.public_key.clone(), public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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(), created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(),
revoked_at: None, revoked_at: None,
@@ -19730,21 +19959,158 @@ mod tests {
assert!(!serialized.contains("materialized_path")); assert!(!serialized.contains("materialized_path"));
} }
#[test] #[tokio::test]
fn runtime_connection_request_validation_bounds_browser_input() { async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() {
let ok = CreateRemoteRuntimeRequest { let dir = tempfile::tempdir().unwrap();
runtime_id: "team-runtime_1".to_string(), let api = test_api(dir.path()).await;
display_name: Some("Team Runtime".to_string()), let actor = test_owner_actor();
endpoint: "https://runtime.example".to_string(), api.signing_identities
token_ref: None, .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 { let bad_endpoint = CreateRemoteRuntimeRequest {
endpoint: "/tmp/socket".to_string(), endpoint: "http://169.254.169.254/latest/meta-data".to_string(),
..ok ..ok
}; };
assert!(validate_runtime_connection_request(&bad_endpoint).is_err()); assert!(
validate_runtime_connection_request(&bad_endpoint)
.await
.is_err()
);
} }
#[test] #[test]
@@ -23752,6 +24118,10 @@ mod tests {
public_key: first.public_key, public_key: first.public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
revoked_at: None, revoked_at: None,
@@ -24450,6 +24820,10 @@ mod tests {
public_key: identity.public_key.clone(), public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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(), created_at: "2026-08-11T00:00:00Z".to_string(),
updated_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(),
revoked_at: None, revoked_at: None,
@@ -24717,6 +25091,7 @@ mod tests {
assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED);
let mut revoked = trust; let mut revoked = trust;
revoked.state = StoredRuntimeBindingState::Revoked;
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
authority authority
@@ -25913,6 +26288,10 @@ mod tests {
.public_key, .public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
revoked_at: None, revoked_at: None,
@@ -27043,6 +27422,10 @@ mod tests {
public_key: identity.public_key, public_key: identity.public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
revoked_at: None, revoked_at: None,
@@ -27094,9 +27477,12 @@ mod tests {
"POST", "POST",
&runtimes_uri, &runtimes_uri,
Some(serde_json::json!({ Some(serde_json::json!({
"runtime_id": "keyless-runtime", "public_bundle": {
"identity_id": "keyless-runtime",
"public_key": ""
},
"display_name": "Keyless Runtime", "display_name": "Keyless Runtime",
"endpoint": "https://keyless.runtime.invalid" "endpoint": "https://8.8.8.8"
})), })),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
) )
@@ -27203,6 +27589,10 @@ mod tests {
.public_key, .public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
revoked_at: None, revoked_at: None,
+410 -49
View File
@@ -18,12 +18,14 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result}; use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50; 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 SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; 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_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations";
const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority"; 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] = &[ const MIGRATIONS: &[Migration] = &[
Migration { Migration {
@@ -46,6 +48,11 @@ const MIGRATIONS: &[Migration] = &[
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME, name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME,
apply: migrate_workspace_signing_identity_v53_to_v54, 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)] #[derive(Clone, Copy)]
@@ -210,11 +217,49 @@ pub struct WorkspaceRuntimeBinding {
pub public_key: String, pub public_key: String,
pub public_key_fingerprint: String, pub public_key_fingerprint: String,
pub binding_revision: u64, 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 created_at: String,
pub updated_at: String, pub updated_at: String,
pub revoked_at: Option<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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceRuntimeBindingUpsert { pub enum WorkspaceRuntimeBindingUpsert {
Created, Created,
@@ -1716,13 +1761,15 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| { self.with_conn(|conn| {
let sql = if include_revoked { let sql = if include_revoked {
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 WHERE workspace_id = ?1
ORDER BY runtime_id ASC"# ORDER BY runtime_id ASC"#
} else { } else {
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND revoked_at IS NULL WHERE workspace_id = ?1 AND revoked_at IS NULL
ORDER BY runtime_id ASC"# ORDER BY runtime_id ASC"#
@@ -1744,7 +1791,8 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.query_row( conn.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id], params![workspace_id, runtime_id],
@@ -1770,7 +1818,8 @@ impl SqliteWorkspaceStore {
let existing = tx let existing = tx
.query_row( .query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id], params![record.workspace_id, record.runtime_id],
@@ -1783,7 +1832,11 @@ impl SqliteWorkspaceStore {
&& existing.display_name == record.display_name && existing.display_name == record.display_name
&& existing.base_url == record.base_url && existing.base_url == record.base_url
&& existing.public_key == record.public_key && 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 { if exact_active_match {
tx.commit()?; tx.commit()?;
return Ok(WorkspaceRuntimeBindingUpsert::Unchanged); return Ok(WorkspaceRuntimeBindingUpsert::Unchanged);
@@ -1798,7 +1851,9 @@ impl SqliteWorkspaceStore {
r#"UPDATE workspace_runtime_bindings r#"UPDATE workspace_runtime_bindings
SET display_name = ?3, base_url = ?4, public_key = ?5, SET display_name = ?3, base_url = ?4, public_key = ?5,
public_key_fingerprint = ?6, binding_revision = binding_revision + 1, 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"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![ params![
record.workspace_id, record.workspace_id,
@@ -1807,6 +1862,10 @@ impl SqliteWorkspaceStore {
record.base_url, record.base_url,
record.public_key, record.public_key,
record.public_key_fingerprint, 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.updated_at,
record.revoked_at, record.revoked_at,
], ],
@@ -1818,8 +1877,9 @@ impl SqliteWorkspaceStore {
tx.execute( tx.execute(
r#"INSERT INTO workspace_runtime_bindings ( r#"INSERT INTO workspace_runtime_bindings (
workspace_id, runtime_id, display_name, base_url, public_key, 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,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#, 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![ params![
record.workspace_id, record.workspace_id,
record.runtime_id, record.runtime_id,
@@ -1827,6 +1887,10 @@ impl SqliteWorkspaceStore {
record.base_url, record.base_url,
record.public_key, record.public_key,
record.public_key_fingerprint, 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.created_at,
record.updated_at, record.updated_at,
record.revoked_at, record.revoked_at,
@@ -1850,7 +1914,7 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| { self.with_conn(|conn| {
let changed = conn.execute( let changed = conn.execute(
r#"UPDATE workspace_runtime_bindings 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 binding_revision = binding_revision + 1
WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#,
params![workspace_id, runtime_id, revoked_at], params![workspace_id, runtime_id, revoked_at],
@@ -1877,7 +1941,8 @@ impl SqliteWorkspaceStore {
let existing = tx let existing = tx
.query_row( .query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id], params![record.workspace_id, record.runtime_id],
@@ -1887,8 +1952,14 @@ impl SqliteWorkspaceStore {
if let Some(existing) = existing { if let Some(existing) = existing {
if existing.revoked_at.is_none() 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 == 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
{ {
tx.commit()?; tx.commit()?;
return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing));
@@ -1932,14 +2003,23 @@ impl SqliteWorkspaceStore {
})?; })?;
tx.execute( tx.execute(
r#"UPDATE workspace_runtime_bindings r#"UPDATE workspace_runtime_bindings
SET public_key = ?3, public_key_fingerprint = ?4, SET display_name = ?3, base_url = ?4,
binding_revision = ?5, updated_at = ?6, revoked_at = NULL 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"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![ params![
record.workspace_id, record.workspace_id,
record.runtime_id, record.runtime_id,
record.display_name,
record.base_url,
record.public_key, record.public_key,
record.public_key_fingerprint, record.public_key_fingerprint,
record.state.as_str(),
record.authentication_mode.as_str(),
record.workspace_key_id,
record.workspace_key_generation,
next_revision, next_revision,
record.updated_at, record.updated_at,
], ],
@@ -1957,7 +2037,8 @@ impl SqliteWorkspaceStore {
)?; )?;
let updated = tx.query_row( let updated = tx.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![record.workspace_id, record.runtime_id], params![record.workspace_id, record.runtime_id],
@@ -1991,8 +2072,9 @@ impl SqliteWorkspaceStore {
tx.execute( tx.execute(
r#"INSERT INTO workspace_runtime_bindings ( r#"INSERT INTO workspace_runtime_bindings (
workspace_id, runtime_id, display_name, base_url, public_key, 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,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#, 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![ params![
record.workspace_id, record.workspace_id,
record.runtime_id, record.runtime_id,
@@ -2000,6 +2082,10 @@ impl SqliteWorkspaceStore {
record.base_url, record.base_url,
record.public_key, record.public_key,
record.public_key_fingerprint, 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.created_at,
record.updated_at, record.updated_at,
], ],
@@ -2037,7 +2123,8 @@ impl SqliteWorkspaceStore {
let existing = tx let existing = tx
.query_row( .query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id], params![workspace_id, runtime_id],
@@ -2063,7 +2150,7 @@ impl SqliteWorkspaceStore {
.ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?;
tx.execute( tx.execute(
r#"UPDATE workspace_runtime_bindings 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"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id, revoked_at, next_revision], params![workspace_id, runtime_id, revoked_at, next_revision],
)?; )?;
@@ -2080,7 +2167,8 @@ impl SqliteWorkspaceStore {
)?; )?;
let updated = tx.query_row( let updated = tx.query_row(
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, 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 FROM workspace_runtime_bindings
WHERE workspace_id = ?1 AND runtime_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2"#,
params![workspace_id, runtime_id], params![workspace_id, runtime_id],
@@ -6339,6 +6427,29 @@ fn account_select_sql(where_clause: &str) -> String {
fn read_workspace_runtime_binding( fn read_workspace_runtime_binding(
row: &rusqlite::Row<'_>, row: &rusqlite::Row<'_>,
) -> rusqlite::Result<WorkspaceRuntimeBinding> { ) -> 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 { Ok(WorkspaceRuntimeBinding {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
runtime_id: row.get(1)?, runtime_id: row.get(1)?,
@@ -6347,9 +6458,13 @@ fn read_workspace_runtime_binding(
public_key: row.get(4)?, public_key: row.get(4)?,
public_key_fingerprint: row.get(5)?, public_key_fingerprint: row.get(5)?,
binding_revision: row.get(6)?, binding_revision: row.get(6)?,
created_at: row.get(7)?, state,
updated_at: row.get(8)?, authentication_mode,
revoked_at: row.get(9)?, 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 = canonical;
record.public_key_fingerprint = fingerprint; 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(()) Ok(())
} }
@@ -7581,10 +7729,89 @@ fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()
verify_workspace_signing_identity_schema(&tx)?; verify_workspace_signing_identity_schema(&tx)?;
tx.execute( tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![ params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME],
LATEST_SCHEMA_VERSION, )?;
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()?; tx.commit()?;
Ok(()) Ok(())
@@ -7711,7 +7938,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
let columns = table_columns(conn, "workspace_runtime_bindings")? let columns = table_columns(conn, "workspace_runtime_bindings")?
.into_iter() .into_iter()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let expected = [ let has_binding_state = columns.contains("state");
let mut expected = [
"workspace_id", "workspace_id",
"runtime_id", "runtime_id",
"display_name", "display_name",
@@ -7726,11 +7954,42 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
.into_iter() .into_iter()
.map(str::to_string) .map(str::to_string)
.collect::<BTreeSet<_>>(); .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 { if columns != expected {
return Err(Error::Store( 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( let revision_default = conn.query_row(
"SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'", "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(), .to_string(),
)); ));
} }
let mut stmt = conn.prepare( if has_binding_state {
r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, let mut stmt = conn.prepare(
public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key,
FROM workspace_runtime_bindings"#, public_key_fingerprint, binding_revision, state, authentication_mode,
)?; workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at
let rows = stmt.query_map([], read_workspace_runtime_binding)?; FROM workspace_runtime_bindings"#,
for row in rows { )?;
let binding = row?; let rows = stmt.query_map([], read_workspace_runtime_binding)?;
let mut normalized = binding.clone(); for row in rows {
normalize_workspace_runtime_binding_key(&mut normalized)?; verify_canonical_workspace_runtime_binding(row?)?;
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
)));
} }
} 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(()) Ok(())
} }
@@ -8601,6 +8898,10 @@ mod tests {
version: 54, version: 54,
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(), 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()), (52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()),
(53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()), (53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()),
(54, WORKSPACE_SIGNING_IDENTITY_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")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -8634,6 +8939,26 @@ mod tests {
)?, )?,
1 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!( assert_eq!(
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE workspace_id='workspace-a' AND runtime_id='shared' AND jti='jti-1'", "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() .iter()
.map(|migration| migration.version) .map(|migration| migration.version)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![52, 53, 54] vec![52, 53, 54, 55]
); );
SqliteWorkspaceStore::migrate_database(&path).unwrap(); SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
@@ -8680,7 +9005,7 @@ mod tests {
current_schema_version(&conn).unwrap(), current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION LATEST_SCHEMA_VERSION
); );
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5); assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 6);
} }
#[test] #[test]
@@ -8948,6 +9273,10 @@ mod tests {
public_key: identity.public_key.clone(), public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
revoked_at: None, revoked_at: None,
@@ -9068,6 +9397,10 @@ mod tests {
public_key, public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, 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(), created_at: at.to_string(),
updated_at: at.to_string(), updated_at: at.to_string(),
revoked_at: None, revoked_at: None,
@@ -9082,11 +9415,30 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); assert_eq!(created, WorkspaceRuntimeBindingMutation::Created);
assert_eq!(created_binding.binding_revision, 1); 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 let (replayed, replayed_binding) = store
.put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner")
.unwrap(); .unwrap();
assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged);
assert_eq!(replayed_binding.binding_revision, 1); 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 let stale = store
.put_workspace_runtime_binding_key( .put_workspace_runtime_binding_key(
@@ -9117,6 +9469,7 @@ mod tests {
assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked);
assert_eq!(revoked_binding.binding_revision, 3); assert_eq!(revoked_binding.binding_revision, 3);
assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4"));
assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked);
let (reactivated, reactivated_binding) = store let (reactivated, reactivated_binding) = store
.put_workspace_runtime_binding_key( .put_workspace_runtime_binding_key(
binding(second.public_key.clone(), "5"), binding(second.public_key.clone(), "5"),
@@ -9126,6 +9479,10 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated);
assert_eq!(reactivated_binding.binding_revision, 4); assert_eq!(reactivated_binding.binding_revision, 4);
assert_eq!(
reactivated_binding.state,
WorkspaceRuntimeBindingState::Configured
);
let mut duplicate = binding(second.public_key, "6"); let mut duplicate = binding(second.public_key, "6");
duplicate.runtime_id = "runtime-b".to_string(); duplicate.runtime_id = "runtime-b".to_string();
@@ -9176,6 +9533,10 @@ mod tests {
public_key, public_key,
public_key_fingerprint: String::new(), public_key_fingerprint: String::new(),
binding_revision: 1, binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
revoked_at: None, revoked_at: None,
@@ -10330,13 +10691,13 @@ INSERT INTO worker_registry (
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap(); configure_sqlite(&conn).unwrap();
conn.execute( conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')", "INSERT INTO __yoi_schema_migrations (version, name) VALUES (56, 'future')",
[], [],
) )
.unwrap(); .unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string(); 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}"); assert!(error.contains("refusing to serve"), "{error}");
} }
@@ -344,12 +344,30 @@ export type RuntimeSummary = {
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
export type WorkspaceRuntimeBindingState =
| "configured"
| "verified"
| "revoked";
export type WorkspaceRuntimeAuthenticationMode =
| "legacy_server_issuer"
| "workspace_identity";
export type WorkspaceRuntimeBindingSummary = {
state: WorkspaceRuntimeBindingState;
authentication_mode: WorkspaceRuntimeAuthenticationMode;
revision: number;
workspace_key_id?: string | null;
workspace_key_generation?: number | null;
};
export type RuntimeManagementSummary = { export type RuntimeManagementSummary = {
built_in: boolean; built_in: boolean;
config_managed: boolean; config_managed: boolean;
removable: boolean; removable: boolean;
endpoint_configured: boolean; endpoint_configured: boolean;
token_ref_configured: boolean; token_ref_configured: boolean;
binding?: WorkspaceRuntimeBindingSummary | null;
}; };
export type WorkspaceRuntimeResource = { export type WorkspaceRuntimeResource = {
@@ -418,6 +436,18 @@ export type RuntimeTrustConflictResponse = {
current_fingerprint?: string | null; current_fingerprint?: string | null;
}; };
export type RuntimePublicIdentityBundle = {
identity_id: string;
public_key: string;
};
export type CreateRemoteRuntimeRequest = {
public_bundle: RuntimePublicIdentityBundle;
display_name?: string | null;
endpoint: string;
expected_revision?: number | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind = export type RuntimeConnectionTestFailureKind =