feat: integrate Workspace signing identity authority
This commit is contained in:
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceSigningIdentityState {
|
||||
PendingProvisioning,
|
||||
Active,
|
||||
}
|
||||
|
||||
/// Public metadata for a Workspace signing identity. Private material and its
|
||||
/// storage reference are deliberately not part of this wire authority.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSigningIdentityPublic {
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
pub algorithm: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_key_fingerprint: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
pub state: WorkspaceSigningIdentityState,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub provisioned_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Copyable public trust bundle consumed by future Runtime enrollment work.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspacePublicIdentityBundle {
|
||||
pub workspace_id: String,
|
||||
pub backend_url: String,
|
||||
pub key_id: String,
|
||||
pub algorithm: String,
|
||||
pub public_key: String,
|
||||
pub public_key_fingerprint: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSigningIdentityResponse {
|
||||
pub identity: WorkspaceSigningIdentityPublic,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
|
||||
}
|
||||
|
||||
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
||||
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
||||
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||
@@ -2848,6 +2906,10 @@ pub fn catalog_typescript() -> String {
|
||||
WorkspaceMetadataSettingsResponse::decl(&config),
|
||||
UpdateWorkspaceMetadataRequest::decl(&config),
|
||||
WorkspaceMetadataMutationResponse::decl(&config),
|
||||
WorkspaceSigningIdentityState::decl(&config),
|
||||
WorkspaceSigningIdentityPublic::decl(&config),
|
||||
WorkspacePublicIdentityBundle::decl(&config),
|
||||
WorkspaceSigningIdentityResponse::decl(&config),
|
||||
ProfileSettingsResponse::decl(&config),
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
@@ -3877,6 +3939,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() {
|
||||
let response = WorkspaceSigningIdentityResponse {
|
||||
identity: WorkspaceSigningIdentityPublic {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
key_id: "WK-test".to_string(),
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: None,
|
||||
public_key_fingerprint: None,
|
||||
revision: 1,
|
||||
state: WorkspaceSigningIdentityState::PendingProvisioning,
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
provisioned_at: None,
|
||||
},
|
||||
public_bundle: None,
|
||||
};
|
||||
let encoded = serde_json::to_value(&response).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({
|
||||
"identity": {
|
||||
"workspace_id": "workspace-test",
|
||||
"key_id": "WK-test",
|
||||
"algorithm": "ed25519",
|
||||
"revision": 1,
|
||||
"state": "pending_provisioning",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceSigningIdentityResponse>(serde_json::json!({
|
||||
"identity": encoded["identity"].clone(),
|
||||
"private_material_ref": "must-not-cross-the-wire"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||
WorkspaceWorkerDiscoveryItem {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
|
||||
@@ -795,6 +795,51 @@ CREATE TABLE workspace_create_operations (
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_signing_identities (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
|
||||
public_key TEXT,
|
||||
public_key_fingerprint TEXT,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
provisioned_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
|
||||
OR
|
||||
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_provisioning_operations (
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
|
||||
workspace_id TEXT NOT NULL UNIQUE,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_audit (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX workspace_signing_identity_audit_workspace_idx
|
||||
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
|
||||
CREATE TABLE workspace_memory_documents (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
body_md TEXT NOT NULL,
|
||||
|
||||
@@ -34,6 +34,7 @@ mod workdir_removal;
|
||||
pub mod worker_source;
|
||||
pub mod workspace_catalog;
|
||||
mod workspace_deletion;
|
||||
pub mod workspace_signing_identity;
|
||||
mod workspace_subscription;
|
||||
|
||||
pub use authority::{
|
||||
@@ -138,6 +139,8 @@ pub enum Error {
|
||||
WorkerSourceIdentity(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("Workspace signing identity error ({code}): {message}")]
|
||||
WorkspaceSigningIdentity { code: String, message: String },
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
@@ -95,9 +95,11 @@ use workspace_api::{
|
||||
WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse,
|
||||
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
|
||||
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
|
||||
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord,
|
||||
WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary,
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
|
||||
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
|
||||
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
||||
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -167,6 +169,10 @@ use crate::workdir_removal::{
|
||||
};
|
||||
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
||||
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::workspace_signing_identity::{
|
||||
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
WorkspaceSigningMaterialStore, workspace_signing_material_root,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
||||
@@ -585,6 +591,7 @@ pub struct WorkspaceApi {
|
||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||
repository_secrets: Arc<RepositorySecretService>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||
authority: SqliteWorkspaceAuthority,
|
||||
@@ -1027,6 +1034,7 @@ pub struct WorkspaceServerApi {
|
||||
template: Arc<ServerConfig>,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
catalog: WorkspaceCatalogService,
|
||||
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
|
||||
routers: Arc<AsyncMutex<HashMap<String, Router>>>,
|
||||
apis: Arc<AsyncMutex<HashMap<String, WorkspaceApi>>>,
|
||||
mutation_locks: Arc<AsyncMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||
@@ -1047,9 +1055,14 @@ async fn workspace_mutation_lock(
|
||||
|
||||
impl WorkspaceServerApi {
|
||||
pub fn new(template: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Self {
|
||||
let signing_materials: Arc<dyn WorkspaceSigningMaterialStore> =
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&template.database_path),
|
||||
));
|
||||
Self {
|
||||
template: Arc::new(template),
|
||||
catalog: WorkspaceCatalogService::new(store.clone()),
|
||||
catalog: WorkspaceCatalogService::new(store.clone(), signing_materials.clone()),
|
||||
signing_materials,
|
||||
store,
|
||||
routers: Arc::new(AsyncMutex::new(HashMap::new())),
|
||||
apis: Arc::new(AsyncMutex::new(HashMap::new())),
|
||||
@@ -1283,6 +1296,8 @@ impl WorkspaceServerApi {
|
||||
None,
|
||||
);
|
||||
}
|
||||
WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone())
|
||||
.delete_material(&operation.workspace_id)?;
|
||||
let completed = self.store.finalize_workspace_deletion(operation_id)?;
|
||||
self.routers.lock().await.remove(&completed.workspace_id);
|
||||
if let Some(handle) = self
|
||||
@@ -2228,6 +2243,13 @@ impl WorkspaceApi {
|
||||
config_store.clone(),
|
||||
&config.database_path,
|
||||
)?);
|
||||
let signing_materials: Arc<dyn WorkspaceSigningMaterialStore> =
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&config.database_path),
|
||||
));
|
||||
let signing_identities =
|
||||
WorkspaceSigningIdentityService::new(store.clone(), signing_materials);
|
||||
signing_identities.get_validated(&config.workspace_id)?;
|
||||
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||
@@ -2244,6 +2266,7 @@ impl WorkspaceApi {
|
||||
let api = Self {
|
||||
config_store,
|
||||
repository_secrets,
|
||||
signing_identities,
|
||||
config_schema_registry,
|
||||
prompt_projection_cache:
|
||||
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
||||
@@ -2926,6 +2949,14 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/settings/workspace",
|
||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/workspace/signing-identity",
|
||||
get(scoped_get_workspace_signing_identity),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/workspace/signing-identity/provision",
|
||||
post(scoped_provision_workspace_signing_identity),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/memory",
|
||||
get(scoped_get_workspace_memory_settings)
|
||||
@@ -4087,6 +4118,107 @@ async fn scoped_update_workspace_settings(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace_signing_identity(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<WorkspaceSigningIdentityResponse>> {
|
||||
require_workspace_owner(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
&actor,
|
||||
"Workspace public identity access",
|
||||
)
|
||||
.await?;
|
||||
let identity = api.signing_identities.get_validated(&path.workspace_id)?;
|
||||
Ok(Json(project_workspace_signing_identity(&api, identity)?))
|
||||
}
|
||||
|
||||
async fn scoped_provision_workspace_signing_identity(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<WorkspaceSigningIdentityResponse>> {
|
||||
require_workspace_owner(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
&actor,
|
||||
"Workspace signing identity provisioning",
|
||||
)
|
||||
.await?;
|
||||
let identity = api
|
||||
.signing_identities
|
||||
.provision_existing(&path.workspace_id, &actor.account_id)?;
|
||||
Ok(Json(project_workspace_signing_identity(&api, identity)?))
|
||||
}
|
||||
|
||||
fn project_workspace_signing_identity(
|
||||
api: &WorkspaceApi,
|
||||
identity: crate::store::WorkspaceSigningIdentityRecord,
|
||||
) -> Result<WorkspaceSigningIdentityResponse> {
|
||||
let state = match identity.state.as_str() {
|
||||
"pending_provisioning" => WorkspaceSigningIdentityState::PendingProvisioning,
|
||||
"active" => WorkspaceSigningIdentityState::Active,
|
||||
_ => {
|
||||
return Err(crate::workspace_signing_identity::identity_error(
|
||||
"workspace_signing_identity_state_invalid",
|
||||
"Workspace signing identity state is invalid",
|
||||
));
|
||||
}
|
||||
};
|
||||
let public_bundle = if state == WorkspaceSigningIdentityState::Active {
|
||||
let public_key = identity.public_key.clone().ok_or_else(|| {
|
||||
crate::workspace_signing_identity::identity_error(
|
||||
"workspace_signing_identity_metadata_corrupt",
|
||||
"Active Workspace signing identity has no public key",
|
||||
)
|
||||
})?;
|
||||
let public_key_fingerprint = identity.public_key_fingerprint.clone().ok_or_else(|| {
|
||||
crate::workspace_signing_identity::identity_error(
|
||||
"workspace_signing_identity_metadata_corrupt",
|
||||
"Active Workspace signing identity has no public key fingerprint",
|
||||
)
|
||||
})?;
|
||||
let backend_url = api
|
||||
.config
|
||||
.backend_base_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
crate::workspace_signing_identity::identity_error(
|
||||
"workspace_signing_identity_backend_url_unavailable",
|
||||
"Backend public URL is unavailable for the Workspace identity bundle",
|
||||
)
|
||||
})?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
Some(WorkspacePublicIdentityBundle {
|
||||
workspace_id: identity.workspace_id.clone(),
|
||||
backend_url,
|
||||
key_id: identity.key_id.clone(),
|
||||
algorithm: identity.algorithm.clone(),
|
||||
public_key,
|
||||
public_key_fingerprint,
|
||||
revision: identity.revision,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(WorkspaceSigningIdentityResponse {
|
||||
identity: WorkspaceSigningIdentityPublic {
|
||||
workspace_id: identity.workspace_id,
|
||||
key_id: identity.key_id,
|
||||
algorithm: identity.algorithm,
|
||||
public_key: identity.public_key,
|
||||
public_key_fingerprint: identity.public_key_fingerprint,
|
||||
revision: identity.revision,
|
||||
state,
|
||||
created_at: identity.created_at,
|
||||
provisioned_at: identity.provisioned_at,
|
||||
},
|
||||
public_bundle,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkspaceConfigRevisionPath {
|
||||
workspace_id: String,
|
||||
@@ -17222,6 +17354,11 @@ impl From<Error> for ApiError {
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}],
|
||||
Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic {
|
||||
code: code.clone(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}],
|
||||
Error::Ticket(ticket_error) => vec![RuntimeDiagnostic {
|
||||
code: match ticket_error {
|
||||
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
||||
@@ -17378,6 +17515,7 @@ impl IntoResponse for ApiError {
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
@@ -20159,7 +20297,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn workspace_server_router_requires_identity_for_scoped_rest() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = test_server_config(temp.path());
|
||||
let mut config = test_server_config(temp.path());
|
||||
config.backend_base_url = Some("https://backend.example.test".to_string());
|
||||
let AuthConfig::Passkey {
|
||||
origin: expected_origin,
|
||||
..
|
||||
@@ -20176,7 +20315,12 @@ mod tests {
|
||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||
let catalog = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&config.database_path),
|
||||
)),
|
||||
);
|
||||
let repository = temp.path().join("repository");
|
||||
std::fs::create_dir_all(&repository).unwrap();
|
||||
assert!(
|
||||
@@ -20234,7 +20378,10 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
let non_owner_token = seed_test_api_token(store.as_ref(), "repository-access-non-owner");
|
||||
let app = build_workspace_server_router(config, store).await.unwrap();
|
||||
let identity_material_root = workspace_signing_material_root(&config.database_path);
|
||||
let app = build_workspace_server_router(config, store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/api/w/{}/workspace", workspace.workspace.workspace_id);
|
||||
|
||||
let anonymous = app
|
||||
@@ -20563,6 +20710,109 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
||||
|
||||
let identity_uri = format!(
|
||||
"/api/w/{}/settings/workspace/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
);
|
||||
let identity_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&identity_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(identity_response.status(), StatusCode::OK);
|
||||
let identity_body = axum::body::to_bytes(identity_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let identity_json: serde_json::Value = serde_json::from_slice(&identity_body).unwrap();
|
||||
assert_eq!(identity_json["identity"]["state"], "active");
|
||||
assert_eq!(
|
||||
identity_json["public_bundle"]["workspace_id"],
|
||||
workspace.workspace.workspace_id
|
||||
);
|
||||
let identity_text = String::from_utf8(identity_body.to_vec()).unwrap();
|
||||
assert!(!identity_text.contains("private_key"));
|
||||
assert!(!identity_text.contains("private_material_ref"));
|
||||
let identity_non_owner = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&identity_uri)
|
||||
.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {non_owner_token}"),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(identity_non_owner.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let pending_identity = store
|
||||
.get_workspace_signing_identity(&workspace.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut(|conn| {
|
||||
conn.execute(
|
||||
r#"UPDATE workspace_signing_identities
|
||||
SET public_key = NULL, public_key_fingerprint = NULL,
|
||||
state = 'pending_provisioning', provisioned_at = NULL
|
||||
WHERE workspace_id = ?1"#,
|
||||
rusqlite::params![workspace.workspace.workspace_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_signing_identity_audit WHERE workspace_id = ?1",
|
||||
rusqlite::params![workspace.workspace.workspace_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_signing_identity_provisioning_operations WHERE workspace_id = ?1",
|
||||
rusqlite::params![workspace.workspace.workspace_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
FsWorkspaceSigningMaterialStore::new(identity_material_root)
|
||||
.delete(&pending_identity.private_material_ref)
|
||||
.unwrap();
|
||||
let provision_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("{identity_uri}/provision"))
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.header(ORIGIN, &expected_origin)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provision_response.status(), StatusCode::OK);
|
||||
let provision_body = axum::body::to_bytes(provision_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let provision_json: serde_json::Value = serde_json::from_slice(&provision_body).unwrap();
|
||||
assert_eq!(provision_json["identity"]["state"], "active");
|
||||
assert_eq!(
|
||||
provision_json["identity"]["key_id"],
|
||||
pending_identity.key_id
|
||||
);
|
||||
|
||||
let settings_uri = format!(
|
||||
"/api/w/{}/settings/workspace",
|
||||
workspace.workspace.workspace_id
|
||||
@@ -20783,7 +21033,12 @@ mod tests {
|
||||
template.static_assets_dir = Some(static_dir);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||
let catalog = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&template.database_path),
|
||||
)),
|
||||
);
|
||||
let workspace_a = catalog
|
||||
.create(
|
||||
WorkspaceCreateRequest {
|
||||
|
||||
@@ -18,11 +18,12 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 53;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 54;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations";
|
||||
const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority";
|
||||
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
@@ -40,6 +41,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: WORKSPACE_DELETION_MIGRATION_NAME,
|
||||
apply: migrate_workspace_deletion_v52_to_v53,
|
||||
},
|
||||
Migration {
|
||||
version: 54,
|
||||
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME,
|
||||
apply: migrate_workspace_signing_identity_v53_to_v54,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -135,6 +141,66 @@ pub struct WorkspaceBootstrapResult {
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceSigningIdentityRecord {
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
pub algorithm: String,
|
||||
pub public_key: Option<String>,
|
||||
pub public_key_fingerprint: Option<String>,
|
||||
pub private_material_ref: String,
|
||||
pub revision: u64,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub provisioned_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceSigningIdentityRecord {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("WorkspaceSigningIdentityRecord")
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("key_id", &self.key_id)
|
||||
.field("algorithm", &self.algorithm)
|
||||
.field("public_key", &self.public_key)
|
||||
.field("public_key_fingerprint", &self.public_key_fingerprint)
|
||||
.field("private_material_ref", &"[REDACTED]")
|
||||
.field("revision", &self.revision)
|
||||
.field("state", &self.state)
|
||||
.field("created_at", &self.created_at)
|
||||
.field("provisioned_at", &self.provisioned_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceSigningIdentityActivation {
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
pub public_key_fingerprint: String,
|
||||
pub private_material_ref: String,
|
||||
pub revision: u64,
|
||||
pub provisioned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceSigningIdentityProvisioningOperation {
|
||||
pub operation_key: String,
|
||||
pub request_fingerprint: String,
|
||||
pub operation_kind: String,
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
pub private_material_ref: String,
|
||||
pub revision: u64,
|
||||
pub actor_account_id: String,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceRuntimeBinding {
|
||||
pub workspace_id: String,
|
||||
@@ -602,7 +668,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
fn create_workspace_bootstrap(
|
||||
&self,
|
||||
record: &WorkspaceBootstrapRecord,
|
||||
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||
identity_provisioning_operation_key: &str,
|
||||
) -> Result<WorkspaceBootstrapResult>;
|
||||
fn reserve_workspace_signing_identity_provisioning(
|
||||
&self,
|
||||
operation: &WorkspaceSigningIdentityProvisioningOperation,
|
||||
) -> Result<WorkspaceSigningIdentityProvisioningOperation>;
|
||||
fn get_workspace_signing_identity(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Option<WorkspaceSigningIdentityRecord>>;
|
||||
fn activate_workspace_signing_identity(
|
||||
&self,
|
||||
activation: &WorkspaceSigningIdentityActivation,
|
||||
identity_provisioning_operation_key: &str,
|
||||
actor_account_id: &str,
|
||||
) -> Result<WorkspaceSigningIdentityRecord>;
|
||||
fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding)
|
||||
-> Result<bool>;
|
||||
async fn get_workspace_runtime_binding(
|
||||
@@ -2205,6 +2287,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) VALUES (?1, 1, 'English', ?2, ?3)"#,
|
||||
params![record.workspace_id, record.created_at, record.updated_at],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT OR IGNORE INTO workspace_signing_identities (
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
) VALUES (
|
||||
?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL,
|
||||
'workspace-signing/' || ?1 || '/ed25519-v1', 1,
|
||||
'pending_provisioning', ?2, NULL, ?3
|
||||
)"#,
|
||||
params![record.workspace_id, record.created_at, record.updated_at],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -2227,8 +2320,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn create_workspace_bootstrap(
|
||||
&self,
|
||||
record: &WorkspaceBootstrapRecord,
|
||||
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||
identity_provisioning_operation_key: &str,
|
||||
) -> Result<WorkspaceBootstrapResult> {
|
||||
validate_repository_record_identity(&record.repository)?;
|
||||
if signing_identity.workspace_id != record.workspace.workspace_id {
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
|
||||
));
|
||||
}
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let owner_kind = tx
|
||||
@@ -2272,6 +2372,43 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
params![workspace.workspace_id, record.repository.repository_key],
|
||||
read_repository_record,
|
||||
)?;
|
||||
let persisted_identity = tx.query_row(
|
||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||
public_key_fingerprint, private_material_ref, revision, state,
|
||||
created_at, provisioned_at, updated_at
|
||||
FROM workspace_signing_identities WHERE workspace_id = ?1"#,
|
||||
params![workspace.workspace_id],
|
||||
read_workspace_signing_identity,
|
||||
)?;
|
||||
if persisted_identity.workspace_id != signing_identity.workspace_id
|
||||
|| persisted_identity.key_id != signing_identity.key_id
|
||||
|| persisted_identity.public_key.as_deref()
|
||||
!= Some(signing_identity.public_key.as_str())
|
||||
|| persisted_identity.public_key_fingerprint.as_deref()
|
||||
!= Some(signing_identity.public_key_fingerprint.as_str())
|
||||
|| persisted_identity.private_material_ref
|
||||
!= signing_identity.private_material_ref
|
||||
|| persisted_identity.revision != signing_identity.revision
|
||||
|| persisted_identity.state != "active"
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace create replay signing identity does not match persisted authority"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let provisioning_state: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT state FROM workspace_signing_identity_provisioning_operations WHERE operation_key = ?1",
|
||||
params![identity_provisioning_operation_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if provisioning_state.as_deref() != Some("completed") {
|
||||
return Err(Error::Store(
|
||||
"Workspace create replay lacks completed signing identity provisioning evidence"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)?
|
||||
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
||||
.snapshot
|
||||
@@ -2370,6 +2507,58 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
.ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?
|
||||
.snapshot
|
||||
.revision;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_signing_identities (
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
) VALUES (?1, ?2, 'ed25519', ?3, ?4, ?5, ?6, 'active', ?7, ?8, ?8)"#,
|
||||
params![
|
||||
signing_identity.workspace_id,
|
||||
signing_identity.key_id,
|
||||
signing_identity.public_key,
|
||||
signing_identity.public_key_fingerprint,
|
||||
signing_identity.private_material_ref,
|
||||
signing_identity.revision,
|
||||
record.workspace.created_at,
|
||||
signing_identity.provisioned_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_signing_identity_audit (
|
||||
event_id, workspace_id, key_id, action, revision,
|
||||
public_key_fingerprint, actor_account_id, created_at
|
||||
) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#,
|
||||
params![
|
||||
uuid::Uuid::now_v7().to_string(),
|
||||
signing_identity.workspace_id,
|
||||
signing_identity.key_id,
|
||||
signing_identity.revision,
|
||||
signing_identity.public_key_fingerprint,
|
||||
record.workspace.owner_account_id,
|
||||
signing_identity.provisioned_at,
|
||||
],
|
||||
)?;
|
||||
let completed = tx.execute(
|
||||
r#"UPDATE workspace_signing_identity_provisioning_operations
|
||||
SET state = 'completed', completed_at = ?2
|
||||
WHERE operation_key = ?1 AND state = 'pending'
|
||||
AND workspace_id = ?3 AND key_id = ?4
|
||||
AND private_material_ref = ?5 AND revision = ?6"#,
|
||||
params![
|
||||
identity_provisioning_operation_key,
|
||||
signing_identity.provisioned_at,
|
||||
signing_identity.workspace_id,
|
||||
signing_identity.key_id,
|
||||
signing_identity.private_material_ref,
|
||||
signing_identity.revision,
|
||||
],
|
||||
)?;
|
||||
if completed != 1 {
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity provisioning reservation is missing or inconsistent"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_create_operations (
|
||||
operation_key, request_fingerprint, workspace_id, created_at
|
||||
@@ -2391,6 +2580,245 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn reserve_workspace_signing_identity_provisioning(
|
||||
&self,
|
||||
operation: &WorkspaceSigningIdentityProvisioningOperation,
|
||||
) -> Result<WorkspaceSigningIdentityProvisioningOperation> {
|
||||
for (label, value) in [
|
||||
("operation_key", operation.operation_key.as_str()),
|
||||
(
|
||||
"request_fingerprint",
|
||||
operation.request_fingerprint.as_str(),
|
||||
),
|
||||
("workspace_id", operation.workspace_id.as_str()),
|
||||
("key_id", operation.key_id.as_str()),
|
||||
(
|
||||
"private_material_ref",
|
||||
operation.private_material_ref.as_str(),
|
||||
),
|
||||
("actor_account_id", operation.actor_account_id.as_str()),
|
||||
] {
|
||||
validate_non_empty(label, value)?;
|
||||
}
|
||||
if !matches!(
|
||||
operation.operation_kind.as_str(),
|
||||
"workspace_create" | "existing_workspace"
|
||||
) || operation.revision == 0
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity provisioning reservation is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
if let Some(existing) = tx
|
||||
.query_row(
|
||||
r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id,
|
||||
key_id, private_material_ref, revision, actor_account_id, state,
|
||||
created_at, completed_at
|
||||
FROM workspace_signing_identity_provisioning_operations
|
||||
WHERE operation_key = ?1"#,
|
||||
params![operation.operation_key],
|
||||
read_workspace_signing_identity_provisioning_operation,
|
||||
)
|
||||
.optional()?
|
||||
{
|
||||
if existing.request_fingerprint != operation.request_fingerprint
|
||||
|| existing.operation_kind != operation.operation_kind
|
||||
{
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Workspace signing identity operation key was already used with different input"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
tx.commit()?;
|
||||
return Ok(existing);
|
||||
}
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_signing_identity_provisioning_operations (
|
||||
operation_key, request_fingerprint, operation_kind, workspace_id,
|
||||
key_id, private_material_ref, revision, actor_account_id, state,
|
||||
created_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, NULL)"#,
|
||||
params![
|
||||
operation.operation_key,
|
||||
operation.request_fingerprint,
|
||||
operation.operation_kind,
|
||||
operation.workspace_id,
|
||||
operation.key_id,
|
||||
operation.private_material_ref,
|
||||
operation.revision,
|
||||
operation.actor_account_id,
|
||||
operation.created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
if matches!(error, rusqlite::Error::SqliteFailure(_, _)) {
|
||||
Error::WorkspaceConfigConflict(
|
||||
"Workspace already has a signing identity provisioning operation"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Error::from(error)
|
||||
}
|
||||
})?;
|
||||
tx.commit()?;
|
||||
Ok(operation.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn get_workspace_signing_identity(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Option<WorkspaceSigningIdentityRecord>> {
|
||||
validate_identifier("workspace_id", workspace_id)?;
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||
public_key_fingerprint, private_material_ref, revision, state,
|
||||
created_at, provisioned_at, updated_at
|
||||
FROM workspace_signing_identities WHERE workspace_id = ?1"#,
|
||||
params![workspace_id],
|
||||
read_workspace_signing_identity,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_workspace_signing_identity(
|
||||
&self,
|
||||
activation: &WorkspaceSigningIdentityActivation,
|
||||
identity_provisioning_operation_key: &str,
|
||||
actor_account_id: &str,
|
||||
) -> Result<WorkspaceSigningIdentityRecord> {
|
||||
validate_identifier("workspace_id", &activation.workspace_id)?;
|
||||
validate_non_empty(
|
||||
"identity_provisioning_operation_key",
|
||||
identity_provisioning_operation_key,
|
||||
)?;
|
||||
validate_identifier("actor_account_id", actor_account_id)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let operation = tx
|
||||
.query_row(
|
||||
r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id,
|
||||
key_id, private_material_ref, revision, actor_account_id, state,
|
||||
created_at, completed_at
|
||||
FROM workspace_signing_identity_provisioning_operations
|
||||
WHERE operation_key = ?1"#,
|
||||
params![identity_provisioning_operation_key],
|
||||
read_workspace_signing_identity_provisioning_operation,
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(
|
||||
"Workspace signing identity provisioning operation is missing".to_string(),
|
||||
)
|
||||
})?;
|
||||
if operation.workspace_id != activation.workspace_id
|
||||
|| operation.key_id != activation.key_id
|
||||
|| operation.private_material_ref != activation.private_material_ref
|
||||
|| operation.revision != activation.revision
|
||||
|| operation.actor_account_id != actor_account_id
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity provisioning operation is inconsistent".to_string(),
|
||||
));
|
||||
}
|
||||
let existing = tx.query_row(
|
||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||
public_key_fingerprint, private_material_ref, revision, state,
|
||||
created_at, provisioned_at, updated_at
|
||||
FROM workspace_signing_identities WHERE workspace_id = ?1"#,
|
||||
params![activation.workspace_id],
|
||||
read_workspace_signing_identity,
|
||||
)?;
|
||||
if existing.key_id != activation.key_id
|
||||
|| existing.private_material_ref != activation.private_material_ref
|
||||
|| existing.revision != activation.revision
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity activation does not match persisted metadata"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if existing.state == "active" {
|
||||
if existing.public_key.as_deref() != Some(activation.public_key.as_str())
|
||||
|| existing.public_key_fingerprint.as_deref()
|
||||
!= Some(activation.public_key_fingerprint.as_str())
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity replay does not match active authority"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
r#"UPDATE workspace_signing_identity_provisioning_operations
|
||||
SET state = 'completed', completed_at = COALESCE(completed_at, ?2)
|
||||
WHERE operation_key = ?1"#,
|
||||
params![
|
||||
identity_provisioning_operation_key,
|
||||
activation.provisioned_at
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
return Ok(existing);
|
||||
}
|
||||
if existing.state != "pending_provisioning" {
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity has an unknown lifecycle state".to_string(),
|
||||
));
|
||||
}
|
||||
tx.execute(
|
||||
r#"UPDATE workspace_signing_identities
|
||||
SET public_key = ?2, public_key_fingerprint = ?3, state = 'active',
|
||||
provisioned_at = ?4, updated_at = ?4
|
||||
WHERE workspace_id = ?1 AND state = 'pending_provisioning'"#,
|
||||
params![
|
||||
activation.workspace_id,
|
||||
activation.public_key,
|
||||
activation.public_key_fingerprint,
|
||||
activation.provisioned_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_signing_identity_audit (
|
||||
event_id, workspace_id, key_id, action, revision,
|
||||
public_key_fingerprint, actor_account_id, created_at
|
||||
) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#,
|
||||
params![
|
||||
uuid::Uuid::now_v7().to_string(),
|
||||
activation.workspace_id,
|
||||
activation.key_id,
|
||||
activation.revision,
|
||||
activation.public_key_fingerprint,
|
||||
actor_account_id,
|
||||
activation.provisioned_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"UPDATE workspace_signing_identity_provisioning_operations
|
||||
SET state = 'completed', completed_at = ?2
|
||||
WHERE operation_key = ?1"#,
|
||||
params![
|
||||
identity_provisioning_operation_key,
|
||||
activation.provisioned_at
|
||||
],
|
||||
)?;
|
||||
let activated = tx.query_row(
|
||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||
public_key_fingerprint, private_material_ref, revision, state,
|
||||
created_at, provisioned_at, updated_at
|
||||
FROM workspace_signing_identities WHERE workspace_id = ?1"#,
|
||||
params![activation.workspace_id],
|
||||
read_workspace_signing_identity,
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(activated)
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_runtime_binding_matches(
|
||||
&self,
|
||||
expected: &WorkspaceRuntimeBinding,
|
||||
@@ -5806,6 +6234,46 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
|
||||
})
|
||||
}
|
||||
|
||||
fn read_workspace_signing_identity(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<WorkspaceSigningIdentityRecord> {
|
||||
let revision: i64 = row.get(6)?;
|
||||
Ok(WorkspaceSigningIdentityRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
key_id: row.get(1)?,
|
||||
algorithm: row.get(2)?,
|
||||
public_key: row.get(3)?,
|
||||
public_key_fingerprint: row.get(4)?,
|
||||
private_material_ref: row.get(5)?,
|
||||
revision: u64::try_from(revision)
|
||||
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?,
|
||||
state: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
provisioned_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_workspace_signing_identity_provisioning_operation(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<WorkspaceSigningIdentityProvisioningOperation> {
|
||||
let revision: i64 = row.get(6)?;
|
||||
Ok(WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: row.get(0)?,
|
||||
request_fingerprint: row.get(1)?,
|
||||
operation_kind: row.get(2)?,
|
||||
workspace_id: row.get(3)?,
|
||||
key_id: row.get(4)?,
|
||||
private_material_ref: row.get(5)?,
|
||||
revision: u64::try_from(revision)
|
||||
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?,
|
||||
actor_account_id: row.get(7)?,
|
||||
state: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
completed_at: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn repository_registration_intent_matches(
|
||||
existing: &RepositoryRecord,
|
||||
requested: &RepositoryRecord,
|
||||
@@ -7036,12 +7504,167 @@ fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> {
|
||||
verify_workspace_deletion_schema(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![LATEST_SCHEMA_VERSION, WORKSPACE_DELETION_MIGRATION_NAME],
|
||||
params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 53 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 53 before {WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE workspace_signing_identities (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
|
||||
public_key TEXT,
|
||||
public_key_fingerprint TEXT,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
provisioned_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
|
||||
OR
|
||||
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_provisioning_operations (
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
|
||||
workspace_id TEXT NOT NULL UNIQUE,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_audit (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX workspace_signing_identity_audit_workspace_idx
|
||||
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
|
||||
INSERT INTO workspace_signing_identities (
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
)
|
||||
SELECT workspace_id,
|
||||
'WK-' || lower(hex(randomblob(16))),
|
||||
'ed25519', NULL, NULL,
|
||||
'workspace-signing/' || workspace_id || '/ed25519-v1',
|
||||
1, 'pending_provisioning', created_at, NULL, updated_at
|
||||
FROM workspaces;
|
||||
"#,
|
||||
)?;
|
||||
verify_workspace_signing_identity_schema(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![
|
||||
LATEST_SCHEMA_VERSION,
|
||||
WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_workspace_signing_identity_schema(conn: &Connection) -> Result<()> {
|
||||
for (table, expected) in [
|
||||
(
|
||||
"workspace_signing_identities",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"key_id",
|
||||
"algorithm",
|
||||
"public_key",
|
||||
"public_key_fingerprint",
|
||||
"private_material_ref",
|
||||
"revision",
|
||||
"state",
|
||||
"created_at",
|
||||
"provisioned_at",
|
||||
"updated_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
vec![
|
||||
"operation_key",
|
||||
"request_fingerprint",
|
||||
"operation_kind",
|
||||
"workspace_id",
|
||||
"key_id",
|
||||
"private_material_ref",
|
||||
"revision",
|
||||
"actor_account_id",
|
||||
"state",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"workspace_signing_identity_audit",
|
||||
vec![
|
||||
"event_id",
|
||||
"workspace_id",
|
||||
"key_id",
|
||||
"action",
|
||||
"revision",
|
||||
"public_key_fingerprint",
|
||||
"actor_account_id",
|
||||
"created_at",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let actual = table_columns(conn, table)?
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected = expected
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<BTreeSet<_>>();
|
||||
if actual != expected {
|
||||
return Err(Error::Store(format!(
|
||||
"{table} schema does not match schema-54"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let pending_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM workspaces w LEFT JOIN workspace_signing_identities i ON i.workspace_id = w.workspace_id WHERE i.workspace_id IS NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if pending_count != 0 {
|
||||
return Err(Error::Store(
|
||||
"schema-54 failed to initialize every existing Workspace signing identity as pending"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> {
|
||||
let columns = table_columns(conn, "workspace_deletion_operations")?
|
||||
.into_iter()
|
||||
@@ -7759,7 +8382,8 @@ fn apply_migrations(conn: &Connection) -> Result<()> {
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
verify_workspace_runtime_binding_schema(conn)?;
|
||||
verify_workspace_deletion_schema(conn)
|
||||
verify_workspace_deletion_schema(conn)?;
|
||||
verify_workspace_signing_identity_schema(conn)
|
||||
}
|
||||
|
||||
fn table_exists(conn: &Connection, table_name: &str) -> Result<bool> {
|
||||
@@ -7852,6 +8476,10 @@ mod tests {
|
||||
create_latest_workspace_schema(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
DROP INDEX workspace_signing_identity_audit_workspace_idx;
|
||||
DROP TABLE workspace_signing_identity_audit;
|
||||
DROP TABLE workspace_signing_identity_provisioning_operations;
|
||||
DROP TABLE workspace_signing_identities;
|
||||
DROP INDEX workspace_deletion_operations_workspace_recent;
|
||||
DROP TABLE workspace_deletion_operations;
|
||||
CREATE TABLE worker_create_reservations_v52 (
|
||||
@@ -7969,6 +8597,10 @@ mod tests {
|
||||
version: 53,
|
||||
name: WORKSPACE_DELETION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 54,
|
||||
name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -7990,6 +8622,7 @@ mod tests {
|
||||
(51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME.to_string()),
|
||||
(52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()),
|
||||
(53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()),
|
||||
(54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -8009,6 +8642,14 @@ mod tests {
|
||||
)?,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT state FROM workspace_signing_identities WHERE workspace_id='workspace-a'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?,
|
||||
"pending_provisioning"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -8031,7 +8672,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![52, 53]
|
||||
vec![52, 53, 54]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
@@ -8039,7 +8680,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 4);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -8203,6 +8844,42 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v53_signing_identity_migration_rolls_back_on_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v52(&path);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
migrate_workspace_deletion_v52_to_v53(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE workspace_signing_identity_audit (unexpected TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
let before = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(migrate_workspace_signing_identity_v53_to_v54(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 53);
|
||||
assert!(!table_exists(&conn, "workspace_signing_identities").unwrap());
|
||||
assert!(
|
||||
!table_exists(&conn, "workspace_signing_identity_provisioning_operations").unwrap()
|
||||
);
|
||||
let after = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v52_workspace_deletion_migration_rolls_back_on_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -8251,6 +8928,12 @@ mod tests {
|
||||
VALUES
|
||||
('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'),
|
||||
('workspace-b', 'owner', 'Workspace B', 'active', '1', '1');
|
||||
INSERT INTO workspace_signing_identities(
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'WK-a', 'ed25519', NULL, NULL, 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'),
|
||||
('workspace-b', 'WK-b', 'ed25519', NULL, NULL, 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1');
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -9647,13 +10330,13 @@ INSERT INTO worker_registry (
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (54, 'future')",
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (55, 'future')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||
assert!(error.contains("schema version 54 is newer"), "{error}");
|
||||
assert!(error.contains("schema version 55 is newer"), "{error}");
|
||||
assert!(error.contains("refusing to serve"), "{error}");
|
||||
}
|
||||
|
||||
@@ -9695,6 +10378,14 @@ INSERT INTO accounts (account_id, kind, handle, display_name, created_at, update
|
||||
VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', '2026-01-01', '2026-01-01');
|
||||
INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at)
|
||||
VALUES ('workspace-a', 'owner-account', 'A', 'active', '2026-01-01', '2026-01-01');
|
||||
INSERT INTO workspace_signing_identities (
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
) VALUES (
|
||||
'workspace-a', 'WK-a', 'ed25519', NULL, NULL,
|
||||
'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning',
|
||||
'2026-01-01', NULL, '2026-01-01'
|
||||
);
|
||||
INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
@@ -9717,6 +10408,14 @@ DELETE FROM typed_tickets
|
||||
WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a';
|
||||
INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at)
|
||||
VALUES ('workspace-b', 'owner-account', 'B', 'active', '2026-01-01', '2026-01-01');
|
||||
INSERT INTO workspace_signing_identities (
|
||||
workspace_id, key_id, algorithm, public_key, public_key_fingerprint,
|
||||
private_material_ref, revision, state, created_at, provisioned_at, updated_at
|
||||
) VALUES (
|
||||
'workspace-b', 'WK-b', 'ed25519', NULL, NULL,
|
||||
'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning',
|
||||
'2026-01-01', NULL, '2026-01-01'
|
||||
);
|
||||
INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
@@ -9800,13 +10499,26 @@ INSERT INTO worker_registry (
|
||||
updated_at: "1".to_string(),
|
||||
};
|
||||
|
||||
let signing_identity = WorkspaceSigningIdentityActivation {
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
key_id: "WK-store-test".to_string(),
|
||||
public_key: "test-public-key".to_string(),
|
||||
public_key_fingerprint: "sha256:test-public-key".to_string(),
|
||||
private_material_ref: "workspace-signing/store-test/ed25519-v1".to_string(),
|
||||
revision: 1,
|
||||
provisioned_at: "1".to_string(),
|
||||
};
|
||||
let error = store
|
||||
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
||||
operation_key: "invalid-key".to_string(),
|
||||
request_fingerprint: "sha256:invalid-key".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
repository,
|
||||
})
|
||||
.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key: "invalid-key".to_string(),
|
||||
request_fingerprint: "sha256:invalid-key".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
repository,
|
||||
},
|
||||
&signing_identity,
|
||||
"identity-store-test",
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
@@ -9843,12 +10555,34 @@ INSERT INTO worker_registry (
|
||||
workspace,
|
||||
repository: valid_repository,
|
||||
};
|
||||
assert!(!store.create_workspace_bootstrap(&first).unwrap().replayed);
|
||||
store
|
||||
.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: "identity-store-test".to_string(),
|
||||
request_fingerprint: "sha256:create-workspace".to_string(),
|
||||
operation_kind: "workspace_create".to_string(),
|
||||
workspace_id: first.workspace.workspace_id.clone(),
|
||||
key_id: signing_identity.key_id.clone(),
|
||||
private_material_ref: signing_identity.private_material_ref.clone(),
|
||||
revision: signing_identity.revision,
|
||||
actor_account_id: first.workspace.owner_account_id.clone(),
|
||||
state: "pending".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
completed_at: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store
|
||||
.create_workspace_bootstrap(&first, &signing_identity, "identity-store-test")
|
||||
.unwrap()
|
||||
.replayed
|
||||
);
|
||||
let mut duplicate = first;
|
||||
duplicate.operation_key = "duplicate-workspace".to_string();
|
||||
duplicate.repository.repository_id = Uuid::now_v7().to_string();
|
||||
let error = store
|
||||
.create_workspace_bootstrap(&duplicate)
|
||||
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
|
||||
@@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger
|
||||
use crate::store::{
|
||||
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::workspace_signing_identity::{
|
||||
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_DISPLAY_NAME_BYTES: usize = 200;
|
||||
@@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult {
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceCatalogService {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
}
|
||||
|
||||
impl WorkspaceCatalogService {
|
||||
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
|
||||
Self { store }
|
||||
pub fn new(
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
signing_identities: WorkspaceSigningIdentityService::new(
|
||||
store.clone(),
|
||||
signing_materials,
|
||||
),
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> Result<bool> {
|
||||
@@ -118,7 +131,7 @@ impl WorkspaceCatalogService {
|
||||
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
|
||||
})
|
||||
.transpose()?;
|
||||
let workspace_id = requested_workspace_id
|
||||
let proposed_workspace_id = requested_workspace_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
||||
let fingerprint = workspace_create_fingerprint(
|
||||
@@ -130,9 +143,16 @@ impl WorkspaceCatalogService {
|
||||
&default_ref,
|
||||
);
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let result = self
|
||||
.store
|
||||
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
||||
let (signing_identity, identity_provisioning_operation_key) =
|
||||
self.signing_identities.prepare_workspace_creation(
|
||||
&operation_key,
|
||||
&fingerprint,
|
||||
&proposed_workspace_id,
|
||||
&owner_account_id,
|
||||
)?;
|
||||
let workspace_id = signing_identity.workspace_id.clone();
|
||||
let result = self.store.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key,
|
||||
request_fingerprint: fingerprint.clone(),
|
||||
workspace: WorkspaceRecord {
|
||||
@@ -158,7 +178,10 @@ impl WorkspaceCatalogService {
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
},
|
||||
})?;
|
||||
},
|
||||
&signing_identity,
|
||||
&identity_provisioning_operation_key,
|
||||
)?;
|
||||
Ok(WorkspaceCreateResult {
|
||||
workspace: result.workspace,
|
||||
repository: result.repository,
|
||||
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
||||
use crate::workspace_signing_identity::{
|
||||
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
|
||||
WorkspaceSigningPrivateMaterial, identity_error,
|
||||
};
|
||||
use workspace_api::RepositorySourceKind;
|
||||
|
||||
struct FailFirstMaterialWrite {
|
||||
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||
fail: AtomicBool,
|
||||
}
|
||||
|
||||
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
|
||||
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||
self.inner.load(material_ref)
|
||||
}
|
||||
|
||||
fn put_if_absent(
|
||||
&self,
|
||||
material_ref: &str,
|
||||
material: &WorkspaceSigningPrivateMaterial,
|
||||
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||
if self.fail.swap(false, Ordering::SeqCst) {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_io_failed",
|
||||
"injected private material write failure",
|
||||
));
|
||||
}
|
||||
self.inner.put_if_absent(material_ref, material)
|
||||
}
|
||||
|
||||
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||
self.inner.delete(material_ref)
|
||||
}
|
||||
}
|
||||
|
||||
fn git_repository() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||
@@ -243,7 +301,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn create_is_atomic_and_exact_retries_converge() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let service = WorkspaceCatalogService::new(store.clone());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "request-1".to_string(),
|
||||
@@ -268,6 +331,14 @@ mod tests {
|
||||
replayed.workspace.workspace_id
|
||||
);
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
let signing_identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.expect("new Workspace signing identity");
|
||||
assert_eq!(signing_identity.state, "active");
|
||||
assert_eq!(signing_identity.algorithm, "ed25519");
|
||||
assert!(signing_identity.public_key.is_some());
|
||||
assert!(signing_identity.public_key_fingerprint.is_some());
|
||||
assert_eq!(
|
||||
store
|
||||
.list_repositories(&created.workspace.workspace_id)
|
||||
@@ -283,11 +354,137 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(FailFirstMaterialWrite {
|
||||
inner: materials.clone(),
|
||||
fail: AtomicBool::new(true),
|
||||
}),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "material-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let reserved_key = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "db-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
||||
BEFORE INSERT ON workspace_signing_identity_audit
|
||||
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let (reserved_key, material_ref) = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
|
||||
[],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(materials.load(&material_ref).unwrap().is_some());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(identity.state, "active");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let service = WorkspaceCatalogService::new(store);
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store,
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let mut request = WorkspaceCreateRequest {
|
||||
operation_key: "request-1".to_string(),
|
||||
@@ -338,7 +535,12 @@ mod tests {
|
||||
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let service = WorkspaceCatalogService::new(store);
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store,
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let error = service
|
||||
.create(
|
||||
@@ -373,7 +575,12 @@ mod tests {
|
||||
updated_at: "2026-07-03T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let service = WorkspaceCatalogService::new(store);
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store,
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let repository_a = git_repository();
|
||||
let repository_b = git_repository();
|
||||
let created_a = service
|
||||
@@ -425,7 +632,12 @@ mod tests {
|
||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let service = WorkspaceCatalogService::new(store.clone());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(
|
||||
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||
),
|
||||
);
|
||||
let result = service
|
||||
.create(
|
||||
WorkspaceCreateRequest {
|
||||
|
||||
@@ -80,6 +80,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_resource_keys",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_audit",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_worker_retention_policies",
|
||||
"workspace_worker_retention_policy_revisions",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
use std::fmt;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use ring::signature::KeyPair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkspaceSigningIdentityActivation,
|
||||
WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
|
||||
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
|
||||
const MATERIAL_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSigningPrivateMaterial {
|
||||
version: u32,
|
||||
workspace_id: String,
|
||||
key_id: String,
|
||||
revision: u64,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for WorkspaceSigningPrivateMaterial {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("WorkspaceSigningPrivateMaterial")
|
||||
.field("version", &self.version)
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("key_id", &self.key_id)
|
||||
.field("revision", &self.revision)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceSigningPrivateMaterial {
|
||||
fn drop(&mut self) {
|
||||
self.private_key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceSigningPrivateMaterial {
|
||||
pub fn generate(workspace_id: &str, key_id: &str) -> Result<Self> {
|
||||
let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_generation_failed",
|
||||
format!("failed to generate Workspace signing identity: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
version: MATERIAL_SCHEMA_VERSION,
|
||||
workspace_id: workspace_id.to_string(),
|
||||
key_id: key_id.to_string(),
|
||||
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
|
||||
private_key: material.private_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn signing_key(
|
||||
&self,
|
||||
expected_workspace_id: &str,
|
||||
expected_key_id: &str,
|
||||
expected_revision: u64,
|
||||
) -> Result<ring::signature::Ed25519KeyPair> {
|
||||
if self.version != MATERIAL_SCHEMA_VERSION
|
||||
|| self.workspace_id != expected_workspace_id
|
||||
|| self.key_id != expected_key_id
|
||||
|| self.revision != expected_revision
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing private material does not match its persisted metadata",
|
||||
));
|
||||
}
|
||||
RuntimeIdentityMaterial {
|
||||
identity_id: self.key_id.clone(),
|
||||
public_key: String::new(),
|
||||
private_key: self.private_key.clone(),
|
||||
}
|
||||
.signing_key()
|
||||
.map_err(|_| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_corrupt",
|
||||
"Workspace signing private material is corrupt",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_and_public_key(
|
||||
&self,
|
||||
expected_workspace_id: &str,
|
||||
expected_key_id: &str,
|
||||
expected_revision: u64,
|
||||
) -> Result<String> {
|
||||
let signing_key =
|
||||
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
|
||||
Ok(encode_public_key(signing_key.public_key().as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WorkspaceSigningMaterialStore: Send + Sync {
|
||||
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>>;
|
||||
fn put_if_absent(
|
||||
&self,
|
||||
material_ref: &str,
|
||||
material: &WorkspaceSigningPrivateMaterial,
|
||||
) -> Result<WorkspaceSigningPrivateMaterial>;
|
||||
fn delete(&self, material_ref: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceSigningIdentityService {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
materials: Arc<dyn WorkspaceSigningMaterialStore>,
|
||||
}
|
||||
|
||||
impl WorkspaceSigningIdentityService {
|
||||
pub fn new(
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
materials: Arc<dyn WorkspaceSigningMaterialStore>,
|
||||
) -> Self {
|
||||
Self { store, materials }
|
||||
}
|
||||
|
||||
pub fn prepare_workspace_creation(
|
||||
&self,
|
||||
workspace_create_operation_key: &str,
|
||||
request_fingerprint: &str,
|
||||
proposed_workspace_id: &str,
|
||||
actor_account_id: &str,
|
||||
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
|
||||
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
|
||||
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
|
||||
let operation = self.store.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: operation_key.clone(),
|
||||
request_fingerprint: request_fingerprint.to_string(),
|
||||
operation_kind: "workspace_create".to_string(),
|
||||
workspace_id: proposed_workspace_id.to_string(),
|
||||
key_id: proposed_key_id,
|
||||
private_material_ref: proposed_material_ref,
|
||||
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
|
||||
actor_account_id: actor_account_id.to_string(),
|
||||
state: "pending".to_string(),
|
||||
created_at: now,
|
||||
completed_at: None,
|
||||
},
|
||||
)?;
|
||||
let activation = self.prepare_material(&operation)?;
|
||||
Ok((activation, operation.operation_key))
|
||||
}
|
||||
|
||||
pub fn provision_existing(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
actor_account_id: &str,
|
||||
) -> Result<WorkspaceSigningIdentityRecord> {
|
||||
let identity = self
|
||||
.store
|
||||
.get_workspace_signing_identity(workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_metadata_missing",
|
||||
"Workspace signing identity metadata is missing",
|
||||
)
|
||||
})?;
|
||||
if identity.state == "active" {
|
||||
self.validate_active_material(&identity)?;
|
||||
return Ok(identity);
|
||||
}
|
||||
if identity.state != "pending_provisioning" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_state_invalid",
|
||||
"Workspace signing identity state is invalid",
|
||||
));
|
||||
}
|
||||
let operation_key = format!(
|
||||
"existing-workspace:{workspace_id}:revision-{}",
|
||||
identity.revision
|
||||
);
|
||||
let request_fingerprint =
|
||||
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
|
||||
let operation = self.store.reserve_workspace_signing_identity_provisioning(
|
||||
&WorkspaceSigningIdentityProvisioningOperation {
|
||||
operation_key: operation_key.clone(),
|
||||
request_fingerprint,
|
||||
operation_kind: "existing_workspace".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
key_id: identity.key_id.clone(),
|
||||
private_material_ref: identity.private_material_ref.clone(),
|
||||
revision: identity.revision,
|
||||
actor_account_id: actor_account_id.to_string(),
|
||||
state: "pending".to_string(),
|
||||
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
completed_at: None,
|
||||
},
|
||||
)?;
|
||||
let activation = self.prepare_material(&operation)?;
|
||||
self.store.activate_workspace_signing_identity(
|
||||
&activation,
|
||||
&operation.operation_key,
|
||||
&operation.actor_account_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_validated(&self, workspace_id: &str) -> Result<WorkspaceSigningIdentityRecord> {
|
||||
let identity = self
|
||||
.store
|
||||
.get_workspace_signing_identity(workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_metadata_missing",
|
||||
"Workspace signing identity metadata is missing",
|
||||
)
|
||||
})?;
|
||||
if identity.state == "active" {
|
||||
self.validate_active_material(&identity)?;
|
||||
}
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let identity = self.get_validated(workspace_id)?;
|
||||
if identity.state != "active" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_not_provisioned",
|
||||
"Workspace signing identity is not provisioned",
|
||||
));
|
||||
}
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_missing",
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let signing_key =
|
||||
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
|
||||
Ok(signing_key.sign(payload).as_ref().to_vec())
|
||||
}
|
||||
|
||||
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
|
||||
if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? {
|
||||
self.materials.delete(&identity.private_material_ref)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_material(
|
||||
&self,
|
||||
operation: &WorkspaceSigningIdentityProvisioningOperation,
|
||||
) -> Result<WorkspaceSigningIdentityActivation> {
|
||||
let material = match self.materials.load(&operation.private_material_ref)? {
|
||||
Some(material) => material,
|
||||
None => {
|
||||
let generated = WorkspaceSigningPrivateMaterial::generate(
|
||||
&operation.workspace_id,
|
||||
&operation.key_id,
|
||||
)?;
|
||||
self.materials
|
||||
.put_if_absent(&operation.private_material_ref, &generated)?
|
||||
}
|
||||
};
|
||||
let public_key = material.validate_and_public_key(
|
||||
&operation.workspace_id,
|
||||
&operation.key_id,
|
||||
operation.revision,
|
||||
)?;
|
||||
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
|
||||
Ok(WorkspaceSigningIdentityActivation {
|
||||
workspace_id: operation.workspace_id.clone(),
|
||||
key_id: operation.key_id.clone(),
|
||||
public_key,
|
||||
public_key_fingerprint,
|
||||
private_material_ref: operation.private_material_ref.clone(),
|
||||
revision: operation.revision,
|
||||
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_missing",
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let public_key = material.validate_and_public_key(
|
||||
&identity.workspace_id,
|
||||
&identity.key_id,
|
||||
identity.revision,
|
||||
)?;
|
||||
let fingerprint = public_key_fingerprint(&public_key)?;
|
||||
if identity.public_key.as_deref() != Some(public_key.as_str())
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(workspace_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(key_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(revision.to_be_bytes());
|
||||
format!("sha256:{}", hex_lower(&hasher.finalize()))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryWorkspaceSigningMaterialStore {
|
||||
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
|
||||
}
|
||||
|
||||
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
|
||||
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||
Ok(self
|
||||
.materials
|
||||
.lock()
|
||||
.expect("identity material store lock")
|
||||
.get(material_ref)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn put_if_absent(
|
||||
&self,
|
||||
material_ref: &str,
|
||||
material: &WorkspaceSigningPrivateMaterial,
|
||||
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||
let mut materials = self.materials.lock().expect("identity material store lock");
|
||||
Ok(materials
|
||||
.entry(material_ref.to_string())
|
||||
.or_insert_with(|| material.clone())
|
||||
.clone())
|
||||
}
|
||||
|
||||
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||
self.materials
|
||||
.lock()
|
||||
.expect("identity material store lock")
|
||||
.remove(material_ref);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FsWorkspaceSigningMaterialStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsWorkspaceSigningMaterialStore {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
fn material_path(&self, material_ref: &str) -> Result<PathBuf> {
|
||||
let relative = Path::new(material_ref);
|
||||
if relative.as_os_str().is_empty()
|
||||
|| relative.is_absolute()
|
||||
|| relative.components().any(|component| {
|
||||
!matches!(component, Component::Normal(_))
|
||||
|| component.as_os_str().to_string_lossy().starts_with('.')
|
||||
})
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_ref_invalid",
|
||||
"Workspace signing private material reference is invalid",
|
||||
));
|
||||
}
|
||||
Ok(self.root.join(relative).with_extension("json"))
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
|
||||
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
|
||||
let path = self.material_path(material_ref)?;
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(material_io_error("read", error)),
|
||||
};
|
||||
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_corrupt",
|
||||
"Workspace signing private material is corrupt",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn put_if_absent(
|
||||
&self,
|
||||
material_ref: &str,
|
||||
material: &WorkspaceSigningPrivateMaterial,
|
||||
) -> Result<WorkspaceSigningPrivateMaterial> {
|
||||
let path = self.material_path(material_ref)?;
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_ref_invalid",
|
||||
"Workspace signing private material reference has no parent",
|
||||
)
|
||||
})?;
|
||||
ensure_private_tree(&self.root, parent)?;
|
||||
|
||||
let mut bytes = serde_json::to_vec(material).map_err(|_| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_encode_failed",
|
||||
"Workspace signing private material could not be encoded",
|
||||
)
|
||||
})?;
|
||||
let temporary = parent.join(format!(
|
||||
".workspace-signing-{}.tmp",
|
||||
uuid::Uuid::now_v7().simple()
|
||||
));
|
||||
let write_result = (|| -> Result<()> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(&temporary)
|
||||
.map_err(|error| material_io_error("create", error))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| material_io_error("write", error))?;
|
||||
match fs::hard_link(&temporary, &path) {
|
||||
Ok(()) => sync_directory(parent),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(error) => Err(material_io_error("publish", error)),
|
||||
}
|
||||
})();
|
||||
bytes.zeroize();
|
||||
let cleanup_result = match fs::remove_file(&temporary) {
|
||||
Ok(()) => sync_directory(parent),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(material_io_error("remove temporary", error)),
|
||||
};
|
||||
write_result?;
|
||||
cleanup_result?;
|
||||
self.load(material_ref)?.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_missing",
|
||||
"Workspace signing private material is missing after publication",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(&self, material_ref: &str) -> Result<()> {
|
||||
let path = self.material_path(material_ref)?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_ref_invalid",
|
||||
"Workspace signing private material reference has no parent",
|
||||
)
|
||||
})?;
|
||||
sync_directory(parent)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(material_io_error("delete", error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
|
||||
ensure_private_directory(root)?;
|
||||
if let Some(parent) = root.parent() {
|
||||
sync_directory(parent)?;
|
||||
}
|
||||
let relative = leaf.strip_prefix(root).map_err(|_| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_ref_invalid",
|
||||
"Workspace signing private material path escapes its authority root",
|
||||
)
|
||||
})?;
|
||||
let mut current = root.to_path_buf();
|
||||
for component in relative.components() {
|
||||
let parent = current.clone();
|
||||
current.push(component);
|
||||
ensure_private_directory(¤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>;
|
||||
};
|
||||
|
||||
export type WorkspaceSigningIdentityState = "pending_provisioning" | "active";
|
||||
|
||||
export type WorkspaceSigningIdentityPublic = {
|
||||
workspace_id: string;
|
||||
key_id: string;
|
||||
algorithm: string;
|
||||
public_key?: string;
|
||||
public_key_fingerprint?: string;
|
||||
revision: number;
|
||||
state: WorkspaceSigningIdentityState;
|
||||
created_at: string;
|
||||
provisioned_at?: string;
|
||||
};
|
||||
|
||||
export type WorkspacePublicIdentityBundle = {
|
||||
workspace_id: string;
|
||||
backend_url: string;
|
||||
key_id: string;
|
||||
algorithm: string;
|
||||
public_key: string;
|
||||
public_key_fingerprint: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export type WorkspaceSigningIdentityResponse = {
|
||||
identity: WorkspaceSigningIdentityPublic;
|
||||
public_bundle?: WorkspacePublicIdentityBundle;
|
||||
};
|
||||
|
||||
export type ProfileSettingsResponse = {
|
||||
workspace_id: string;
|
||||
registry_revision: string;
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
WorkspaceProfileSourceProvenance,
|
||||
WorkspaceProfileSourceSummary,
|
||||
WorkspaceProfileSummary,
|
||||
WorkspacePublicIdentityBundle,
|
||||
WorkspaceSigningIdentityPublic,
|
||||
WorkspaceSigningIdentityResponse,
|
||||
WorkspaceSigningIdentityState,
|
||||
} from "$lib/generated/workspace-api";
|
||||
|
||||
export class ProfileApiError extends Error {
|
||||
@@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedStringValue(
|
||||
value: unknown,
|
||||
context: string,
|
||||
maxBytes: number,
|
||||
): string {
|
||||
const text = stringValue(value, context);
|
||||
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
||||
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, context: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
|
||||
@@ -66,6 +82,15 @@ function optionalString(
|
||||
return stringValue(value, context);
|
||||
}
|
||||
|
||||
function optionalBoundedString(
|
||||
value: unknown,
|
||||
context: string,
|
||||
maxBytes: number,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return boundedStringValue(value, context, maxBytes);
|
||||
}
|
||||
|
||||
function optionalRevision(
|
||||
value: unknown,
|
||||
context: string,
|
||||
@@ -286,6 +311,197 @@ export function parseProfileSettingsResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceSigningIdentityResponse(
|
||||
value: unknown,
|
||||
): WorkspaceSigningIdentityResponse {
|
||||
const item = record(value, "Workspace signing identity");
|
||||
exactKeys(
|
||||
item,
|
||||
["identity"],
|
||||
["public_bundle"],
|
||||
"Workspace signing identity",
|
||||
);
|
||||
const identityItem = record(item.identity, "Workspace signing identity");
|
||||
exactKeys(
|
||||
identityItem,
|
||||
["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"],
|
||||
["public_key", "public_key_fingerprint", "provisioned_at"],
|
||||
"Workspace signing identity",
|
||||
);
|
||||
const state = boundedStringValue(
|
||||
identityItem.state,
|
||||
"Workspace signing identity",
|
||||
32,
|
||||
);
|
||||
if (state !== "pending_provisioning" && state !== "active") {
|
||||
throw new ProfileApiError(
|
||||
"Workspace signing identity returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const revision = optionalRevision(
|
||||
identityItem.revision,
|
||||
"Workspace signing identity",
|
||||
);
|
||||
if (revision === undefined || revision === null || revision < 1) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace signing identity returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const publicKey = optionalBoundedString(
|
||||
identityItem.public_key,
|
||||
"Workspace signing identity",
|
||||
256,
|
||||
);
|
||||
const fingerprint = optionalBoundedString(
|
||||
identityItem.public_key_fingerprint,
|
||||
"Workspace signing identity",
|
||||
128,
|
||||
);
|
||||
const provisionedAt = optionalBoundedString(
|
||||
identityItem.provisioned_at,
|
||||
"Workspace signing identity",
|
||||
128,
|
||||
);
|
||||
const identity: WorkspaceSigningIdentityPublic = {
|
||||
workspace_id: boundedStringValue(
|
||||
identityItem.workspace_id,
|
||||
"Workspace signing identity",
|
||||
128,
|
||||
),
|
||||
key_id: boundedStringValue(
|
||||
identityItem.key_id,
|
||||
"Workspace signing identity",
|
||||
128,
|
||||
),
|
||||
algorithm: boundedStringValue(
|
||||
identityItem.algorithm,
|
||||
"Workspace signing identity",
|
||||
32,
|
||||
),
|
||||
...(publicKey === undefined || publicKey === null
|
||||
? {}
|
||||
: { public_key: publicKey }),
|
||||
...(fingerprint === undefined || fingerprint === null
|
||||
? {}
|
||||
: { public_key_fingerprint: fingerprint }),
|
||||
revision,
|
||||
state: state as WorkspaceSigningIdentityState,
|
||||
created_at: boundedStringValue(
|
||||
identityItem.created_at,
|
||||
"Workspace signing identity",
|
||||
128,
|
||||
),
|
||||
...(provisionedAt === undefined || provisionedAt === null
|
||||
? {}
|
||||
: { provisioned_at: provisionedAt }),
|
||||
};
|
||||
|
||||
let publicBundle: WorkspacePublicIdentityBundle | undefined;
|
||||
if (item.public_bundle !== undefined) {
|
||||
const bundle = record(
|
||||
item.public_bundle,
|
||||
"Workspace public identity bundle",
|
||||
);
|
||||
exactKeys(
|
||||
bundle,
|
||||
[
|
||||
"workspace_id",
|
||||
"backend_url",
|
||||
"key_id",
|
||||
"algorithm",
|
||||
"public_key",
|
||||
"public_key_fingerprint",
|
||||
"revision",
|
||||
],
|
||||
[],
|
||||
"Workspace public identity bundle",
|
||||
);
|
||||
const bundleRevision = optionalRevision(
|
||||
bundle.revision,
|
||||
"Workspace public identity bundle",
|
||||
);
|
||||
if (
|
||||
bundleRevision === undefined || bundleRevision === null ||
|
||||
bundleRevision < 1
|
||||
) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace public identity bundle returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
publicBundle = {
|
||||
workspace_id: boundedStringValue(
|
||||
bundle.workspace_id,
|
||||
"Workspace public identity bundle",
|
||||
128,
|
||||
),
|
||||
backend_url: boundedStringValue(
|
||||
bundle.backend_url,
|
||||
"Workspace public identity bundle",
|
||||
2048,
|
||||
),
|
||||
key_id: boundedStringValue(
|
||||
bundle.key_id,
|
||||
"Workspace public identity bundle",
|
||||
128,
|
||||
),
|
||||
algorithm: boundedStringValue(
|
||||
bundle.algorithm,
|
||||
"Workspace public identity bundle",
|
||||
32,
|
||||
),
|
||||
public_key: boundedStringValue(
|
||||
bundle.public_key,
|
||||
"Workspace public identity bundle",
|
||||
256,
|
||||
),
|
||||
public_key_fingerprint: boundedStringValue(
|
||||
bundle.public_key_fingerprint,
|
||||
"Workspace public identity bundle",
|
||||
128,
|
||||
),
|
||||
revision: bundleRevision,
|
||||
};
|
||||
}
|
||||
if (
|
||||
publicBundle !== undefined &&
|
||||
(
|
||||
publicBundle.workspace_id !== identity.workspace_id ||
|
||||
publicBundle.key_id !== identity.key_id ||
|
||||
publicBundle.algorithm !== identity.algorithm ||
|
||||
publicBundle.public_key !== identity.public_key ||
|
||||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ||
|
||||
publicBundle.revision !== identity.revision
|
||||
)
|
||||
) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace public identity bundle does not match identity metadata.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(state === "active" &&
|
||||
(publicBundle === undefined || identity.public_key === undefined ||
|
||||
identity.public_key_fingerprint === undefined ||
|
||||
identity.provisioned_at === undefined)) ||
|
||||
(state === "pending_provisioning" &&
|
||||
(publicBundle !== undefined || identity.public_key !== undefined ||
|
||||
identity.public_key_fingerprint !== undefined ||
|
||||
identity.provisioned_at !== undefined))
|
||||
) {
|
||||
throw new ProfileApiError(
|
||||
"Workspace signing identity returned an invalid response.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
return {
|
||||
identity,
|
||||
...(publicBundle === undefined ? {} : { public_bundle: publicBundle }),
|
||||
};
|
||||
}
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
parser: (value: unknown) => T,
|
||||
@@ -325,6 +541,33 @@ export async function updateWorkspaceMetadata(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSigningIdentity(
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceSigningIdentityResponse> {
|
||||
return await parseResponse(
|
||||
await fetch(
|
||||
`/api/w/${
|
||||
encodeURIComponent(workspaceId)
|
||||
}/settings/workspace/signing-identity`,
|
||||
),
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export async function provisionWorkspaceSigningIdentity(
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceSigningIdentityResponse> {
|
||||
return await parseResponse(
|
||||
await fetch(
|
||||
`/api/w/${
|
||||
encodeURIComponent(workspaceId)
|
||||
}/settings/workspace/signing-identity/provision`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProfileSettings(
|
||||
workspaceId: string,
|
||||
): Promise<ProfileSettingsResponse> {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
WorkspaceDeletionPreflightResponse,
|
||||
WorkspaceDeletionRequest,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceSigningIdentityResponse,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -18,6 +19,8 @@
|
||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||
import {
|
||||
fetchWorkspaceMetadata,
|
||||
fetchWorkspaceSigningIdentity,
|
||||
provisionWorkspaceSigningIdentity,
|
||||
updateWorkspaceMetadata,
|
||||
} from '$lib/workspace/settings/profile-api';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -26,6 +29,14 @@
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
||||
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
|
||||
let identityLoading = $state(true);
|
||||
let identityError = $state<string | null>(null);
|
||||
let provisioningIdentity = $state(false);
|
||||
let identityCopied = $state(false);
|
||||
let identityBundleText = $derived(
|
||||
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
|
||||
);
|
||||
let displayNameDraft = $state('');
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
@@ -58,6 +69,17 @@
|
||||
workspaceMetadata = response;
|
||||
displayNameDraft = response.display_name;
|
||||
diagnostics = response.diagnostics;
|
||||
if (data.workspace?.permissions.delete_workspace) {
|
||||
try {
|
||||
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
|
||||
} finally {
|
||||
identityLoading = false;
|
||||
}
|
||||
} else {
|
||||
identityLoading = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -93,6 +115,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionIdentity() {
|
||||
provisioningIdentity = true;
|
||||
identityError = null;
|
||||
try {
|
||||
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
|
||||
} finally {
|
||||
provisioningIdentity = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIdentityBundle() {
|
||||
const bundle = signingIdentity?.public_bundle;
|
||||
if (!bundle) return;
|
||||
identityCopied = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
|
||||
identityCopied = true;
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeletionConfirmation() {
|
||||
deletionOpen = true;
|
||||
deletionLoading = true;
|
||||
@@ -232,6 +278,52 @@
|
||||
</section>
|
||||
|
||||
{#if data.workspace?.permissions.delete_workspace}
|
||||
<section class="settings-section" aria-labelledby="workspace-identity-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="workspace-identity-title">Workspace public identity</h2>
|
||||
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
|
||||
</div>
|
||||
{#if signingIdentity?.public_bundle}
|
||||
<button type="button" onclick={() => void copyIdentityBundle()}>
|
||||
{identityCopied ? 'Copied' : 'Copy bundle'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if identityError}
|
||||
<p class="status-message error">{identityError}</p>
|
||||
{/if}
|
||||
{#if identityLoading}
|
||||
<p>Loading identity…</p>
|
||||
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
|
||||
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={provisioningIdentity}
|
||||
onclick={() => void provisionIdentity()}
|
||||
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
|
||||
{:else if signingIdentity?.public_bundle}
|
||||
<dl class="metadata-list">
|
||||
<div>
|
||||
<dt>Key</dt>
|
||||
<dd><code>{signingIdentity.identity.key_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fingerprint</dt>
|
||||
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd><code>{signingIdentity.identity.revision}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label class="identity-bundle">
|
||||
<span>Public identity bundle</span>
|
||||
<textarea readonly rows="9" value={identityBundleText}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
|
||||
<div>
|
||||
<h2 id="workspace-danger-title">Danger zone</h2>
|
||||
@@ -286,6 +378,13 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
|
||||
.section-heading p { margin-block: var(--space-1) 0; }
|
||||
.metadata-list { display: grid; gap: var(--space-2); }
|
||||
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
|
||||
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
|
||||
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
|
||||
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
|
||||
.danger-zone p { max-width: 68ch; }
|
||||
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
fetchWorkspaceMetadata,
|
||||
parseProfileSettingsResponse,
|
||||
parseWorkspaceMetadataSettingsResponse,
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
ProfileApiError,
|
||||
updateWorkspaceMetadata,
|
||||
} from "../src/lib/workspace/settings/profile-api.ts";
|
||||
@@ -170,6 +171,78 @@ Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Workspace signing identity parser validates active and pending public contracts", () => {
|
||||
const active = {
|
||||
identity: {
|
||||
workspace_id: "workspace-1",
|
||||
key_id: "WK-1",
|
||||
algorithm: "ed25519",
|
||||
public_key: "public-key",
|
||||
public_key_fingerprint: "sha256:fingerprint",
|
||||
revision: 1,
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
provisioned_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
public_bundle: {
|
||||
workspace_id: "workspace-1",
|
||||
backend_url: "https://backend.example.test",
|
||||
key_id: "WK-1",
|
||||
algorithm: "ed25519",
|
||||
public_key: "public-key",
|
||||
public_key_fingerprint: "sha256:fingerprint",
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
parseWorkspaceSigningIdentityResponse(active).public_bundle?.key_id,
|
||||
"WK-1",
|
||||
);
|
||||
assertEquals(
|
||||
parseWorkspaceSigningIdentityResponse({
|
||||
identity: {
|
||||
workspace_id: "workspace-1",
|
||||
key_id: "WK-1",
|
||||
algorithm: "ed25519",
|
||||
revision: 1,
|
||||
state: "pending_provisioning",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
}).public_bundle,
|
||||
undefined,
|
||||
);
|
||||
|
||||
for (
|
||||
const mutate of [
|
||||
(value: Record<string, unknown>) => {
|
||||
value.private_material_ref = "must-not-be-accepted";
|
||||
},
|
||||
(value: Record<string, unknown>) => {
|
||||
(value.identity as Record<string, unknown>).revision =
|
||||
Number.MAX_SAFE_INTEGER + 1;
|
||||
},
|
||||
(value: Record<string, unknown>) => {
|
||||
(value.identity as Record<string, unknown>).public_key = "x".repeat(
|
||||
17_000,
|
||||
);
|
||||
},
|
||||
(value: Record<string, unknown>) => {
|
||||
(value.public_bundle as Record<string, unknown>).key_id = "WK-other";
|
||||
},
|
||||
(value: Record<string, unknown>) => {
|
||||
delete value.public_bundle;
|
||||
},
|
||||
]
|
||||
) {
|
||||
const value = structuredClone(active);
|
||||
mutate(value);
|
||||
assertThrows(
|
||||
() => parseWorkspaceSigningIdentityResponse(value),
|
||||
ProfileApiError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
|
||||
assertThrows(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user