feat: add configured Workspace Runtime bindings
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user