From ebec98a14cfba45a1a305620c2c345f3e8aa6950 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 7 Sep 2026 23:52:20 +0900 Subject: [PATCH] feat: add Workspace signing identity authority --- crates/workspace-api/src/lib.rs | 101 +++ crates/workspace-server/src/latest_schema.sql | 45 ++ crates/workspace-server/src/lib.rs | 3 + crates/workspace-server/src/server.rs | 271 ++++++- crates/workspace-server/src/store.rs | 764 +++++++++++++++++- .../workspace-server/src/workspace_catalog.rs | 80 +- .../src/workspace_deletion.rs | 3 + .../src/workspace_signing_identity.rs | 685 ++++++++++++++++ .../src/lib/generated/workspace-api.ts | 29 + .../src/lib/workspace/settings/profile-api.ts | 243 ++++++ .../settings/workspace/+page.svelte | 99 +++ web/workspace/tests/profile-api.test.ts | 73 ++ 12 files changed, 2361 insertions(+), 35 deletions(-) create mode 100644 crates/workspace-server/src/workspace_signing_identity.rs diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index e52b45e8..7529c12d 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse { pub diagnostics: Vec, } +/// Lifecycle state for a Workspace-scoped Ed25519 signing identity. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSigningIdentityState { + PendingProvisioning, + Active, +} + +/// Public metadata for a Workspace signing identity. Private material and its +/// storage reference are deliberately not part of this wire authority. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningIdentityPublic { + pub workspace_id: String, + pub key_id: String, + pub algorithm: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_key_fingerprint: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + pub state: WorkspaceSigningIdentityState, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub provisioned_at: Option, +} + +/// Copyable public trust bundle consumed by future Runtime enrollment work. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspacePublicIdentityBundle { + pub workspace_id: String, + pub backend_url: String, + pub key_id: String, + pub algorithm: String, + pub public_key: String, + pub public_key_fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningIdentityResponse { + pub identity: WorkspaceSigningIdentityPublic, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_bundle: Option, +} + pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; @@ -2848,6 +2906,10 @@ pub fn catalog_typescript() -> String { WorkspaceMetadataSettingsResponse::decl(&config), UpdateWorkspaceMetadataRequest::decl(&config), WorkspaceMetadataMutationResponse::decl(&config), + WorkspaceSigningIdentityState::decl(&config), + WorkspaceSigningIdentityPublic::decl(&config), + WorkspacePublicIdentityBundle::decl(&config), + WorkspaceSigningIdentityResponse::decl(&config), ProfileSettingsResponse::decl(&config), WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), @@ -3877,6 +3939,45 @@ mod tests { ); } + #[test] + fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() { + let response = WorkspaceSigningIdentityResponse { + identity: WorkspaceSigningIdentityPublic { + workspace_id: "workspace-test".to_string(), + key_id: "WK-test".to_string(), + algorithm: "ed25519".to_string(), + public_key: None, + public_key_fingerprint: None, + revision: 1, + state: WorkspaceSigningIdentityState::PendingProvisioning, + created_at: "2026-01-01T00:00:00Z".to_string(), + provisioned_at: None, + }, + public_bundle: None, + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!( + encoded, + serde_json::json!({ + "identity": { + "workspace_id": "workspace-test", + "key_id": "WK-test", + "algorithm": "ed25519", + "revision": 1, + "state": "pending_provisioning", + "created_at": "2026-01-01T00:00:00Z" + } + }) + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "identity": encoded["identity"].clone(), + "private_material_ref": "must-not-cross-the-wire" + })) + .is_err() + ); + } + fn companion_worker() -> WorkspaceWorkerDiscoveryItem { WorkspaceWorkerDiscoveryItem { subject: WorkspaceWorkerSubject::RuntimeWorker { diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 7a7c2199..f918dafe 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -795,6 +795,51 @@ CREATE TABLE workspace_create_operations ( created_at TEXT NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); +CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); +CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); CREATE TABLE workspace_memory_documents ( workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, body_md TEXT NOT NULL, diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index f95318f2..fe50e5a0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -34,6 +34,7 @@ mod workdir_removal; pub mod worker_source; pub mod workspace_catalog; mod workspace_deletion; +pub mod workspace_signing_identity; mod workspace_subscription; pub use authority::{ @@ -138,6 +139,8 @@ pub enum Error { WorkerSourceIdentity(String), #[error("workspace identity error: {0}")] WorkspaceIdentity(String), + #[error("Workspace signing identity error ({code}): {message}")] + WorkspaceSigningIdentity { code: String, message: String }, #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 04f2f22c..99109525 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -93,9 +93,11 @@ use workspace_api::{ WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse, WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState, WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, - WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord, - WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary, - WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, + WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle, + WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, + WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse, + WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, + WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; use crate::auth::{ @@ -165,6 +167,10 @@ use crate::workdir_removal::{ }; use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; use crate::workspace_deletion::WorkspaceDeletionStore; +use crate::workspace_signing_identity::{ + FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService, + WorkspaceSigningMaterialStore, workspace_signing_material_root, +}; use crate::{Error, Result}; use worker_runtime::catalog::{ ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation, @@ -583,6 +589,7 @@ pub struct WorkspaceApi { pub(crate) store: Arc, config_store: Arc, repository_secrets: Arc, + signing_identities: WorkspaceSigningIdentityService, config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, @@ -1025,6 +1032,7 @@ pub struct WorkspaceServerApi { template: Arc, store: Arc, catalog: WorkspaceCatalogService, + signing_materials: Arc, routers: Arc>>, apis: Arc>>, mutation_locks: Arc>>>>, @@ -1045,9 +1053,14 @@ async fn workspace_mutation_lock( impl WorkspaceServerApi { pub fn new(template: ServerConfig, store: Arc) -> Self { + let signing_materials: Arc = + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&template.database_path), + )); Self { template: Arc::new(template), - catalog: WorkspaceCatalogService::new(store.clone()), + catalog: WorkspaceCatalogService::new(store.clone(), signing_materials.clone()), + signing_materials, store, routers: Arc::new(AsyncMutex::new(HashMap::new())), apis: Arc::new(AsyncMutex::new(HashMap::new())), @@ -1281,6 +1294,8 @@ impl WorkspaceServerApi { None, ); } + WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone()) + .delete_material(&operation.workspace_id)?; let completed = self.store.finalize_workspace_deletion(operation_id)?; self.routers.lock().await.remove(&completed.workspace_id); if let Some(handle) = self @@ -2220,6 +2235,13 @@ impl WorkspaceApi { config_store.clone(), &config.database_path, )?); + let signing_materials: Arc = + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&config.database_path), + )); + let signing_identities = + WorkspaceSigningIdentityService::new(store.clone(), signing_materials); + signing_identities.get_validated(&config.workspace_id)?; let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default() .with_provider(Arc::new( crate::profile_settings::ProfileConfigSchemaProvider, @@ -2236,6 +2258,7 @@ impl WorkspaceApi { let api = Self { config_store, repository_secrets, + signing_identities, config_schema_registry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache::default(), @@ -2801,6 +2824,14 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/workspace", get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), ) + .route( + "/api/w/{workspace_id}/settings/workspace/signing-identity", + get(scoped_get_workspace_signing_identity), + ) + .route( + "/api/w/{workspace_id}/settings/workspace/signing-identity/provision", + post(scoped_provision_workspace_signing_identity), + ) .route( "/api/w/{workspace_id}/settings/memory", get(scoped_get_workspace_memory_settings) @@ -3952,6 +3983,107 @@ async fn scoped_update_workspace_settings( })) } +async fn scoped_get_workspace_signing_identity( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Workspace public identity access", + ) + .await?; + let identity = api.signing_identities.get_validated(&path.workspace_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) +} + +async fn scoped_provision_workspace_signing_identity( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Workspace signing identity provisioning", + ) + .await?; + let identity = api + .signing_identities + .provision_existing(&path.workspace_id, &actor.account_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) +} + +fn project_workspace_signing_identity( + api: &WorkspaceApi, + identity: crate::store::WorkspaceSigningIdentityRecord, +) -> Result { + let state = match identity.state.as_str() { + "pending_provisioning" => WorkspaceSigningIdentityState::PendingProvisioning, + "active" => WorkspaceSigningIdentityState::Active, + _ => { + return Err(crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_state_invalid", + "Workspace signing identity state is invalid", + )); + } + }; + let public_bundle = if state == WorkspaceSigningIdentityState::Active { + let public_key = identity.public_key.clone().ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_metadata_corrupt", + "Active Workspace signing identity has no public key", + ) + })?; + let public_key_fingerprint = identity.public_key_fingerprint.clone().ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_metadata_corrupt", + "Active Workspace signing identity has no public key fingerprint", + ) + })?; + let backend_url = api + .config + .backend_base_url + .as_deref() + .ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_backend_url_unavailable", + "Backend public URL is unavailable for the Workspace identity bundle", + ) + })? + .trim_end_matches('/') + .to_string(); + Some(WorkspacePublicIdentityBundle { + workspace_id: identity.workspace_id.clone(), + backend_url, + key_id: identity.key_id.clone(), + algorithm: identity.algorithm.clone(), + public_key, + public_key_fingerprint, + revision: identity.revision, + }) + } else { + None + }; + Ok(WorkspaceSigningIdentityResponse { + identity: WorkspaceSigningIdentityPublic { + workspace_id: identity.workspace_id, + key_id: identity.key_id, + algorithm: identity.algorithm, + public_key: identity.public_key, + public_key_fingerprint: identity.public_key_fingerprint, + revision: identity.revision, + state, + created_at: identity.created_at, + provisioned_at: identity.provisioned_at, + }, + public_bundle, + }) +} + #[derive(Debug, Deserialize)] struct WorkspaceConfigRevisionPath { workspace_id: String, @@ -16895,6 +17027,11 @@ impl From for ApiError { severity: DiagnosticSeverity::Error, message: sanitize_backend_error(message), }], + Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic { + code: code.clone(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }], Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { code: match ticket_error { ticket::TicketError::NotFound(_) => "ticket_not_found", @@ -17051,6 +17188,7 @@ impl IntoResponse for ApiError { { StatusCode::SERVICE_UNAVAILABLE } + Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -19806,7 +19944,8 @@ mod tests { #[tokio::test] async fn workspace_server_router_requires_identity_for_scoped_rest() { let temp = tempfile::tempdir().unwrap(); - let config = test_server_config(temp.path()); + let mut config = test_server_config(temp.path()); + config.backend_base_url = Some("https://backend.example.test".to_string()); let AuthConfig::Passkey { origin: expected_origin, .. @@ -19823,7 +19962,12 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_owned(), }) .unwrap(); - let catalog = WorkspaceCatalogService::new(store.clone()); + let catalog = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&config.database_path), + )), + ); let repository = temp.path().join("repository"); std::fs::create_dir_all(&repository).unwrap(); assert!( @@ -19881,7 +20025,10 @@ mod tests { }) .unwrap(); let non_owner_token = seed_test_api_token(store.as_ref(), "repository-access-non-owner"); - let app = build_workspace_server_router(config, store).await.unwrap(); + let identity_material_root = workspace_signing_material_root(&config.database_path); + let app = build_workspace_server_router(config, store.clone()) + .await + .unwrap(); let uri = format!("/api/w/{}/workspace", workspace.workspace.workspace_id); let anonymous = app @@ -20210,6 +20357,109 @@ mod tests { .unwrap(); assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); + let identity_uri = format!( + "/api/w/{}/settings/workspace/signing-identity", + workspace.workspace.workspace_id + ); + let identity_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&identity_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(identity_response.status(), StatusCode::OK); + let identity_body = axum::body::to_bytes(identity_response.into_body(), usize::MAX) + .await + .unwrap(); + let identity_json: serde_json::Value = serde_json::from_slice(&identity_body).unwrap(); + assert_eq!(identity_json["identity"]["state"], "active"); + assert_eq!( + identity_json["public_bundle"]["workspace_id"], + workspace.workspace.workspace_id + ); + let identity_text = String::from_utf8(identity_body.to_vec()).unwrap(); + assert!(!identity_text.contains("private_key")); + assert!(!identity_text.contains("private_material_ref")); + let identity_non_owner = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&identity_uri) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {non_owner_token}"), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(identity_non_owner.status(), StatusCode::FORBIDDEN); + + let pending_identity = store + .get_workspace_signing_identity(&workspace.workspace.workspace_id) + .unwrap() + .unwrap(); + store + .with_conn_mut(|conn| { + conn.execute( + r#"UPDATE workspace_signing_identities + SET public_key = NULL, public_key_fingerprint = NULL, + state = 'pending_provisioning', provisioned_at = NULL + WHERE workspace_id = ?1"#, + rusqlite::params![workspace.workspace.workspace_id], + )?; + conn.execute( + "DELETE FROM workspace_signing_identity_audit WHERE workspace_id = ?1", + rusqlite::params![workspace.workspace.workspace_id], + )?; + conn.execute( + "DELETE FROM workspace_signing_identity_provisioning_operations WHERE workspace_id = ?1", + rusqlite::params![workspace.workspace.workspace_id], + )?; + Ok(()) + }) + .unwrap(); + FsWorkspaceSigningMaterialStore::new(identity_material_root) + .delete(&pending_identity.private_material_ref) + .unwrap(); + let provision_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("{identity_uri}/provision")) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(provision_response.status(), StatusCode::OK); + let provision_body = axum::body::to_bytes(provision_response.into_body(), usize::MAX) + .await + .unwrap(); + let provision_json: serde_json::Value = serde_json::from_slice(&provision_body).unwrap(); + assert_eq!(provision_json["identity"]["state"], "active"); + assert_eq!( + provision_json["identity"]["key_id"], + pending_identity.key_id + ); + let settings_uri = format!( "/api/w/{}/settings/workspace", workspace.workspace.workspace_id @@ -20430,7 +20680,12 @@ mod tests { template.static_assets_dir = Some(static_dir); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); let token = seed_test_api_token(store.as_ref(), "two-workspaces"); - let catalog = WorkspaceCatalogService::new(store.clone()); + let catalog = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&template.database_path), + )), + ); let workspace_a = catalog .create( WorkspaceCreateRequest { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 4b22b1a8..b3457087 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -18,11 +18,12 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 53; +const LATEST_SCHEMA_VERSION: i64 = 54; 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 MIGRATIONS: &[Migration] = &[ Migration { @@ -40,6 +41,11 @@ const MIGRATIONS: &[Migration] = &[ name: WORKSPACE_DELETION_MIGRATION_NAME, apply: migrate_workspace_deletion_v52_to_v53, }, + Migration { + version: 54, + name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME, + apply: migrate_workspace_signing_identity_v53_to_v54, + }, ]; #[derive(Clone, Copy)] @@ -135,6 +141,66 @@ pub struct WorkspaceBootstrapResult { pub replayed: bool, } +#[derive(Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityRecord { + pub workspace_id: String, + pub key_id: String, + pub algorithm: String, + pub public_key: Option, + pub public_key_fingerprint: Option, + pub private_material_ref: String, + pub revision: u64, + pub state: String, + pub created_at: String, + pub provisioned_at: Option, + pub updated_at: String, +} + +impl std::fmt::Debug for WorkspaceSigningIdentityRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspaceSigningIdentityRecord") + .field("workspace_id", &self.workspace_id) + .field("key_id", &self.key_id) + .field("algorithm", &self.algorithm) + .field("public_key", &self.public_key) + .field("public_key_fingerprint", &self.public_key_fingerprint) + .field("private_material_ref", &"[REDACTED]") + .field("revision", &self.revision) + .field("state", &self.state) + .field("created_at", &self.created_at) + .field("provisioned_at", &self.provisioned_at) + .field("updated_at", &self.updated_at) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityActivation { + pub workspace_id: String, + pub key_id: String, + pub public_key: String, + pub public_key_fingerprint: String, + pub private_material_ref: String, + pub revision: u64, + pub provisioned_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityProvisioningOperation { + pub operation_key: String, + pub request_fingerprint: String, + pub operation_kind: String, + pub workspace_id: String, + pub key_id: String, + pub private_material_ref: String, + pub revision: u64, + pub actor_account_id: String, + pub state: String, + pub created_at: String, + pub completed_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRuntimeBinding { pub workspace_id: String, @@ -602,7 +668,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, + signing_identity: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, ) -> Result; + fn reserve_workspace_signing_identity_provisioning( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result; + fn get_workspace_signing_identity( + &self, + workspace_id: &str, + ) -> Result>; + fn activate_workspace_signing_identity( + &self, + activation: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, + actor_account_id: &str, + ) -> Result; fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding) -> Result; async fn get_workspace_runtime_binding( @@ -2065,6 +2147,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) VALUES (?1, 1, 'English', ?2, ?3)"#, params![record.workspace_id, record.created_at, record.updated_at], )?; + tx.execute( + r#"INSERT OR IGNORE INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES ( + ?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL, + 'workspace-signing/' || ?1 || '/ed25519-v1', 1, + 'pending_provisioning', ?2, NULL, ?3 + )"#, + params![record.workspace_id, record.created_at, record.updated_at], + )?; tx.commit()?; Ok(()) })?; @@ -2087,8 +2180,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, + signing_identity: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, ) -> Result { validate_repository_record_identity(&record.repository)?; + if signing_identity.workspace_id != record.workspace.workspace_id { + return Err(Error::Store( + "Workspace signing identity does not belong to the Workspace bootstrap".to_string(), + )); + } self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let owner_kind = tx @@ -2132,6 +2232,43 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace.workspace_id, record.repository.repository_key], read_repository_record, )?; + let persisted_identity = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![workspace.workspace_id], + read_workspace_signing_identity, + )?; + if persisted_identity.workspace_id != signing_identity.workspace_id + || persisted_identity.key_id != signing_identity.key_id + || persisted_identity.public_key.as_deref() + != Some(signing_identity.public_key.as_str()) + || persisted_identity.public_key_fingerprint.as_deref() + != Some(signing_identity.public_key_fingerprint.as_str()) + || persisted_identity.private_material_ref + != signing_identity.private_material_ref + || persisted_identity.revision != signing_identity.revision + || persisted_identity.state != "active" + { + return Err(Error::Store( + "Workspace create replay signing identity does not match persisted authority" + .to_string(), + )); + } + let provisioning_state: Option = tx + .query_row( + "SELECT state FROM workspace_signing_identity_provisioning_operations WHERE operation_key = ?1", + params![identity_provisioning_operation_key], + |row| row.get(0), + ) + .optional()?; + if provisioning_state.as_deref() != Some("completed") { + return Err(Error::Store( + "Workspace create replay lacks completed signing identity provisioning evidence" + .to_string(), + )); + } let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)? .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? .snapshot @@ -2230,6 +2367,58 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? .snapshot .revision; + tx.execute( + r#"INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES (?1, ?2, 'ed25519', ?3, ?4, ?5, ?6, 'active', ?7, ?8, ?8)"#, + params![ + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.public_key, + signing_identity.public_key_fingerprint, + signing_identity.private_material_ref, + signing_identity.revision, + record.workspace.created_at, + signing_identity.provisioned_at, + ], + )?; + tx.execute( + r#"INSERT INTO workspace_signing_identity_audit ( + event_id, workspace_id, key_id, action, revision, + public_key_fingerprint, actor_account_id, created_at + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + params![ + uuid::Uuid::now_v7().to_string(), + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.revision, + signing_identity.public_key_fingerprint, + record.workspace.owner_account_id, + signing_identity.provisioned_at, + ], + )?; + let completed = tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = ?2 + WHERE operation_key = ?1 AND state = 'pending' + AND workspace_id = ?3 AND key_id = ?4 + AND private_material_ref = ?5 AND revision = ?6"#, + params![ + identity_provisioning_operation_key, + signing_identity.provisioned_at, + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.private_material_ref, + signing_identity.revision, + ], + )?; + if completed != 1 { + return Err(Error::Store( + "Workspace signing identity provisioning reservation is missing or inconsistent" + .to_string(), + )); + } tx.execute( r#"INSERT INTO workspace_create_operations ( operation_key, request_fingerprint, workspace_id, created_at @@ -2251,6 +2440,245 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn reserve_workspace_signing_identity_provisioning( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result { + for (label, value) in [ + ("operation_key", operation.operation_key.as_str()), + ( + "request_fingerprint", + operation.request_fingerprint.as_str(), + ), + ("workspace_id", operation.workspace_id.as_str()), + ("key_id", operation.key_id.as_str()), + ( + "private_material_ref", + operation.private_material_ref.as_str(), + ), + ("actor_account_id", operation.actor_account_id.as_str()), + ] { + validate_non_empty(label, value)?; + } + if !matches!( + operation.operation_kind.as_str(), + "workspace_create" | "existing_workspace" + ) || operation.revision == 0 + { + return Err(Error::Store( + "Workspace signing identity provisioning reservation is invalid".to_string(), + )); + } + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(existing) = tx + .query_row( + r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + FROM workspace_signing_identity_provisioning_operations + WHERE operation_key = ?1"#, + params![operation.operation_key], + read_workspace_signing_identity_provisioning_operation, + ) + .optional()? + { + if existing.request_fingerprint != operation.request_fingerprint + || existing.operation_kind != operation.operation_kind + { + return Err(Error::WorkspaceConfigConflict( + "Workspace signing identity operation key was already used with different input" + .to_string(), + )); + } + tx.commit()?; + return Ok(existing); + } + tx.execute( + r#"INSERT INTO workspace_signing_identity_provisioning_operations ( + operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, NULL)"#, + params![ + operation.operation_key, + operation.request_fingerprint, + operation.operation_kind, + operation.workspace_id, + operation.key_id, + operation.private_material_ref, + operation.revision, + operation.actor_account_id, + operation.created_at, + ], + ) + .map_err(|error| { + if matches!(error, rusqlite::Error::SqliteFailure(_, _)) { + Error::WorkspaceConfigConflict( + "Workspace already has a signing identity provisioning operation" + .to_string(), + ) + } else { + Error::from(error) + } + })?; + tx.commit()?; + Ok(operation.clone()) + }) + } + + fn get_workspace_signing_identity( + &self, + workspace_id: &str, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![workspace_id], + read_workspace_signing_identity, + ) + .optional() + .map_err(Error::from) + }) + } + + fn activate_workspace_signing_identity( + &self, + activation: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, + actor_account_id: &str, + ) -> Result { + validate_identifier("workspace_id", &activation.workspace_id)?; + validate_non_empty( + "identity_provisioning_operation_key", + identity_provisioning_operation_key, + )?; + validate_identifier("actor_account_id", actor_account_id)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let operation = tx + .query_row( + r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + FROM workspace_signing_identity_provisioning_operations + WHERE operation_key = ?1"#, + params![identity_provisioning_operation_key], + read_workspace_signing_identity_provisioning_operation, + ) + .optional()? + .ok_or_else(|| { + Error::Store( + "Workspace signing identity provisioning operation is missing".to_string(), + ) + })?; + if operation.workspace_id != activation.workspace_id + || operation.key_id != activation.key_id + || operation.private_material_ref != activation.private_material_ref + || operation.revision != activation.revision + || operation.actor_account_id != actor_account_id + { + return Err(Error::Store( + "Workspace signing identity provisioning operation is inconsistent".to_string(), + )); + } + let existing = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![activation.workspace_id], + read_workspace_signing_identity, + )?; + if existing.key_id != activation.key_id + || existing.private_material_ref != activation.private_material_ref + || existing.revision != activation.revision + { + return Err(Error::Store( + "Workspace signing identity activation does not match persisted metadata" + .to_string(), + )); + } + if existing.state == "active" { + if existing.public_key.as_deref() != Some(activation.public_key.as_str()) + || existing.public_key_fingerprint.as_deref() + != Some(activation.public_key_fingerprint.as_str()) + { + return Err(Error::Store( + "Workspace signing identity replay does not match active authority" + .to_string(), + )); + } + tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = COALESCE(completed_at, ?2) + WHERE operation_key = ?1"#, + params![ + identity_provisioning_operation_key, + activation.provisioned_at + ], + )?; + tx.commit()?; + return Ok(existing); + } + if existing.state != "pending_provisioning" { + return Err(Error::Store( + "Workspace signing identity has an unknown lifecycle state".to_string(), + )); + } + tx.execute( + r#"UPDATE workspace_signing_identities + SET public_key = ?2, public_key_fingerprint = ?3, state = 'active', + provisioned_at = ?4, updated_at = ?4 + WHERE workspace_id = ?1 AND state = 'pending_provisioning'"#, + params![ + activation.workspace_id, + activation.public_key, + activation.public_key_fingerprint, + activation.provisioned_at, + ], + )?; + tx.execute( + r#"INSERT INTO workspace_signing_identity_audit ( + event_id, workspace_id, key_id, action, revision, + public_key_fingerprint, actor_account_id, created_at + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + params![ + uuid::Uuid::now_v7().to_string(), + activation.workspace_id, + activation.key_id, + activation.revision, + activation.public_key_fingerprint, + actor_account_id, + activation.provisioned_at, + ], + )?; + tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = ?2 + WHERE operation_key = ?1"#, + params![ + identity_provisioning_operation_key, + activation.provisioned_at + ], + )?; + let activated = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![activation.workspace_id], + read_workspace_signing_identity, + )?; + tx.commit()?; + Ok(activated) + }) + } + fn workspace_runtime_binding_matches( &self, expected: &WorkspaceRuntimeBinding, @@ -5666,6 +6094,46 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + let revision: i64 = row.get(6)?; + Ok(WorkspaceSigningIdentityRecord { + workspace_id: row.get(0)?, + key_id: row.get(1)?, + algorithm: row.get(2)?, + public_key: row.get(3)?, + public_key_fingerprint: row.get(4)?, + private_material_ref: row.get(5)?, + revision: u64::try_from(revision) + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, + state: row.get(7)?, + created_at: row.get(8)?, + provisioned_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +fn read_workspace_signing_identity_provisioning_operation( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let revision: i64 = row.get(6)?; + Ok(WorkspaceSigningIdentityProvisioningOperation { + operation_key: row.get(0)?, + request_fingerprint: row.get(1)?, + operation_kind: row.get(2)?, + workspace_id: row.get(3)?, + key_id: row.get(4)?, + private_material_ref: row.get(5)?, + revision: u64::try_from(revision) + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, + actor_account_id: row.get(7)?, + state: row.get(8)?, + created_at: row.get(9)?, + completed_at: row.get(10)?, + }) +} + fn repository_registration_intent_matches( existing: &RepositoryRecord, requested: &RepositoryRecord, @@ -6896,12 +7364,167 @@ fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> { verify_workspace_deletion_schema(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![LATEST_SCHEMA_VERSION, WORKSPACE_DELETION_MIGRATION_NAME], + params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME], )?; tx.commit()?; Ok(()) } +fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 53 { + return Err(Error::Store(format!( + "expected schema version 53 before {WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); + CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); + INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) + SELECT workspace_id, + 'WK-' || lower(hex(randomblob(16))), + 'ed25519', NULL, NULL, + 'workspace-signing/' || workspace_id || '/ed25519-v1', + 1, 'pending_provisioning', created_at, NULL, updated_at + FROM workspaces; + "#, + )?; + 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 + ], + )?; + tx.commit()?; + Ok(()) +} + +fn verify_workspace_signing_identity_schema(conn: &Connection) -> Result<()> { + for (table, expected) in [ + ( + "workspace_signing_identities", + vec![ + "workspace_id", + "key_id", + "algorithm", + "public_key", + "public_key_fingerprint", + "private_material_ref", + "revision", + "state", + "created_at", + "provisioned_at", + "updated_at", + ], + ), + ( + "workspace_signing_identity_provisioning_operations", + vec![ + "operation_key", + "request_fingerprint", + "operation_kind", + "workspace_id", + "key_id", + "private_material_ref", + "revision", + "actor_account_id", + "state", + "created_at", + "completed_at", + ], + ), + ( + "workspace_signing_identity_audit", + vec![ + "event_id", + "workspace_id", + "key_id", + "action", + "revision", + "public_key_fingerprint", + "actor_account_id", + "created_at", + ], + ), + ] { + let actual = table_columns(conn, table)? + .into_iter() + .collect::>(); + let expected = expected + .into_iter() + .map(str::to_string) + .collect::>(); + if actual != expected { + return Err(Error::Store(format!( + "{table} schema does not match schema-54" + ))); + } + } + let pending_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM workspaces w LEFT JOIN workspace_signing_identities i ON i.workspace_id = w.workspace_id WHERE i.workspace_id IS NULL", + [], + |row| row.get(0), + )?; + if pending_count != 0 { + return Err(Error::Store( + "schema-54 failed to initialize every existing Workspace signing identity as pending" + .to_string(), + )); + } + Ok(()) +} + fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> { let columns = table_columns(conn, "workspace_deletion_operations")? .into_iter() @@ -7619,7 +8242,8 @@ fn apply_migrations(conn: &Connection) -> Result<()> { verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; verify_workspace_runtime_binding_schema(conn)?; - verify_workspace_deletion_schema(conn) + verify_workspace_deletion_schema(conn)?; + verify_workspace_signing_identity_schema(conn) } fn table_exists(conn: &Connection, table_name: &str) -> Result { @@ -7712,6 +8336,10 @@ mod tests { create_latest_workspace_schema(&conn).unwrap(); conn.execute_batch( r#" + DROP INDEX workspace_signing_identity_audit_workspace_idx; + DROP TABLE workspace_signing_identity_audit; + DROP TABLE workspace_signing_identity_provisioning_operations; + DROP TABLE workspace_signing_identities; DROP INDEX workspace_deletion_operations_workspace_recent; DROP TABLE workspace_deletion_operations; CREATE TABLE worker_create_reservations_v52 ( @@ -7829,6 +8457,10 @@ mod tests { version: 53, name: WORKSPACE_DELETION_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 54, + name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(), + }, ] ); @@ -7850,6 +8482,7 @@ mod tests { (51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME.to_string()), (52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()), (53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()), + (54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -7869,6 +8502,14 @@ mod tests { )?, 1 ); + assert_eq!( + conn.query_row( + "SELECT state FROM workspace_signing_identities WHERE workspace_id='workspace-a'", + [], + |row| row.get::<_, String>(0), + )?, + "pending_provisioning" + ); Ok(()) }) .unwrap(); @@ -7891,7 +8532,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53] + vec![52, 53, 54] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -7899,7 +8540,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 4); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5); } #[test] @@ -8063,6 +8704,42 @@ mod tests { .unwrap(); } + #[test] + fn schema_v53_signing_identity_migration_rolls_back_on_failure() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v52(&path); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + migrate_workspace_deletion_v52_to_v53(&conn).unwrap(); + conn.execute_batch( + "CREATE TABLE workspace_signing_identity_audit (unexpected TEXT NOT NULL);", + ) + .unwrap(); + let before = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + + assert!(migrate_workspace_signing_identity_v53_to_v54(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 53); + assert!(!table_exists(&conn, "workspace_signing_identities").unwrap()); + assert!( + !table_exists(&conn, "workspace_signing_identity_provisioning_operations").unwrap() + ); + let after = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + assert_eq!(after, before); + } + #[test] fn schema_v52_workspace_deletion_migration_rolls_back_on_failure() { let temp = tempfile::tempdir().unwrap(); @@ -8111,6 +8788,12 @@ mod tests { VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'), ('workspace-b', 'owner', 'Workspace B', 'active', '1', '1'); + INSERT INTO workspace_signing_identities( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES + ('workspace-a', 'WK-a', 'ed25519', NULL, NULL, 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'), + ('workspace-b', 'WK-b', 'ed25519', NULL, NULL, 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'); "#, )?; Ok(()) @@ -9422,13 +10105,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 (54, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 54 is newer"), "{error}"); + assert!(error.contains("schema version 55 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -9470,6 +10153,14 @@ INSERT INTO accounts (account_id, kind, handle, display_name, created_at, update VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', '2026-01-01', '2026-01-01'); INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at) VALUES ('workspace-a', 'owner-account', 'A', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at +) VALUES ( + 'workspace-a', 'WK-a', 'ed25519', NULL, NULL, + 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', + '2026-01-01', NULL, '2026-01-01' +); INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit @@ -9492,6 +10183,14 @@ DELETE FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at) VALUES ('workspace-b', 'owner-account', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at +) VALUES ( + 'workspace-b', 'WK-b', 'ed25519', NULL, NULL, + 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', + '2026-01-01', NULL, '2026-01-01' +); INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit @@ -9575,13 +10274,26 @@ INSERT INTO worker_registry ( updated_at: "1".to_string(), }; + let signing_identity = WorkspaceSigningIdentityActivation { + workspace_id: workspace.workspace_id.clone(), + key_id: "WK-store-test".to_string(), + public_key: "test-public-key".to_string(), + public_key_fingerprint: "sha256:test-public-key".to_string(), + private_material_ref: "workspace-signing/store-test/ed25519-v1".to_string(), + revision: 1, + provisioned_at: "1".to_string(), + }; let error = store - .create_workspace_bootstrap(&WorkspaceBootstrapRecord { - operation_key: "invalid-key".to_string(), - request_fingerprint: "sha256:invalid-key".to_string(), - workspace: workspace.clone(), - repository, - }) + .create_workspace_bootstrap( + &WorkspaceBootstrapRecord { + operation_key: "invalid-key".to_string(), + request_fingerprint: "sha256:invalid-key".to_string(), + workspace: workspace.clone(), + repository, + }, + &signing_identity, + "identity-store-test", + ) .unwrap_err() .to_string(); @@ -9618,12 +10330,34 @@ INSERT INTO worker_registry ( workspace, repository: valid_repository, }; - assert!(!store.create_workspace_bootstrap(&first).unwrap().replayed); + store + .reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: "identity-store-test".to_string(), + request_fingerprint: "sha256:create-workspace".to_string(), + operation_kind: "workspace_create".to_string(), + workspace_id: first.workspace.workspace_id.clone(), + key_id: signing_identity.key_id.clone(), + private_material_ref: signing_identity.private_material_ref.clone(), + revision: signing_identity.revision, + actor_account_id: first.workspace.owner_account_id.clone(), + state: "pending".to_string(), + created_at: "1".to_string(), + completed_at: None, + }, + ) + .unwrap(); + assert!( + !store + .create_workspace_bootstrap(&first, &signing_identity, "identity-store-test") + .unwrap() + .replayed + ); let mut duplicate = first; duplicate.operation_key = "duplicate-workspace".to_string(); duplicate.repository.repository_id = Uuid::now_v7().to_string(); let error = store - .create_workspace_bootstrap(&duplicate) + .create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test") .unwrap_err() .to_string(); assert!( diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index a606dde3..dc2df2c4 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger use crate::store::{ ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, }; +use crate::workspace_signing_identity::{ + WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore, +}; use crate::{Error, Result}; const MAX_DISPLAY_NAME_BYTES: usize = 200; @@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult { #[derive(Clone)] pub struct WorkspaceCatalogService { store: Arc, + signing_identities: WorkspaceSigningIdentityService, } impl WorkspaceCatalogService { - pub fn new(store: Arc) -> Self { - Self { store } + pub fn new( + store: Arc, + signing_materials: Arc, + ) -> Self { + Self { + signing_identities: WorkspaceSigningIdentityService::new( + store.clone(), + signing_materials, + ), + store, + } } pub fn is_empty(&self) -> Result { @@ -118,7 +131,7 @@ impl WorkspaceCatalogService { .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) }) .transpose()?; - let workspace_id = requested_workspace_id + let proposed_workspace_id = requested_workspace_id .clone() .unwrap_or_else(|| Uuid::now_v7().to_string()); let fingerprint = workspace_create_fingerprint( @@ -130,9 +143,16 @@ impl WorkspaceCatalogService { &default_ref, ); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let result = self - .store - .create_workspace_bootstrap(&WorkspaceBootstrapRecord { + let (signing_identity, identity_provisioning_operation_key) = + self.signing_identities.prepare_workspace_creation( + &operation_key, + &fingerprint, + &proposed_workspace_id, + &owner_account_id, + )?; + let workspace_id = signing_identity.workspace_id.clone(); + let result = self.store.create_workspace_bootstrap( + &WorkspaceBootstrapRecord { operation_key, request_fingerprint: fingerprint.clone(), workspace: WorkspaceRecord { @@ -158,7 +178,10 @@ impl WorkspaceCatalogService { created_at: now.clone(), updated_at: now, }, - })?; + }, + &signing_identity, + &identity_provisioning_operation_key, + )?; Ok(WorkspaceCreateResult { workspace: result.workspace, repository: result.repository, @@ -243,7 +266,12 @@ mod tests { #[tokio::test] async fn create_is_atomic_and_exact_retries_converge() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); - let service = WorkspaceCatalogService::new(store.clone()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), @@ -268,6 +296,14 @@ mod tests { replayed.workspace.workspace_id ); assert_eq!(store.list_workspaces().unwrap().len(), 1); + let signing_identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .expect("new Workspace signing identity"); + assert_eq!(signing_identity.state, "active"); + assert_eq!(signing_identity.algorithm, "ed25519"); + assert!(signing_identity.public_key.is_some()); + assert!(signing_identity.public_key_fingerprint.is_some()); assert_eq!( store .list_repositories(&created.workspace.workspace_id) @@ -287,7 +323,12 @@ mod tests { async fn idempotency_key_reuse_with_different_payload_is_rejected() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let owner_account_id = owner_account(store.as_ref()); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let mut request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), @@ -338,7 +379,12 @@ mod tests { updated_at: "2026-07-03T00:00:00Z".to_string(), }) .unwrap(); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let error = service .create( @@ -373,7 +419,12 @@ mod tests { updated_at: "2026-07-03T00:00:00Z".to_string(), }) .unwrap(); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository_a = git_repository(); let repository_b = git_repository(); let created_a = service @@ -425,7 +476,12 @@ mod tests { fn remote_repository_creation_persists_typed_source_without_auth_metadata() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let owner_account_id = owner_account(store.as_ref()); - let service = WorkspaceCatalogService::new(store.clone()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let result = service .create( WorkspaceCreateRequest { diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 1382edcf..0c6f35ea 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -80,6 +80,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_resource_keys", "workspace_runtime_binding_audit", "workspace_runtime_bindings", + "workspace_signing_identities", + "workspace_signing_identity_audit", + "workspace_signing_identity_provisioning_operations", "workspace_worker_retention_policies", "workspace_worker_retention_policy_revisions", ]; diff --git a/crates/workspace-server/src/workspace_signing_identity.rs b/crates/workspace-server/src/workspace_signing_identity.rs new file mode 100644 index 00000000..7730f0be --- /dev/null +++ b/crates/workspace-server/src/workspace_signing_identity.rs @@ -0,0 +1,685 @@ +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use std::sync::Arc; + +use chrono::{SecondsFormat, Utc}; +use ring::signature::KeyPair; +use serde::{Deserialize, Serialize}; +use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key}; +use zeroize::Zeroize; + +use crate::store::{ + ControlPlaneStore, WorkspaceSigningIdentityActivation, + WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord, +}; +use crate::{Error, Result}; + +pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; +pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1; +const MATERIAL_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningPrivateMaterial { + version: u32, + workspace_id: String, + key_id: String, + revision: u64, + private_key: String, +} + +impl fmt::Debug for WorkspaceSigningPrivateMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceSigningPrivateMaterial") + .field("version", &self.version) + .field("workspace_id", &self.workspace_id) + .field("key_id", &self.key_id) + .field("revision", &self.revision) + .field("private_key", &"[REDACTED]") + .finish() + } +} + +impl Drop for WorkspaceSigningPrivateMaterial { + fn drop(&mut self) { + self.private_key.zeroize(); + } +} + +impl WorkspaceSigningPrivateMaterial { + pub fn generate(workspace_id: &str, key_id: &str) -> Result { + let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| { + identity_error( + "workspace_signing_identity_generation_failed", + format!("failed to generate Workspace signing identity: {error}"), + ) + })?; + Ok(Self { + version: MATERIAL_SCHEMA_VERSION, + workspace_id: workspace_id.to_string(), + key_id: key_id.to_string(), + revision: WORKSPACE_SIGNING_IDENTITY_REVISION, + private_key: material.private_key, + }) + } + + fn signing_key( + &self, + expected_workspace_id: &str, + expected_key_id: &str, + expected_revision: u64, + ) -> Result { + if self.version != MATERIAL_SCHEMA_VERSION + || self.workspace_id != expected_workspace_id + || self.key_id != expected_key_id + || self.revision != expected_revision + { + return Err(identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing private material does not match its persisted metadata", + )); + } + RuntimeIdentityMaterial { + identity_id: self.key_id.clone(), + public_key: String::new(), + private_key: self.private_key.clone(), + } + .signing_key() + .map_err(|_| { + identity_error( + "workspace_signing_identity_material_corrupt", + "Workspace signing private material is corrupt", + ) + }) + } + + pub fn validate_and_public_key( + &self, + expected_workspace_id: &str, + expected_key_id: &str, + expected_revision: u64, + ) -> Result { + let signing_key = + self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?; + Ok(encode_public_key(signing_key.public_key().as_ref())) + } +} + +pub trait WorkspaceSigningMaterialStore: Send + Sync { + fn load(&self, material_ref: &str) -> Result>; + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result; + fn delete(&self, material_ref: &str) -> Result<()>; +} + +#[derive(Clone)] +pub struct WorkspaceSigningIdentityService { + store: Arc, + materials: Arc, +} + +impl WorkspaceSigningIdentityService { + pub fn new( + store: Arc, + materials: Arc, + ) -> Self { + Self { store, materials } + } + + pub fn prepare_workspace_creation( + &self, + workspace_create_operation_key: &str, + request_fingerprint: &str, + proposed_workspace_id: &str, + actor_account_id: &str, + ) -> Result<(WorkspaceSigningIdentityActivation, String)> { + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple()); + let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1"); + let operation_key = format!("workspace-create:{workspace_create_operation_key}"); + let operation = self.store.reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: operation_key.clone(), + request_fingerprint: request_fingerprint.to_string(), + operation_kind: "workspace_create".to_string(), + workspace_id: proposed_workspace_id.to_string(), + key_id: proposed_key_id, + private_material_ref: proposed_material_ref, + revision: WORKSPACE_SIGNING_IDENTITY_REVISION, + actor_account_id: actor_account_id.to_string(), + state: "pending".to_string(), + created_at: now, + completed_at: None, + }, + )?; + let activation = self.prepare_material(&operation)?; + Ok((activation, operation.operation_key)) + } + + pub fn provision_existing( + &self, + workspace_id: &str, + actor_account_id: &str, + ) -> Result { + let identity = self + .store + .get_workspace_signing_identity(workspace_id)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_metadata_missing", + "Workspace signing identity metadata is missing", + ) + })?; + if identity.state == "active" { + self.validate_active_material(&identity)?; + return Ok(identity); + } + if identity.state != "pending_provisioning" { + return Err(identity_error( + "workspace_signing_identity_state_invalid", + "Workspace signing identity state is invalid", + )); + } + let operation_key = format!( + "existing-workspace:{workspace_id}:revision-{}", + identity.revision + ); + let request_fingerprint = + provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision); + let operation = self.store.reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: operation_key.clone(), + request_fingerprint, + operation_kind: "existing_workspace".to_string(), + workspace_id: workspace_id.to_string(), + key_id: identity.key_id.clone(), + private_material_ref: identity.private_material_ref.clone(), + revision: identity.revision, + actor_account_id: actor_account_id.to_string(), + state: "pending".to_string(), + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + completed_at: None, + }, + )?; + let activation = self.prepare_material(&operation)?; + self.store.activate_workspace_signing_identity( + &activation, + &operation.operation_key, + &operation.actor_account_id, + ) + } + + pub fn get_validated(&self, workspace_id: &str) -> Result { + let identity = self + .store + .get_workspace_signing_identity(workspace_id)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_metadata_missing", + "Workspace signing identity metadata is missing", + ) + })?; + if identity.state == "active" { + self.validate_active_material(&identity)?; + } + Ok(identity) + } + + pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result> { + let identity = self.get_validated(workspace_id)?; + if identity.state != "active" { + return Err(identity_error( + "workspace_signing_identity_not_provisioned", + "Workspace signing identity is not provisioned", + )); + } + let material = self + .materials + .load(&identity.private_material_ref)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing", + ) + })?; + let signing_key = + material.signing_key(workspace_id, &identity.key_id, identity.revision)?; + Ok(signing_key.sign(payload).as_ref().to_vec()) + } + + pub fn delete_material(&self, workspace_id: &str) -> Result<()> { + if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? { + self.materials.delete(&identity.private_material_ref)?; + } + Ok(()) + } + + fn prepare_material( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result { + let material = match self.materials.load(&operation.private_material_ref)? { + Some(material) => material, + None => { + let generated = WorkspaceSigningPrivateMaterial::generate( + &operation.workspace_id, + &operation.key_id, + )?; + self.materials + .put_if_absent(&operation.private_material_ref, &generated)? + } + }; + let public_key = material.validate_and_public_key( + &operation.workspace_id, + &operation.key_id, + operation.revision, + )?; + let public_key_fingerprint = public_key_fingerprint(&public_key)?; + Ok(WorkspaceSigningIdentityActivation { + workspace_id: operation.workspace_id.clone(), + key_id: operation.key_id.clone(), + public_key, + public_key_fingerprint, + private_material_ref: operation.private_material_ref.clone(), + revision: operation.revision, + provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + }) + } + + fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> { + let material = self + .materials + .load(&identity.private_material_ref)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing", + ) + })?; + let public_key = material.validate_and_public_key( + &identity.workspace_id, + &identity.key_id, + identity.revision, + )?; + let fingerprint = public_key_fingerprint(&public_key)?; + if identity.public_key.as_deref() != Some(public_key.as_str()) + || identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str()) + { + return Err(identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing private material does not match public metadata", + )); + } + Ok(()) + } +} + +fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(workspace_id.as_bytes()); + hasher.update([0]); + hasher.update(key_id.as_bytes()); + hasher.update([0]); + hasher.update(revision.to_be_bytes()); + format!("sha256:{}", hex_lower(&hasher.finalize())) +} + +#[derive(Default)] +pub struct InMemoryWorkspaceSigningMaterialStore { + materials: std::sync::Mutex>, +} + +impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore { + fn load(&self, material_ref: &str) -> Result> { + Ok(self + .materials + .lock() + .expect("identity material store lock") + .get(material_ref) + .cloned()) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + let mut materials = self.materials.lock().expect("identity material store lock"); + Ok(materials + .entry(material_ref.to_string()) + .or_insert_with(|| material.clone()) + .clone()) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + self.materials + .lock() + .expect("identity material store lock") + .remove(material_ref); + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct FsWorkspaceSigningMaterialStore { + root: PathBuf, +} + +impl FsWorkspaceSigningMaterialStore { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + fn material_path(&self, material_ref: &str) -> Result { + let relative = Path::new(material_ref); + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative.components().any(|component| { + !matches!(component, Component::Normal(_)) + || component.as_os_str().to_string_lossy().starts_with('.') + }) + { + return Err(identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material reference is invalid", + )); + } + Ok(self.root.join(relative).with_extension("json")) + } +} + +impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore { + fn load(&self, material_ref: &str) -> Result> { + let path = self.material_path(material_ref)?; + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(material_io_error("read", error)), + }; + serde_json::from_slice(&bytes).map(Some).map_err(|_| { + identity_error( + "workspace_signing_identity_material_corrupt", + "Workspace signing private material is corrupt", + ) + }) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + let path = self.material_path(material_ref)?; + let parent = path.parent().ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material reference has no parent", + ) + })?; + ensure_private_tree(&self.root, parent)?; + + let mut bytes = serde_json::to_vec(material).map_err(|_| { + identity_error( + "workspace_signing_identity_material_encode_failed", + "Workspace signing private material could not be encoded", + ) + })?; + let temporary = parent.join(format!( + ".workspace-signing-{}.tmp", + uuid::Uuid::now_v7().simple() + )); + let write_result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| material_io_error("create", error))?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| material_io_error("write", error))?; + match fs::hard_link(&temporary, &path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(material_io_error("publish", error)), + } + })(); + bytes.zeroize(); + let _ = fs::remove_file(&temporary); + write_result?; + self.load(material_ref)?.ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing after publication", + ) + }) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + let path = self.material_path(material_ref)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(material_io_error("delete", error)), + } + } +} + +fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> { + ensure_private_directory(root)?; + let relative = leaf.strip_prefix(root).map_err(|_| { + identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material path escapes its authority root", + ) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + ensure_private_directory(¤t)?; + } + Ok(()) +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| material_io_error("set directory permissions", error))?; + } + Ok(()) +} + +pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf { + database_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("workspace-signing-identities") +} + +pub fn public_key_fingerprint(public_key: &str) -> Result { + let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| { + identity_error( + "workspace_signing_identity_public_key_invalid", + "Workspace signing public key is invalid", + ) + })?; + use sha2::{Digest, Sha256}; + Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes)))) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn material_io_error(action: &str, error: std::io::Error) -> Error { + identity_error( + "workspace_signing_identity_material_io_failed", + format!("failed to {action} Workspace signing private material: {error}"), + ) +} + +pub fn identity_error(code: impl Into, message: impl Into) -> Error { + Error::WorkspaceSigningIdentity { + code: code.into(), + message: message.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() { + use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord}; + + let temp = tempfile::tempdir().unwrap(); + let store = Arc::new(SqliteWorkspaceStore::open(&temp.path().join("server.db")).unwrap()); + store + .upsert_account(&AccountRecord { + account_id: "account-1".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-1".to_string(), + owner_account_id: "account-1".to_string(), + display_name: "Workspace".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); + assert_eq!( + service.get_validated("workspace-1").unwrap().state, + "pending_provisioning" + ); + + let provisioned = service + .provision_existing("workspace-1", "account-1") + .unwrap(); + assert_eq!(provisioned.state, "active"); + assert!(provisioned.public_key.is_some()); + let payload = b"Workspace authority proof"; + let signature = service.sign("workspace-1", payload).unwrap(); + let public_key = + worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap()) + .unwrap(); + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key) + .verify(payload, &signature) + .unwrap(); + assert_eq!( + service + .provision_existing("workspace-1", "account-1") + .unwrap(), + provisioned + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'", + [], + |row| row.get::<_, i64>(0), + )?, + 1 + ); + Ok(()) + }) + .unwrap(); + + materials.delete(&provisioned.private_material_ref).unwrap(); + let error = service.get_validated("workspace-1").unwrap_err(); + assert!(matches!( + error, + Error::WorkspaceSigningIdentity { ref code, .. } + if code == "workspace_signing_identity_material_missing" + )); + } + + #[test] + fn file_store_round_trips_private_material_without_overwrite() { + let temp = tempfile::tempdir().unwrap(); + let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); + let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap(); + let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(temp.path().join("identities")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + assert_eq!( + persisted + .validate_and_public_key("ws-1", "WK-1", 1) + .unwrap(), + first_public + ); + + let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap(); + assert_eq!( + persisted + .validate_and_public_key("ws-1", "WK-1", 1) + .unwrap(), + first_public + ); + } + + #[test] + fn corrupt_and_cross_workspace_material_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); + let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + store.put_if_absent("ws-1/ed25519-v1", &material).unwrap(); + let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap(); + assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err()); + + fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap(); + assert!(store.load("ws-1/ed25519-v1").is_err()); + } +} diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 06cb5f1e..7dfee19a 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = { diagnostics: Array; }; +export type WorkspaceSigningIdentityState = "pending_provisioning" | "active"; + +export type WorkspaceSigningIdentityPublic = { + workspace_id: string; + key_id: string; + algorithm: string; + public_key?: string; + public_key_fingerprint?: string; + revision: number; + state: WorkspaceSigningIdentityState; + created_at: string; + provisioned_at?: string; +}; + +export type WorkspacePublicIdentityBundle = { + workspace_id: string; + backend_url: string; + key_id: string; + algorithm: string; + public_key: string; + public_key_fingerprint: string; + revision: number; +}; + +export type WorkspaceSigningIdentityResponse = { + identity: WorkspaceSigningIdentityPublic; + public_bundle?: WorkspacePublicIdentityBundle; +}; + export type ProfileSettingsResponse = { workspace_id: string; registry_revision: string; diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index e0ff66e7..51cbfc45 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -8,6 +8,10 @@ import type { WorkspaceProfileSourceProvenance, WorkspaceProfileSourceSummary, WorkspaceProfileSummary, + WorkspacePublicIdentityBundle, + WorkspaceSigningIdentityPublic, + WorkspaceSigningIdentityResponse, + WorkspaceSigningIdentityState, } from "$lib/generated/workspace-api"; export class ProfileApiError extends Error { @@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string { return value; } +function boundedStringValue( + value: unknown, + context: string, + maxBytes: number, +): string { + const text = stringValue(value, context); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return text; +} + function booleanValue(value: unknown, context: string): boolean { if (typeof value !== "boolean") { throw new ProfileApiError(`${context} returned an invalid response.`, 502); @@ -66,6 +82,15 @@ function optionalString( return stringValue(value, context); } +function optionalBoundedString( + value: unknown, + context: string, + maxBytes: number, +): string | null | undefined { + if (value === undefined || value === null) return value; + return boundedStringValue(value, context, maxBytes); +} + function optionalRevision( value: unknown, context: string, @@ -286,6 +311,197 @@ export function parseProfileSettingsResponse( }; } +export function parseWorkspaceSigningIdentityResponse( + value: unknown, +): WorkspaceSigningIdentityResponse { + const item = record(value, "Workspace signing identity"); + exactKeys( + item, + ["identity"], + ["public_bundle"], + "Workspace signing identity", + ); + const identityItem = record(item.identity, "Workspace signing identity"); + exactKeys( + identityItem, + ["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"], + ["public_key", "public_key_fingerprint", "provisioned_at"], + "Workspace signing identity", + ); + const state = boundedStringValue( + identityItem.state, + "Workspace signing identity", + 32, + ); + if (state !== "pending_provisioning" && state !== "active") { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + const revision = optionalRevision( + identityItem.revision, + "Workspace signing identity", + ); + if (revision === undefined || revision === null || revision < 1) { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + const publicKey = optionalBoundedString( + identityItem.public_key, + "Workspace signing identity", + 256, + ); + const fingerprint = optionalBoundedString( + identityItem.public_key_fingerprint, + "Workspace signing identity", + 128, + ); + const provisionedAt = optionalBoundedString( + identityItem.provisioned_at, + "Workspace signing identity", + 128, + ); + const identity: WorkspaceSigningIdentityPublic = { + workspace_id: boundedStringValue( + identityItem.workspace_id, + "Workspace signing identity", + 128, + ), + key_id: boundedStringValue( + identityItem.key_id, + "Workspace signing identity", + 128, + ), + algorithm: boundedStringValue( + identityItem.algorithm, + "Workspace signing identity", + 32, + ), + ...(publicKey === undefined || publicKey === null + ? {} + : { public_key: publicKey }), + ...(fingerprint === undefined || fingerprint === null + ? {} + : { public_key_fingerprint: fingerprint }), + revision, + state: state as WorkspaceSigningIdentityState, + created_at: boundedStringValue( + identityItem.created_at, + "Workspace signing identity", + 128, + ), + ...(provisionedAt === undefined || provisionedAt === null + ? {} + : { provisioned_at: provisionedAt }), + }; + + let publicBundle: WorkspacePublicIdentityBundle | undefined; + if (item.public_bundle !== undefined) { + const bundle = record( + item.public_bundle, + "Workspace public identity bundle", + ); + exactKeys( + bundle, + [ + "workspace_id", + "backend_url", + "key_id", + "algorithm", + "public_key", + "public_key_fingerprint", + "revision", + ], + [], + "Workspace public identity bundle", + ); + const bundleRevision = optionalRevision( + bundle.revision, + "Workspace public identity bundle", + ); + if ( + bundleRevision === undefined || bundleRevision === null || + bundleRevision < 1 + ) { + throw new ProfileApiError( + "Workspace public identity bundle returned an invalid response.", + 502, + ); + } + publicBundle = { + workspace_id: boundedStringValue( + bundle.workspace_id, + "Workspace public identity bundle", + 128, + ), + backend_url: boundedStringValue( + bundle.backend_url, + "Workspace public identity bundle", + 2048, + ), + key_id: boundedStringValue( + bundle.key_id, + "Workspace public identity bundle", + 128, + ), + algorithm: boundedStringValue( + bundle.algorithm, + "Workspace public identity bundle", + 32, + ), + public_key: boundedStringValue( + bundle.public_key, + "Workspace public identity bundle", + 256, + ), + public_key_fingerprint: boundedStringValue( + bundle.public_key_fingerprint, + "Workspace public identity bundle", + 128, + ), + revision: bundleRevision, + }; + } + if ( + publicBundle !== undefined && + ( + publicBundle.workspace_id !== identity.workspace_id || + publicBundle.key_id !== identity.key_id || + publicBundle.algorithm !== identity.algorithm || + publicBundle.public_key !== identity.public_key || + publicBundle.public_key_fingerprint !== identity.public_key_fingerprint || + publicBundle.revision !== identity.revision + ) + ) { + throw new ProfileApiError( + "Workspace public identity bundle does not match identity metadata.", + 502, + ); + } + if ( + (state === "active" && + (publicBundle === undefined || identity.public_key === undefined || + identity.public_key_fingerprint === undefined || + identity.provisioned_at === undefined)) || + (state === "pending_provisioning" && + (publicBundle !== undefined || identity.public_key !== undefined || + identity.public_key_fingerprint !== undefined || + identity.provisioned_at !== undefined)) + ) { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + return { + identity, + ...(publicBundle === undefined ? {} : { public_bundle: publicBundle }), + }; +} + async function parseResponse( response: Response, parser: (value: unknown) => T, @@ -325,6 +541,33 @@ export async function updateWorkspaceMetadata( ); } +export async function fetchWorkspaceSigningIdentity( + workspaceId: string, +): Promise { + return await parseResponse( + await fetch( + `/api/w/${ + encodeURIComponent(workspaceId) + }/settings/workspace/signing-identity`, + ), + parseWorkspaceSigningIdentityResponse, + ); +} + +export async function provisionWorkspaceSigningIdentity( + workspaceId: string, +): Promise { + return await parseResponse( + await fetch( + `/api/w/${ + encodeURIComponent(workspaceId) + }/settings/workspace/signing-identity/provision`, + { method: "POST" }, + ), + parseWorkspaceSigningIdentityResponse, + ); +} + export async function fetchProfileSettings( workspaceId: string, ): Promise { diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte index 26ec7005..0dfd5a68 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte @@ -5,6 +5,7 @@ WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceMetadataSettingsResponse, + WorkspaceSigningIdentityResponse, } from '$lib/generated/workspace-api'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; @@ -18,6 +19,8 @@ import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte'; import { fetchWorkspaceMetadata, + fetchWorkspaceSigningIdentity, + provisionWorkspaceSigningIdentity, updateWorkspaceMetadata, } from '$lib/workspace/settings/profile-api'; import type { PageProps } from './$types'; @@ -26,6 +29,14 @@ let workspaceId = $derived(data.workspace?.workspace_id ?? ''); let workspaceMetadata = $state(null); + let signingIdentity = $state(null); + let identityLoading = $state(true); + let identityError = $state(null); + let provisioningIdentity = $state(false); + let identityCopied = $state(false); + let identityBundleText = $derived( + signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : '' + ); let displayNameDraft = $state(''); let loading = $state(true); let submitting = $state(false); @@ -58,6 +69,17 @@ workspaceMetadata = response; displayNameDraft = response.display_name; diagnostics = response.diagnostics; + if (data.workspace?.permissions.delete_workspace) { + try { + signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId); + } catch (err) { + identityError = err instanceof Error ? err.message : 'Workspace identity request failed'; + } finally { + identityLoading = false; + } + } else { + identityLoading = false; + } } } catch (err) { if (!cancelled) { @@ -93,6 +115,30 @@ } } + async function provisionIdentity() { + provisioningIdentity = true; + identityError = null; + try { + signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId); + } catch (err) { + identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed'; + } finally { + provisioningIdentity = false; + } + } + + async function copyIdentityBundle() { + const bundle = signingIdentity?.public_bundle; + if (!bundle) return; + identityCopied = false; + try { + await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2)); + identityCopied = true; + } catch (err) { + identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed'; + } + } + async function openDeletionConfirmation() { deletionOpen = true; deletionLoading = true; @@ -232,6 +278,52 @@ {#if data.workspace?.permissions.delete_workspace} +
+
+
+

Workspace public identity

+

Use this public bundle when connecting a Runtime to this Workspace.

+
+ {#if signingIdentity?.public_bundle} + + {/if} +
+ {#if identityError} +

{identityError}

+ {/if} + {#if identityLoading} +

Loading identity…

+ {:else if signingIdentity?.identity.state === 'pending_provisioning'} +

This existing Workspace needs one explicit signing identity provisioning operation.

+ + {:else if signingIdentity?.public_bundle} + + + {/if} +
+

Danger zone

@@ -286,6 +378,13 @@ {/if}