feat: integrate Workspace signing identity authority

This commit is contained in:
2026-09-08 00:48:45 +09:00
12 changed files with 2691 additions and 35 deletions
+101
View File
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key_fingerprint: Option<String>,
#[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<String>,
}
/// 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<WorkspacePublicIdentityBundle>,
}
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::<WorkspaceSigningIdentityResponse>(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 {
@@ -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,
+3
View File
@@ -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),
}
+263 -8
View File
@@ -95,9 +95,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::{
@@ -167,6 +169,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,
@@ -585,6 +591,7 @@ pub struct WorkspaceApi {
pub(crate) store: Arc<dyn ControlPlaneStore>,
config_store: Arc<crate::SqliteWorkspaceStore>,
repository_secrets: Arc<RepositorySecretService>,
signing_identities: WorkspaceSigningIdentityService,
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
authority: SqliteWorkspaceAuthority,
@@ -1027,6 +1034,7 @@ pub struct WorkspaceServerApi {
template: Arc<ServerConfig>,
store: Arc<dyn ControlPlaneStore>,
catalog: WorkspaceCatalogService,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
routers: Arc<AsyncMutex<HashMap<String, Router>>>,
apis: Arc<AsyncMutex<HashMap<String, WorkspaceApi>>>,
mutation_locks: Arc<AsyncMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
@@ -1047,9 +1055,14 @@ async fn workspace_mutation_lock(
impl WorkspaceServerApi {
pub fn new(template: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Self {
let signing_materials: Arc<dyn WorkspaceSigningMaterialStore> =
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())),
@@ -1283,6 +1296,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
@@ -2228,6 +2243,13 @@ impl WorkspaceApi {
config_store.clone(),
&config.database_path,
)?);
let signing_materials: Arc<dyn WorkspaceSigningMaterialStore> =
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,
@@ -2244,6 +2266,7 @@ impl WorkspaceApi {
let api = Self {
config_store,
repository_secrets,
signing_identities,
config_schema_registry,
prompt_projection_cache:
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
@@ -2926,6 +2949,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)
@@ -4087,6 +4118,107 @@ async fn scoped_update_workspace_settings(
}))
}
async fn scoped_get_workspace_signing_identity(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Extension(actor): Extension<RequestActor>,
) -> ApiResult<Json<WorkspaceSigningIdentityResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Extension(actor): Extension<RequestActor>,
) -> ApiResult<Json<WorkspaceSigningIdentityResponse>> {
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<WorkspaceSigningIdentityResponse> {
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,
@@ -17222,6 +17354,11 @@ impl From<Error> 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",
@@ -17378,6 +17515,7 @@ impl IntoResponse for ApiError {
{
StatusCode::SERVICE_UNAVAILABLE
}
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
@@ -20159,7 +20297,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,
..
@@ -20176,7 +20315,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!(
@@ -20234,7 +20378,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
@@ -20563,6 +20710,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
@@ -20783,7 +21033,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 {
+749 -15
View File
@@ -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<String>,
pub public_key_fingerprint: Option<String>,
pub private_material_ref: String,
pub revision: u64,
pub state: String,
pub created_at: String,
pub provisioned_at: Option<String>,
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<String>,
}
#[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<WorkspaceBootstrapResult>;
fn reserve_workspace_signing_identity_provisioning(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityProvisioningOperation>;
fn get_workspace_signing_identity(
&self,
workspace_id: &str,
) -> Result<Option<WorkspaceSigningIdentityRecord>>;
fn activate_workspace_signing_identity(
&self,
activation: &WorkspaceSigningIdentityActivation,
identity_provisioning_operation_key: &str,
actor_account_id: &str,
) -> Result<WorkspaceSigningIdentityRecord>;
fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding)
-> Result<bool>;
async fn get_workspace_runtime_binding(
@@ -2205,6 +2287,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(())
})?;
@@ -2227,8 +2320,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn create_workspace_bootstrap(
&self,
record: &WorkspaceBootstrapRecord,
signing_identity: &WorkspaceSigningIdentityActivation,
identity_provisioning_operation_key: &str,
) -> Result<WorkspaceBootstrapResult> {
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
@@ -2272,6 +2372,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<String> = 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
@@ -2370,6 +2507,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
@@ -2391,6 +2580,245 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn reserve_workspace_signing_identity_provisioning(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityProvisioningOperation> {
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<Option<WorkspaceSigningIdentityRecord>> {
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<WorkspaceSigningIdentityRecord> {
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,
@@ -5806,6 +6234,46 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
})
}
fn read_workspace_signing_identity(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<WorkspaceSigningIdentityRecord> {
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<WorkspaceSigningIdentityProvisioningOperation> {
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,
@@ -7036,12 +7504,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::<BTreeSet<_>>();
let expected = expected
.into_iter()
.map(str::to_string)
.collect::<BTreeSet<_>>();
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()
@@ -7759,7 +8382,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<bool> {
@@ -7852,6 +8476,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 (
@@ -7969,6 +8597,10 @@ mod tests {
version: 53,
name: WORKSPACE_DELETION_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 54,
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(),
},
]
);
@@ -7990,6 +8622,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")?);
@@ -8009,6 +8642,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();
@@ -8031,7 +8672,7 @@ mod tests {
.iter()
.map(|migration| migration.version)
.collect::<Vec<_>>(),
vec![52, 53]
vec![52, 53, 54]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap();
@@ -8039,7 +8680,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]
@@ -8203,6 +8844,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();
@@ -8251,6 +8928,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(())
@@ -9647,13 +10330,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}");
}
@@ -9695,6 +10378,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
@@ -9717,6 +10408,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
@@ -9800,13 +10499,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();
@@ -9843,12 +10555,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!(
+224 -12
View File
@@ -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<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
}
impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
Self { store }
pub fn new(
store: Arc<dyn ControlPlaneStore>,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self {
signing_identities: WorkspaceSigningIdentityService::new(
store.clone(),
signing_materials,
),
store,
}
}
pub fn is_empty(&self) -> Result<bool> {
@@ -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,
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
use crate::store::{AccountRecord, SqliteWorkspaceStore};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, identity_error,
};
use workspace_api::RepositorySourceKind;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
@@ -243,7 +301,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 +331,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)
@@ -283,11 +354,137 @@ mod tests {
);
}
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
[],
|row| row.get::<_, String>(0),
)
.map_err(Error::from)
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
#[tokio::test]
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 +535,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 +575,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 +632,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 {
@@ -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",
];
@@ -0,0 +1,859 @@
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<Self> {
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<ring::signature::Ed25519KeyPair> {
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<String> {
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<Option<WorkspaceSigningPrivateMaterial>>;
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial>;
fn delete(&self, material_ref: &str) -> Result<()>;
}
#[derive(Clone)]
pub struct WorkspaceSigningIdentityService {
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
}
impl WorkspaceSigningIdentityService {
pub fn new(
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> 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<WorkspaceSigningIdentityRecord> {
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<WorkspaceSigningIdentityRecord> {
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<Vec<u8>> {
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<WorkspaceSigningIdentityActivation> {
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<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
}
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
Ok(self
.materials
.lock()
.expect("identity material store lock")
.get(material_ref)
.cloned())
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
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<PathBuf> {
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<Option<WorkspaceSigningPrivateMaterial>> {
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<WorkspaceSigningPrivateMaterial> {
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(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(material_io_error("publish", error)),
}
})();
bytes.zeroize();
let cleanup_result = match fs::remove_file(&temporary) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("remove temporary", error)),
};
write_result?;
cleanup_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(()) => {
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
sync_directory(parent)
}
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)?;
if let Some(parent) = root.parent() {
sync_directory(parent)?;
}
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() {
let parent = current.clone();
current.push(component);
ensure_private_directory(&current)?;
sync_directory(&parent)?;
}
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(|error| material_io_error("synchronize directory", error))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Err(identity_error(
"workspace_signing_identity_durable_publish_unsupported",
"Workspace signing private material durable publication is unsupported on this platform",
))
}
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<String> {
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<String>, message: impl Into<String>) -> Error {
Error::WorkspaceSigningIdentity {
code: code.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
#[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 database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).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 failing_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
assert_eq!(
failing_service.get_validated("workspace-1").unwrap().state,
"pending_provisioning"
);
let error = failing_service
.provision_existing("workspace-1", "account-1")
.unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_io_failed"
));
assert_eq!(
store
.get_workspace_signing_identity("workspace-1")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(failing_service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
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();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-2".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace 2".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let pending = store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap();
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
completed_at: None,
},
)
.unwrap();
let activation = service.prepare_material(&operation).unwrap();
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
store
.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
"account-1",
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
Ok(())
})
.unwrap();
assert_eq!(
store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let recovered = restarted
.provision_existing("workspace-2", "account-1")
.unwrap();
assert_eq!(recovered.key_id, activation.key_id);
assert_eq!(
recovered.public_key_fingerprint.as_deref(),
Some(activation.public_key_fingerprint.as_str())
);
materials.delete(&provisioned.private_material_ref).unwrap();
let error = restarted.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());
}
}
@@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = {
diagnostics: Array<Diagnostic>;
};
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;
@@ -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<T>(
response: Response,
parser: (value: unknown) => T,
@@ -325,6 +541,33 @@ export async function updateWorkspaceMetadata(
);
}
export async function fetchWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity`,
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function provisionWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity/provision`,
{ method: "POST" },
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function fetchProfileSettings(
workspaceId: string,
): Promise<ProfileSettingsResponse> {
@@ -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<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(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 @@
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
@@ -286,6 +378,13 @@
{/if}
<style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
+73
View File
@@ -28,6 +28,7 @@ import {
fetchWorkspaceMetadata,
parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse,
parseWorkspaceSigningIdentityResponse,
ProfileApiError,
updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts";
@@ -170,6 +171,78 @@ Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid
);
});
Deno.test("Workspace signing identity parser validates active and pending public contracts", () => {
const active = {
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
state: "active",
created_at: "2026-01-01T00:00:00Z",
provisioned_at: "2026-01-01T00:00:00Z",
},
public_bundle: {
workspace_id: "workspace-1",
backend_url: "https://backend.example.test",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
},
};
assertEquals(
parseWorkspaceSigningIdentityResponse(active).public_bundle?.key_id,
"WK-1",
);
assertEquals(
parseWorkspaceSigningIdentityResponse({
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
revision: 1,
state: "pending_provisioning",
created_at: "2026-01-01T00:00:00Z",
},
}).public_bundle,
undefined,
);
for (
const mutate of [
(value: Record<string, unknown>) => {
value.private_material_ref = "must-not-be-accepted";
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).revision =
Number.MAX_SAFE_INTEGER + 1;
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).public_key = "x".repeat(
17_000,
);
},
(value: Record<string, unknown>) => {
(value.public_bundle as Record<string, unknown>).key_id = "WK-other";
},
(value: Record<string, unknown>) => {
delete value.public_bundle;
},
]
) {
const value = structuredClone(active);
mutate(value);
assertThrows(
() => parseWorkspaceSigningIdentityResponse(value),
ProfileApiError,
);
}
});
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
assertThrows(
() =>