feat: integrate Workspace signing identity authority
This commit is contained in:
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
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_OPERATION_ID_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||||
@@ -2848,6 +2906,10 @@ pub fn catalog_typescript() -> String {
|
|||||||
WorkspaceMetadataSettingsResponse::decl(&config),
|
WorkspaceMetadataSettingsResponse::decl(&config),
|
||||||
UpdateWorkspaceMetadataRequest::decl(&config),
|
UpdateWorkspaceMetadataRequest::decl(&config),
|
||||||
WorkspaceMetadataMutationResponse::decl(&config),
|
WorkspaceMetadataMutationResponse::decl(&config),
|
||||||
|
WorkspaceSigningIdentityState::decl(&config),
|
||||||
|
WorkspaceSigningIdentityPublic::decl(&config),
|
||||||
|
WorkspacePublicIdentityBundle::decl(&config),
|
||||||
|
WorkspaceSigningIdentityResponse::decl(&config),
|
||||||
ProfileSettingsResponse::decl(&config),
|
ProfileSettingsResponse::decl(&config),
|
||||||
WorkspaceProfileSummary::decl(&config),
|
WorkspaceProfileSummary::decl(&config),
|
||||||
WorkspaceProfileSourceSummary::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 {
|
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||||
WorkspaceWorkerDiscoveryItem {
|
WorkspaceWorkerDiscoveryItem {
|
||||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||||
|
|||||||
@@ -795,6 +795,51 @@ CREATE TABLE workspace_create_operations (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE workspace_memory_documents (
|
||||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
body_md TEXT NOT NULL,
|
body_md TEXT NOT NULL,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod workdir_removal;
|
|||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
pub mod workspace_catalog;
|
pub mod workspace_catalog;
|
||||||
mod workspace_deletion;
|
mod workspace_deletion;
|
||||||
|
pub mod workspace_signing_identity;
|
||||||
mod workspace_subscription;
|
mod workspace_subscription;
|
||||||
|
|
||||||
pub use authority::{
|
pub use authority::{
|
||||||
@@ -138,6 +139,8 @@ pub enum Error {
|
|||||||
WorkerSourceIdentity(String),
|
WorkerSourceIdentity(String),
|
||||||
#[error("workspace identity error: {0}")]
|
#[error("workspace identity error: {0}")]
|
||||||
WorkspaceIdentity(String),
|
WorkspaceIdentity(String),
|
||||||
|
#[error("Workspace signing identity error ({code}): {message}")]
|
||||||
|
WorkspaceSigningIdentity { code: String, message: String },
|
||||||
#[error("store error: {0}")]
|
#[error("store error: {0}")]
|
||||||
Store(String),
|
Store(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,11 @@ use workspace_api::{
|
|||||||
WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse,
|
WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse,
|
||||||
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
|
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
|
||||||
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
|
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
|
||||||
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord,
|
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
|
||||||
WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary,
|
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
|
||||||
|
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
||||||
|
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -167,6 +169,10 @@ use crate::workdir_removal::{
|
|||||||
};
|
};
|
||||||
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
||||||
use crate::workspace_deletion::WorkspaceDeletionStore;
|
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||||
|
use crate::workspace_signing_identity::{
|
||||||
|
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||||
|
WorkspaceSigningMaterialStore, workspace_signing_material_root,
|
||||||
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
||||||
@@ -585,6 +591,7 @@ pub struct WorkspaceApi {
|
|||||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||||
config_store: Arc<crate::SqliteWorkspaceStore>,
|
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||||
repository_secrets: Arc<RepositorySecretService>,
|
repository_secrets: Arc<RepositorySecretService>,
|
||||||
|
signing_identities: WorkspaceSigningIdentityService,
|
||||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
@@ -1027,6 +1034,7 @@ pub struct WorkspaceServerApi {
|
|||||||
template: Arc<ServerConfig>,
|
template: Arc<ServerConfig>,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
catalog: WorkspaceCatalogService,
|
catalog: WorkspaceCatalogService,
|
||||||
|
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
|
||||||
routers: Arc<AsyncMutex<HashMap<String, Router>>>,
|
routers: Arc<AsyncMutex<HashMap<String, Router>>>,
|
||||||
apis: Arc<AsyncMutex<HashMap<String, WorkspaceApi>>>,
|
apis: Arc<AsyncMutex<HashMap<String, WorkspaceApi>>>,
|
||||||
mutation_locks: Arc<AsyncMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
mutation_locks: Arc<AsyncMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||||
@@ -1047,9 +1055,14 @@ async fn workspace_mutation_lock(
|
|||||||
|
|
||||||
impl WorkspaceServerApi {
|
impl WorkspaceServerApi {
|
||||||
pub fn new(template: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Self {
|
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 {
|
Self {
|
||||||
template: Arc::new(template),
|
template: Arc::new(template),
|
||||||
catalog: WorkspaceCatalogService::new(store.clone()),
|
catalog: WorkspaceCatalogService::new(store.clone(), signing_materials.clone()),
|
||||||
|
signing_materials,
|
||||||
store,
|
store,
|
||||||
routers: Arc::new(AsyncMutex::new(HashMap::new())),
|
routers: Arc::new(AsyncMutex::new(HashMap::new())),
|
||||||
apis: Arc::new(AsyncMutex::new(HashMap::new())),
|
apis: Arc::new(AsyncMutex::new(HashMap::new())),
|
||||||
@@ -1283,6 +1296,8 @@ impl WorkspaceServerApi {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone())
|
||||||
|
.delete_material(&operation.workspace_id)?;
|
||||||
let completed = self.store.finalize_workspace_deletion(operation_id)?;
|
let completed = self.store.finalize_workspace_deletion(operation_id)?;
|
||||||
self.routers.lock().await.remove(&completed.workspace_id);
|
self.routers.lock().await.remove(&completed.workspace_id);
|
||||||
if let Some(handle) = self
|
if let Some(handle) = self
|
||||||
@@ -2228,6 +2243,13 @@ impl WorkspaceApi {
|
|||||||
config_store.clone(),
|
config_store.clone(),
|
||||||
&config.database_path,
|
&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()
|
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||||
.with_provider(Arc::new(
|
.with_provider(Arc::new(
|
||||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||||
@@ -2244,6 +2266,7 @@ impl WorkspaceApi {
|
|||||||
let api = Self {
|
let api = Self {
|
||||||
config_store,
|
config_store,
|
||||||
repository_secrets,
|
repository_secrets,
|
||||||
|
signing_identities,
|
||||||
config_schema_registry,
|
config_schema_registry,
|
||||||
prompt_projection_cache:
|
prompt_projection_cache:
|
||||||
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
||||||
@@ -2926,6 +2949,14 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/settings/workspace",
|
"/api/w/{workspace_id}/settings/workspace",
|
||||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
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(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/memory",
|
"/api/w/{workspace_id}/settings/memory",
|
||||||
get(scoped_get_workspace_memory_settings)
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WorkspaceConfigRevisionPath {
|
struct WorkspaceConfigRevisionPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -17222,6 +17354,11 @@ impl From<Error> for ApiError {
|
|||||||
severity: DiagnosticSeverity::Error,
|
severity: DiagnosticSeverity::Error,
|
||||||
message: sanitize_backend_error(message),
|
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 {
|
Error::Ticket(ticket_error) => vec![RuntimeDiagnostic {
|
||||||
code: match ticket_error {
|
code: match ticket_error {
|
||||||
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
||||||
@@ -17378,6 +17515,7 @@ impl IntoResponse for ApiError {
|
|||||||
{
|
{
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
}
|
}
|
||||||
|
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
@@ -20159,7 +20297,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_server_router_requires_identity_for_scoped_rest() {
|
async fn workspace_server_router_requires_identity_for_scoped_rest() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
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 {
|
let AuthConfig::Passkey {
|
||||||
origin: expected_origin,
|
origin: expected_origin,
|
||||||
..
|
..
|
||||||
@@ -20176,7 +20315,12 @@ mod tests {
|
|||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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");
|
let repository = temp.path().join("repository");
|
||||||
std::fs::create_dir_all(&repository).unwrap();
|
std::fs::create_dir_all(&repository).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -20234,7 +20378,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let non_owner_token = seed_test_api_token(store.as_ref(), "repository-access-non-owner");
|
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 uri = format!("/api/w/{}/workspace", workspace.workspace.workspace_id);
|
||||||
|
|
||||||
let anonymous = app
|
let anonymous = app
|
||||||
@@ -20563,6 +20710,109 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
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!(
|
let settings_uri = format!(
|
||||||
"/api/w/{}/settings/workspace",
|
"/api/w/{}/settings/workspace",
|
||||||
workspace.workspace.workspace_id
|
workspace.workspace.workspace_id
|
||||||
@@ -20783,7 +21033,12 @@ mod tests {
|
|||||||
template.static_assets_dir = Some(static_dir);
|
template.static_assets_dir = Some(static_dir);
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||||
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
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
|
let workspace_a = catalog
|
||||||
.create(
|
.create(
|
||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 53;
|
const LATEST_SCHEMA_VERSION: i64 = 54;
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||||
const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations";
|
const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations";
|
||||||
|
const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority";
|
||||||
|
|
||||||
const MIGRATIONS: &[Migration] = &[
|
const MIGRATIONS: &[Migration] = &[
|
||||||
Migration {
|
Migration {
|
||||||
@@ -40,6 +41,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: WORKSPACE_DELETION_MIGRATION_NAME,
|
name: WORKSPACE_DELETION_MIGRATION_NAME,
|
||||||
apply: migrate_workspace_deletion_v52_to_v53,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -135,6 +141,66 @@ pub struct WorkspaceBootstrapResult {
|
|||||||
pub replayed: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkspaceRuntimeBinding {
|
pub struct WorkspaceRuntimeBinding {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -602,7 +668,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
fn create_workspace_bootstrap(
|
fn create_workspace_bootstrap(
|
||||||
&self,
|
&self,
|
||||||
record: &WorkspaceBootstrapRecord,
|
record: &WorkspaceBootstrapRecord,
|
||||||
|
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||||
|
identity_provisioning_operation_key: &str,
|
||||||
) -> Result<WorkspaceBootstrapResult>;
|
) -> 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)
|
fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding)
|
||||||
-> Result<bool>;
|
-> Result<bool>;
|
||||||
async fn get_workspace_runtime_binding(
|
async fn get_workspace_runtime_binding(
|
||||||
@@ -2205,6 +2287,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) VALUES (?1, 1, 'English', ?2, ?3)"#,
|
) VALUES (?1, 1, 'English', ?2, ?3)"#,
|
||||||
params![record.workspace_id, record.created_at, record.updated_at],
|
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()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -2227,8 +2320,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
fn create_workspace_bootstrap(
|
fn create_workspace_bootstrap(
|
||||||
&self,
|
&self,
|
||||||
record: &WorkspaceBootstrapRecord,
|
record: &WorkspaceBootstrapRecord,
|
||||||
|
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||||
|
identity_provisioning_operation_key: &str,
|
||||||
) -> Result<WorkspaceBootstrapResult> {
|
) -> Result<WorkspaceBootstrapResult> {
|
||||||
validate_repository_record_identity(&record.repository)?;
|
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| {
|
self.with_conn_mut(|conn| {
|
||||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let owner_kind = tx
|
let owner_kind = tx
|
||||||
@@ -2272,6 +2372,43 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
params![workspace.workspace_id, record.repository.repository_key],
|
params![workspace.workspace_id, record.repository.repository_key],
|
||||||
read_repository_record,
|
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)?
|
let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)?
|
||||||
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
||||||
.snapshot
|
.snapshot
|
||||||
@@ -2370,6 +2507,58 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
||||||
.snapshot
|
.snapshot
|
||||||
.revision;
|
.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(
|
tx.execute(
|
||||||
r#"INSERT INTO workspace_create_operations (
|
r#"INSERT INTO workspace_create_operations (
|
||||||
operation_key, request_fingerprint, workspace_id, created_at
|
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(
|
fn workspace_runtime_binding_matches(
|
||||||
&self,
|
&self,
|
||||||
expected: &WorkspaceRuntimeBinding,
|
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(
|
fn repository_registration_intent_matches(
|
||||||
existing: &RepositoryRecord,
|
existing: &RepositoryRecord,
|
||||||
requested: &RepositoryRecord,
|
requested: &RepositoryRecord,
|
||||||
@@ -7036,12 +7504,167 @@ fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> {
|
|||||||
verify_workspace_deletion_schema(&tx)?;
|
verify_workspace_deletion_schema(&tx)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
params![LATEST_SCHEMA_VERSION, WORKSPACE_DELETION_MIGRATION_NAME],
|
params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME],
|
||||||
)?;
|
)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
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<()> {
|
fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> {
|
||||||
let columns = table_columns(conn, "workspace_deletion_operations")?
|
let columns = table_columns(conn, "workspace_deletion_operations")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -7759,7 +8382,8 @@ fn apply_migrations(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||||
verify_workspace_runtime_binding_schema(conn)?;
|
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> {
|
fn table_exists(conn: &Connection, table_name: &str) -> Result<bool> {
|
||||||
@@ -7852,6 +8476,10 @@ mod tests {
|
|||||||
create_latest_workspace_schema(&conn).unwrap();
|
create_latest_workspace_schema(&conn).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
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 INDEX workspace_deletion_operations_workspace_recent;
|
||||||
DROP TABLE workspace_deletion_operations;
|
DROP TABLE workspace_deletion_operations;
|
||||||
CREATE TABLE worker_create_reservations_v52 (
|
CREATE TABLE worker_create_reservations_v52 (
|
||||||
@@ -7969,6 +8597,10 @@ mod tests {
|
|||||||
version: 53,
|
version: 53,
|
||||||
name: WORKSPACE_DELETION_MIGRATION_NAME.to_string(),
|
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()),
|
(51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME.to_string()),
|
||||||
(52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()),
|
(52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()),
|
||||||
(53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()),
|
(53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()),
|
||||||
|
(54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -8009,6 +8642,14 @@ mod tests {
|
|||||||
)?,
|
)?,
|
||||||
1
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -8031,7 +8672,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|migration| migration.version)
|
.map(|migration| migration.version)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![52, 53]
|
vec![52, 53, 54]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
let conn = Connection::open(&path).unwrap();
|
let conn = Connection::open(&path).unwrap();
|
||||||
@@ -8039,7 +8680,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 4);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8203,6 +8844,42 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn schema_v52_workspace_deletion_migration_rolls_back_on_failure() {
|
fn schema_v52_workspace_deletion_migration_rolls_back_on_failure() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -8251,6 +8928,12 @@ mod tests {
|
|||||||
VALUES
|
VALUES
|
||||||
('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'),
|
('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'),
|
||||||
('workspace-b', 'owner', 'Workspace B', '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(())
|
Ok(())
|
||||||
@@ -9647,13 +10330,13 @@ INSERT INTO worker_registry (
|
|||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
configure_sqlite(&conn).unwrap();
|
configure_sqlite(&conn).unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (54, 'future')",
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||||
assert!(error.contains("schema version 54 is newer"), "{error}");
|
assert!(error.contains("schema version 55 is newer"), "{error}");
|
||||||
assert!(error.contains("refusing to serve"), "{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');
|
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)
|
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');
|
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 (
|
INSERT INTO typed_tickets (
|
||||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
workflow_state, workflow_state_explicit
|
workflow_state, workflow_state_explicit
|
||||||
@@ -9717,6 +10408,14 @@ DELETE FROM typed_tickets
|
|||||||
WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a';
|
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)
|
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');
|
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 (
|
INSERT INTO typed_tickets (
|
||||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
workflow_state, workflow_state_explicit
|
workflow_state, workflow_state_explicit
|
||||||
@@ -9800,13 +10499,26 @@ INSERT INTO worker_registry (
|
|||||||
updated_at: "1".to_string(),
|
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
|
let error = store
|
||||||
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
.create_workspace_bootstrap(
|
||||||
operation_key: "invalid-key".to_string(),
|
&WorkspaceBootstrapRecord {
|
||||||
request_fingerprint: "sha256:invalid-key".to_string(),
|
operation_key: "invalid-key".to_string(),
|
||||||
workspace: workspace.clone(),
|
request_fingerprint: "sha256:invalid-key".to_string(),
|
||||||
repository,
|
workspace: workspace.clone(),
|
||||||
})
|
repository,
|
||||||
|
},
|
||||||
|
&signing_identity,
|
||||||
|
"identity-store-test",
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -9843,12 +10555,34 @@ INSERT INTO worker_registry (
|
|||||||
workspace,
|
workspace,
|
||||||
repository: valid_repository,
|
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;
|
let mut duplicate = first;
|
||||||
duplicate.operation_key = "duplicate-workspace".to_string();
|
duplicate.operation_key = "duplicate-workspace".to_string();
|
||||||
duplicate.repository.repository_id = Uuid::now_v7().to_string();
|
duplicate.repository.repository_id = Uuid::now_v7().to_string();
|
||||||
let error = store
|
let error = store
|
||||||
.create_workspace_bootstrap(&duplicate)
|
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger
|
|||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
|
use crate::workspace_signing_identity::{
|
||||||
|
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
|
||||||
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const MAX_DISPLAY_NAME_BYTES: usize = 200;
|
const MAX_DISPLAY_NAME_BYTES: usize = 200;
|
||||||
@@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WorkspaceCatalogService {
|
pub struct WorkspaceCatalogService {
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
|
signing_identities: WorkspaceSigningIdentityService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceCatalogService {
|
impl WorkspaceCatalogService {
|
||||||
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
|
pub fn new(
|
||||||
Self { store }
|
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> {
|
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()))
|
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let workspace_id = requested_workspace_id
|
let proposed_workspace_id = requested_workspace_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
||||||
let fingerprint = workspace_create_fingerprint(
|
let fingerprint = workspace_create_fingerprint(
|
||||||
@@ -130,9 +143,16 @@ impl WorkspaceCatalogService {
|
|||||||
&default_ref,
|
&default_ref,
|
||||||
);
|
);
|
||||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
let result = self
|
let (signing_identity, identity_provisioning_operation_key) =
|
||||||
.store
|
self.signing_identities.prepare_workspace_creation(
|
||||||
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
&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,
|
operation_key,
|
||||||
request_fingerprint: fingerprint.clone(),
|
request_fingerprint: fingerprint.clone(),
|
||||||
workspace: WorkspaceRecord {
|
workspace: WorkspaceRecord {
|
||||||
@@ -158,7 +178,10 @@ impl WorkspaceCatalogService {
|
|||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
})?;
|
},
|
||||||
|
&signing_identity,
|
||||||
|
&identity_provisioning_operation_key,
|
||||||
|
)?;
|
||||||
Ok(WorkspaceCreateResult {
|
Ok(WorkspaceCreateResult {
|
||||||
workspace: result.workspace,
|
workspace: result.workspace,
|
||||||
repository: result.repository,
|
repository: result.repository,
|
||||||
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
||||||
|
use crate::workspace_signing_identity::{
|
||||||
|
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
|
||||||
|
WorkspaceSigningPrivateMaterial, identity_error,
|
||||||
|
};
|
||||||
use workspace_api::RepositorySourceKind;
|
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 {
|
fn git_repository() -> tempfile::TempDir {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||||
@@ -243,7 +301,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_is_atomic_and_exact_retries_converge() {
|
async fn create_is_atomic_and_exact_retries_converge() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
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 repository = git_repository();
|
||||||
let request = WorkspaceCreateRequest {
|
let request = WorkspaceCreateRequest {
|
||||||
operation_key: "request-1".to_string(),
|
operation_key: "request-1".to_string(),
|
||||||
@@ -268,6 +331,14 @@ mod tests {
|
|||||||
replayed.workspace.workspace_id
|
replayed.workspace.workspace_id
|
||||||
);
|
);
|
||||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
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!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.list_repositories(&created.workspace.workspace_id)
|
.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]
|
#[tokio::test]
|
||||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
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 repository = git_repository();
|
||||||
let mut request = WorkspaceCreateRequest {
|
let mut request = WorkspaceCreateRequest {
|
||||||
operation_key: "request-1".to_string(),
|
operation_key: "request-1".to_string(),
|
||||||
@@ -338,7 +535,12 @@ mod tests {
|
|||||||
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let service = WorkspaceCatalogService::new(store);
|
let service = WorkspaceCatalogService::new(
|
||||||
|
store,
|
||||||
|
Arc::new(
|
||||||
|
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||||
|
),
|
||||||
|
);
|
||||||
let repository = git_repository();
|
let repository = git_repository();
|
||||||
let error = service
|
let error = service
|
||||||
.create(
|
.create(
|
||||||
@@ -373,7 +575,12 @@ mod tests {
|
|||||||
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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_a = git_repository();
|
||||||
let repository_b = git_repository();
|
let repository_b = git_repository();
|
||||||
let created_a = service
|
let created_a = service
|
||||||
@@ -425,7 +632,12 @@ mod tests {
|
|||||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
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
|
let result = service
|
||||||
.create(
|
.create(
|
||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"workspace_resource_keys",
|
"workspace_resource_keys",
|
||||||
"workspace_runtime_binding_audit",
|
"workspace_runtime_binding_audit",
|
||||||
"workspace_runtime_bindings",
|
"workspace_runtime_bindings",
|
||||||
|
"workspace_signing_identities",
|
||||||
|
"workspace_signing_identity_audit",
|
||||||
|
"workspace_signing_identity_provisioning_operations",
|
||||||
"workspace_worker_retention_policies",
|
"workspace_worker_retention_policies",
|
||||||
"workspace_worker_retention_policy_revisions",
|
"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(¤t)?;
|
||||||
|
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>;
|
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 = {
|
export type ProfileSettingsResponse = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
registry_revision: string;
|
registry_revision: string;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import type {
|
|||||||
WorkspaceProfileSourceProvenance,
|
WorkspaceProfileSourceProvenance,
|
||||||
WorkspaceProfileSourceSummary,
|
WorkspaceProfileSourceSummary,
|
||||||
WorkspaceProfileSummary,
|
WorkspaceProfileSummary,
|
||||||
|
WorkspacePublicIdentityBundle,
|
||||||
|
WorkspaceSigningIdentityPublic,
|
||||||
|
WorkspaceSigningIdentityResponse,
|
||||||
|
WorkspaceSigningIdentityState,
|
||||||
} from "$lib/generated/workspace-api";
|
} from "$lib/generated/workspace-api";
|
||||||
|
|
||||||
export class ProfileApiError extends Error {
|
export class ProfileApiError extends Error {
|
||||||
@@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string {
|
|||||||
return value;
|
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 {
|
function booleanValue(value: unknown, context: string): boolean {
|
||||||
if (typeof value !== "boolean") {
|
if (typeof value !== "boolean") {
|
||||||
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
|
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
|
||||||
@@ -66,6 +82,15 @@ function optionalString(
|
|||||||
return stringValue(value, context);
|
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(
|
function optionalRevision(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
context: string,
|
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>(
|
async function parseResponse<T>(
|
||||||
response: Response,
|
response: Response,
|
||||||
parser: (value: unknown) => T,
|
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(
|
export async function fetchProfileSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<ProfileSettingsResponse> {
|
): Promise<ProfileSettingsResponse> {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
WorkspaceDeletionPreflightResponse,
|
WorkspaceDeletionPreflightResponse,
|
||||||
WorkspaceDeletionRequest,
|
WorkspaceDeletionRequest,
|
||||||
WorkspaceMetadataSettingsResponse,
|
WorkspaceMetadataSettingsResponse,
|
||||||
|
WorkspaceSigningIdentityResponse,
|
||||||
} from '$lib/generated/workspace-api';
|
} from '$lib/generated/workspace-api';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||||
import {
|
import {
|
||||||
fetchWorkspaceMetadata,
|
fetchWorkspaceMetadata,
|
||||||
|
fetchWorkspaceSigningIdentity,
|
||||||
|
provisionWorkspaceSigningIdentity,
|
||||||
updateWorkspaceMetadata,
|
updateWorkspaceMetadata,
|
||||||
} from '$lib/workspace/settings/profile-api';
|
} from '$lib/workspace/settings/profile-api';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
@@ -26,6 +29,14 @@
|
|||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||||
|
|
||||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
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 displayNameDraft = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
@@ -58,6 +69,17 @@
|
|||||||
workspaceMetadata = response;
|
workspaceMetadata = response;
|
||||||
displayNameDraft = response.display_name;
|
displayNameDraft = response.display_name;
|
||||||
diagnostics = response.diagnostics;
|
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) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
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() {
|
async function openDeletionConfirmation() {
|
||||||
deletionOpen = true;
|
deletionOpen = true;
|
||||||
deletionLoading = true;
|
deletionLoading = true;
|
||||||
@@ -232,6 +278,52 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.workspace?.permissions.delete_workspace}
|
{#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">
|
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="workspace-danger-title">Danger zone</h2>
|
<h2 id="workspace-danger-title">Danger zone</h2>
|
||||||
@@ -286,6 +378,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 { 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-zone p { max-width: 68ch; }
|
||||||
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
fetchWorkspaceMetadata,
|
fetchWorkspaceMetadata,
|
||||||
parseProfileSettingsResponse,
|
parseProfileSettingsResponse,
|
||||||
parseWorkspaceMetadataSettingsResponse,
|
parseWorkspaceMetadataSettingsResponse,
|
||||||
|
parseWorkspaceSigningIdentityResponse,
|
||||||
ProfileApiError,
|
ProfileApiError,
|
||||||
updateWorkspaceMetadata,
|
updateWorkspaceMetadata,
|
||||||
} from "../src/lib/workspace/settings/profile-api.ts";
|
} 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", () => {
|
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user