From f814e808eccc7f80416bf58c61f47bc7477a9d2d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 15:54:35 +0900 Subject: [PATCH 1/6] wip: retain atomic configuration adoption core before startup dependency integration Local checkpoint only. HTTP wiring, focused migration retest, generated artifacts and review remain incomplete; not a reviewed or validated handoff. --- Cargo.lock | 1 + ...generate_configuration_adoption_openapi.rs | 4 + .../generate_configuration_adoption_types.rs | 9 + .../server-api/src/configuration_adoption.rs | 134 ++++++ crates/server-api/src/lib.rs | 1 + crates/subjektiv/src/deletion.rs | 8 + crates/subjektiv/src/lib.rs | 11 +- crates/subjektiv/src/permissions.rs | 39 +- crates/subjektiv/src/permissions/transfer.rs | 244 ++++++++++ crates/subjektiv/src/permissions/transfer.sql | 49 ++ .../src/permissions/transfer/tests.rs | 357 ++++++++++++++ crates/subjektiv/src/storage_format_tests.rs | 92 ++++ crates/subjektiv/src/storage_formats.rs | 69 ++- crates/subjektiv/src/subject_creation.rs | 41 +- .../src/feature/builtin/permission_catalog.rs | 78 ++++ crates/worker/src/feature/permissions.rs | 4 + crates/workspace-server/Cargo.toml | 7 +- .../workspace-server/src/config_adoption.rs | 352 ++++++++++++++ .../workspace-server/src/config_adoption.sql | 31 ++ .../src/config_adoption_tests.rs | 155 ++++++ crates/workspace-server/src/config_source.rs | 24 +- .../src/configuration_migration.rs | 440 ++++++++++++++++++ crates/workspace-server/src/lib.rs | 3 + crates/workspace-server/src/policy_config.rs | 270 +++++++++++ .../src/storage_format_tests.rs | 23 +- .../workspace-server/src/storage_formats.rs | 43 +- crates/workspace-server/src/store.rs | 36 +- 27 files changed, 2484 insertions(+), 41 deletions(-) create mode 100644 crates/server-api/examples/generate_configuration_adoption_openapi.rs create mode 100644 crates/server-api/examples/generate_configuration_adoption_types.rs create mode 100644 crates/server-api/src/configuration_adoption.rs create mode 100644 crates/subjektiv/src/permissions/transfer.rs create mode 100644 crates/subjektiv/src/permissions/transfer.sql create mode 100644 crates/subjektiv/src/permissions/transfer/tests.rs create mode 100644 crates/worker/src/feature/builtin/permission_catalog.rs create mode 100644 crates/workspace-server/src/config_adoption.rs create mode 100644 crates/workspace-server/src/config_adoption.sql create mode 100644 crates/workspace-server/src/config_adoption_tests.rs create mode 100644 crates/workspace-server/src/configuration_migration.rs create mode 100644 crates/workspace-server/src/policy_config.rs diff --git a/Cargo.lock b/Cargo.lock index ffec5d235..ac342bd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7579,6 +7579,7 @@ dependencies = [ "content-history", "content-history-adapters", "content-history-store", + "feature-permissions", "feature-storage", "flate2", "flow", diff --git a/crates/server-api/examples/generate_configuration_adoption_openapi.rs b/crates/server-api/examples/generate_configuration_adoption_openapi.rs new file mode 100644 index 000000000..fbc58b1d5 --- /dev/null +++ b/crates/server-api/examples/generate_configuration_adoption_openapi.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(),Box> { + print!("{}", server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?); + Ok(()) +} diff --git a/crates/server-api/examples/generate_configuration_adoption_types.rs b/crates/server-api/examples/generate_configuration_adoption_types.rs new file mode 100644 index 000000000..fe0798e49 --- /dev/null +++ b/crates/server-api/examples/generate_configuration_adoption_types.rs @@ -0,0 +1,9 @@ +#[cfg(feature="typescript")] +fn main() { + print!("{}",server_api::normalized_typescript(server_api::configuration_adoption::configuration_typescript())); +} +#[cfg(not(feature="typescript"))] +fn main() { + eprintln!("generate_configuration_adoption_types requires --features typescript"); + std::process::exit(2); +} diff --git a/crates/server-api/src/configuration_adoption.rs b/crates/server-api/src/configuration_adoption.rs new file mode 100644 index 000000000..6ea7b75cc --- /dev/null +++ b/crates/server-api/src/configuration_adoption.rs @@ -0,0 +1,134 @@ +//! Workspace-owner source adoption transport. The Server authenticates the owner +//! before reading source, previewing, sealing migration data, adopting or reconciling. +//! A result lookup is not a new adoption and absence is not proof of failure. +use crate::{ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse}; +use feature_permissions::{PermissionAssignment, PermissionCatalog}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionRequest { + pub operation_id: String, + pub expected_head: Option, + pub expected_catalog_digest: String, + pub expected_legacy_export: Option, + pub source: ConfigCommitRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(rename_all="snake_case")] +pub enum RuntimePolicyApplication { NextCurrentPolicyCheck } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionReceipt { + pub workspace_id:String, + pub operation_id:String, + pub history_head:String, + pub state:WorkspaceConfigTreeResponse, + pub source_stored:bool, + pub policy_committed:bool, + pub runtime_application:RuntimePolicyApplication, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationSubjectPolicy { + pub subject_id:String, + pub change_id:String, + pub execution:PermissionAssignment, + pub delegation:PermissionAssignment, + /// Exact retained domain restrictions, not editable payloads on permission keys. + pub legacy_constraints:Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(tag="kind",rename_all="snake_case",deny_unknown_fields)] +pub enum ConfigurationMigrationDiagnostic { + Converted {subject_id:String,operation:String}, + DomainConstraint {subject_id:String,permission:crate::authorization::ScopedPermission}, + ExplicitConfigurationRequired {subject_id:String,permission:crate::authorization::ScopedPermission}, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationSourceResponse { + /// Before the first adoption this is an editable, evaluated migration draft. + /// source_stored distinguishes it from the unchanged stored source. + pub state:WorkspaceConfigTreeResponse, + pub history_head:Option, + pub source_stored:bool, + pub catalog:PermissionCatalog, + pub catalog_digest:String, + pub expected_legacy_export:Option, + pub policies:Vec, + pub migration_diagnostics:Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationPreviewResponse { + pub expected_head:Option, + pub state:WorkspaceConfigTreeResponse, + pub changed_paths:Vec, + pub changed_subjects:Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[serde(tag="status",rename_all="snake_case",deny_unknown_fields)] +pub enum ConfigurationReconciliation { + Adopted {receipt:ConfigurationAdoptionReceipt}, + PendingTransfer {transfer_identity:String,export_digest:String}, + /// No matching evidence was observed. This is not permission to change the ID. + NotObserved, +} + +#[api_macros::api(reqwest,axum,openapi)] +pub trait ConfigurationAdoptionApi { + #[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)] + async fn configuration_source(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String)->Result; + #[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_preview(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + #[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_adopt(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + #[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_reconcile(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; +} + +impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {} +impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionRequest {} +impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {} +impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {} +impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {} + +pub fn configuration_contract_openapi() -> Result { + use sha2::{Digest,Sha256}; + let document=|source_digest:&str| configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { + title:"Workspace configuration adoption",version:env!("CARGO_PKG_VERSION"),source_digest, + }); + let normalized=document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?.to_json()?; + let digest:String=Sha256::digest(normalized.as_bytes()).iter().map(|byte|format!("{byte:02x}")).collect(); + document(&format!("sha256:{digest}")) +} + +#[cfg(feature="typescript")] +pub fn configuration_typescript() -> String { + use ts_rs::TS; + let config=ts_rs::Config::default(); + let declarations=[ + ConfigurationAdoptionRequest::decl(&config), RuntimePolicyApplication::decl(&config), + ConfigurationAdoptionReceipt::decl(&config), ConfigurationSubjectPolicy::decl(&config), + ConfigurationMigrationDiagnostic::decl(&config), ConfigurationSourceResponse::decl(&config), + ConfigurationPreviewResponse::decl(&config), ConfigurationReconciliation::decl(&config), + ]; + format!("// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",declarations.into_iter().map(|declaration|format!("export {declaration}")).collect::>().join("\n\n")) +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 01a9f9988..d0948327d 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9,6 +9,7 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web pub mod authorization; mod chat; pub mod feature_permissions; +pub mod configuration_adoption; pub use chat::*; mod subject_body; pub use subject_body::*; diff --git a/crates/subjektiv/src/deletion.rs b/crates/subjektiv/src/deletion.rs index 817203882..fb2d4c6c2 100644 --- a/crates/subjektiv/src/deletion.rs +++ b/crates/subjektiv/src/deletion.rs @@ -319,6 +319,11 @@ impl SubjektivStore { UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1 UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1 UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?; + if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { + tx.execute("INSERT OR IGNORE INTO subject_deleted_workers(subject_id,runtime_id,worker_id) + SELECT subject_id,runtime_id,worker_id FROM canonical_worker_execution_grants WHERE subject_id=?1 + UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; + } tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id) SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1 UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1 @@ -401,6 +406,9 @@ impl SubjektivStore { [subject], )?; } + if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { + tx.execute("DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; + } for table in DIRECT { tx.execute( &format!("DELETE FROM {table} WHERE subject_id=?1"), diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 631d11c58..e425a8818 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -8,6 +8,10 @@ pub use deletion::{DeletionSubjectJob, SubjectDeletion}; pub mod journal; mod legacy_migrations; pub mod permissions; +pub use permissions::{ + CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, + PermissionTransferReceipt, PermissionTransferSnapshot, +}; mod schema; mod subject_creation; use legacy_migrations::{ @@ -95,6 +99,10 @@ pub enum SubjektivError { SubjectPermissionsConflict(String), #[error("permission operation `{0}` was reused with different input")] PermissionOperationConflict(String), + #[error("legacy permission authority is permanently sealed")] + PermissionAuthoritySealed, + #[error("permission transfer conflicts with identity, export or retained receipt")] + PermissionTransferConflict, #[error("authorization denied: {0}")] AuthorizationDenied(String), #[error("Worker execution grant binding conflicts for `{0}`")] @@ -689,6 +697,7 @@ pub const REGISTRATION: FeatureRegistration = #[derive(Clone)] pub struct SubjektivStore { database: FeatureDatabase, + canonical_permission_source: Option>, } impl SubjektivStore { @@ -746,7 +755,7 @@ impl SubjektivStore { } } })?; - Ok(Self { database }) + Ok(Self { database, canonical_permission_source: None }) } pub fn scope_id(&self) -> &str { diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs index bd878eda7..38f3f6f91 100644 --- a/crates/subjektiv/src/permissions.rs +++ b/crates/subjektiv/src/permissions.rs @@ -12,6 +12,12 @@ use server_api::authorization::{ }; use sha2::{Digest, Sha256}; +pub(crate) mod transfer; +pub use transfer::{ + CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, + PermissionTransferReceipt, PermissionTransferSnapshot, +}; + pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000; pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { @@ -23,9 +29,9 @@ impl SubjektivStore { /// Unset sources are `None` (deny), including migrated Subjects. This is an admin read, /// not an effective-authorization decision; retired Subjects retain their source evidence. pub fn subject_permissions(&self, subject_id: &str) -> Result> { - self.database.try_with_connection(|connection| { + self.database.try_transaction(|connection| { require_subject_in_connection(connection, subject_id)?; - source_in_connection(connection, subject_id) + self.current_permission_source(connection, subject_id) }) } @@ -43,7 +49,8 @@ impl SubjektivStore { /// Management replacement rejects new unsupported operation/resource pairs while /// retaining exact legacy entries in their original execution/delegation lane. /// Validation shares the existing CAS/receipt transaction so a stale editor cannot - /// retain a removed legacy grant. Exact replay remains historical evidence. + /// retain a removed legacy grant. Before sealing, exact replay remains historical evidence; + /// once sealed, replacements reject even an otherwise exact historical receipt replay. pub fn set_managed_subject_permissions( &self, subject_id: &str, @@ -71,6 +78,7 @@ impl SubjektivStore { .map(|byte| format!("{byte:02x}")) .collect(); self.database.try_transaction(|tx| { + transfer::require_unsealed(tx)?; require_subject(tx, subject_id)?; let receipt: Option<(String, String)> = tx.query_row( "SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1", @@ -129,7 +137,7 @@ impl SubjektivStore { }; } require_active_subject(tx, &grant.subject_id)?; - let source = source_in_connection(tx, &grant.subject_id)? + let source = self.current_permission_source(tx, &grant.subject_id)? .ok_or_else(|| denied("Subject permissions are unset"))?; if source.change_id != grant.source_change_id { return Err(denied("Subject permission source is no longer current")); @@ -148,7 +156,12 @@ impl SubjektivStore { require_covered(&origin.redelegation, &grant.permissions)?; require_covered(&origin.redelegation, &grant.redelegation)?; } - tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + let table = if transfer::is_sealed(tx)? { + // Canonical IDs must not be mirrored into legacy source history to satisfy its FK. + crate::storage_formats::install_permission_transfer(tx)?; + "canonical_worker_execution_grants" + } else { "worker_execution_grants" }; + tx.execute(&format!("INSERT INTO {table}(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)"), params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?; Ok(()) }) @@ -164,9 +177,12 @@ impl SubjektivStore { validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; self.database.try_with_connection(|connection| { require_subject_in_connection(connection, subject_id)?; - let mut statement = connection.prepare( - "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2", - )?; + let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? { + "SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2" + } else { + "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2" + }; + let mut statement = connection.prepare(sql)?; let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?; let mut grants = Vec::new(); while let Some(row) = rows.next()? { @@ -216,9 +232,14 @@ fn grant_in_connection( // Absence must never become non-Subject fallback authority after purge. deletion::require_live_worker(connection, runtime, worker)?; + let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? { + "SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) g WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=g.subject_id)" + } else { + "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)" + }; let raw: Option = connection .query_row( - "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)", + sql, params![runtime, worker], |row| row.get(0), ) diff --git a/crates/subjektiv/src/permissions/transfer.rs b/crates/subjektiv/src/permissions/transfer.rs new file mode 100644 index 000000000..059da3e61 --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer.rs @@ -0,0 +1,244 @@ +//! One-way preparation of a permission-domain transfer. No Server adoption or dual commit. +use super::*; +use std::sync::Arc; + +/// Host connection to the canonical permission domain, not an ACL or grant issuer. +/// Implementations must resolve the exact Workspace/Subject pair and propagate failures. +/// Calls are synchronous under the FeatureDatabase guard; do not reenter this store. +pub trait CanonicalPermissionSource: Send + Sync { + fn subject_permissions( + &self, + workspace_id: &str, + subject_id: &str, + ) -> Result>; +} + +/// Serializable transfer DTO for FeatureStorage's typed identity, never a physical path. +/// FeatureStorage owns identity validation; this DTO does not interpret storage metadata. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DatabaseIdentity { + pub scope_id: String, + pub feature_id: String, + pub physical_id: String, +} + +impl From<&feature_storage::DatabaseIdentity> for DatabaseIdentity { + fn from(identity: &feature_storage::DatabaseIdentity) -> Self { + Self { + scope_id: identity.scope.clone(), + feature_id: identity.feature.clone(), + physical_id: identity.physical.clone(), + } + } +} + +impl DatabaseIdentity { + fn storage_identity(&self) -> feature_storage::DatabaseIdentity { + feature_storage::DatabaseIdentity { + scope: self.scope_id.clone(), + feature: self.feature_id.clone(), + physical: self.physical_id.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionSourceExport { + pub subject_id: String, + /// `None` is unset; `Some` with empty vectors is an explicit revocation. + pub permissions: Option, +} + +/// Complete, Subject-ID-sorted policy snapshot. History and grants stay in the old database. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionTransferSnapshot { + pub identity: DatabaseIdentity, + pub sources: Vec, + /// SHA-256 of the versioned identity and sorted policy vector, excluding this field. + pub export_digest: String, +} + +/// Immutable preparation evidence, not evidence that Server settings have been adopted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionTransferReceipt { + pub transfer_id: String, + pub snapshot: PermissionTransferSnapshot, +} + +impl SubjektivStore { + pub fn with_canonical_permission_source(mut self, source: Arc) -> Self { + self.canonical_permission_source = Some(source); + self + } + + /// Reads physical identity under the FeatureStorage connection guard and scope check. + pub fn database_identity(&self) -> Result { + let identity = DatabaseIdentity::from(self.database.identity()); + self.permission_transfer_transaction(&identity, |_| Ok(identity.clone())) + } + + /// Reads one coherent pre-seal snapshot without installing schema or changing authority. + /// After a transfer, returns the retained exact snapshot, never a changed/deleted live set. + pub fn preview_permission_transfer(&self, expected: &DatabaseIdentity) -> Result { + self.permission_transfer_transaction(expected, |tx| { + if let Some(receipt) = retained_receipt(tx)? { + require_receipt_identity(&receipt, expected)?; + return Ok(receipt.snapshot); + } + require_unsealed(tx)?; + export_in_connection(tx, expected.clone()) + }) + } + + /// Permanently seals the old source writer after preview/CAS. All changes, the exact export + /// and its identity-bound receipt commit in one old-DB IMMEDIATE transaction. The Host must + /// authorize/preview first and bind transfer_id to the complete Server request fingerprint. + /// A successful seal does not adopt any Server configuration. Never call two commits atomic. + pub fn seal_permission_sources( + &self, + expected: &DatabaseIdentity, + transfer_id: &str, + expected_export_digest: &str, + ) -> Result { + validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?; + self.permission_transfer_transaction(expected, |tx| { + if let Some(receipt) = retained_receipt(tx)? { + require_receipt_identity(&receipt, expected)?; + if receipt.transfer_id != transfer_id || receipt.snapshot.export_digest != expected_export_digest { + return Err(SubjektivError::PermissionTransferConflict); + } + return Ok(receipt); + } + // A published Permission18 seal is not ours to rewrite or relabel as this receipt. + require_unsealed(tx)?; + let snapshot = export_in_connection(tx, expected.clone())?; + if snapshot.export_digest != expected_export_digest { + return Err(SubjektivError::PermissionTransferConflict); + } + crate::storage_formats::install_permission_transfer(tx)?; + if !has_table(tx, "permission_source_transfer_receipt")? { + return Err(SubjektivError::InvalidRecord("permission transfer requires schema 1..17".into())); + } + let receipt = PermissionTransferReceipt { transfer_id: transfer_id.into(), snapshot }; + let raw = serde_json::to_string(&receipt)?; + tx.execute("INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)", + params![transfer_id, serde_json::to_string(&receipt.snapshot)?])?; + tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?; + Ok(receipt) + }) + } + + fn permission_transfer_transaction( + &self, + expected: &DatabaseIdentity, + action: impl FnOnce(&Transaction<'_>) -> Result, + ) -> Result { + if *expected != DatabaseIdentity::from(self.database.identity()) { + return Err(SubjektivError::PermissionTransferConflict); + } + // Validate the caller's native expected identity without holding a separate guard + // across transaction entry. try_transaction rechecks the same immutable bound identity + // before/after the operation and owns commit, including OutcomeUnknown errors. + drop(self.database.guard(&expected.storage_identity())?); + self.database.try_transaction(|tx| { + require_store_scope(tx, self.scope_id())?; + action(tx) + }) + } + + pub(super) fn current_permission_source(&self, connection: &Connection, subject: &str) -> Result> { + if !is_sealed(connection)? { + return source_in_connection(connection, subject); + } + let provider = self.canonical_permission_source.as_ref() + .ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?; + let source = provider.subject_permissions(self.scope_id(), subject)?; + if let Some(source) = &source { + validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_scope(self.scope_id(), &source.execution)?; + validate_scope(self.scope_id(), &source.delegation)?; + } + Ok(source) + } +} + +pub(crate) fn has_table(connection: &Connection, name: &str) -> Result { + Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", [name], |row| row.get(0))?) +} + +pub(crate) fn is_sealed(connection: &Connection) -> Result { + if !has_table(connection, "permission_authority_seal")? { return Ok(false); } + Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", [], |row| row.get(0))?) +} + +pub(super) fn require_unsealed(connection: &Connection) -> Result<()> { + if is_sealed(connection)? { return Err(SubjektivError::PermissionAuthoritySealed); } + Ok(()) +} + +fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> { + let stored: String = connection.query_row( + "SELECT workspace_id FROM store_scope WHERE singleton=1", + [], |row| row.get(0), + )?; + if stored != scope { return Err(SubjektivError::PermissionTransferConflict); } + Ok(()) +} + +fn require_receipt_identity(receipt: &PermissionTransferReceipt, expected: &DatabaseIdentity) -> Result<()> { + if receipt.snapshot.identity != *expected { return Err(SubjektivError::PermissionTransferConflict); } + Ok(()) +} + +fn retained_receipt(connection: &Connection) -> Result> { + if !has_table(connection, "permission_source_transfer_receipt")? { return Ok(None); } + let raw: Option = connection.query_row( + "SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1", + [], |row| row.get(0), + ).optional()?; + let receipt: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if receipt.is_some() && !is_sealed(connection)? { return Err(SubjektivError::PermissionTransferConflict); } + Ok(receipt) +} + +fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> Result { + let orphaned: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)", + [], |row| row.get(0), + )?; + if orphaned { return Err(SubjektivError::PermissionTransferConflict); } + let mut statement = connection.prepare( + "SELECT s.subject_id,p.change_id,c.record_json FROM subjects s + LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id + LEFT JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id + ORDER BY s.subject_id", + )?; + let mut rows = statement.query([])?; + let mut sources = Vec::new(); + while let Some(row) = rows.next()? { + let subject_id: String = row.get(0)?; + validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?; + let change: Option = row.get(1)?; + let raw: Option = row.get(2)?; + let permissions: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() { + return Err(SubjektivError::PermissionTransferConflict); + } + if let Some(source) = &permissions { + validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_scope(&identity.scope_id, &source.execution)?; + validate_scope(&identity.scope_id, &source.delegation)?; + } + sources.push(PermissionSourceExport { subject_id, permissions }); + } + let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?) + .iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(PermissionTransferSnapshot { identity, sources, export_digest }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/permissions/transfer.sql b/crates/subjektiv/src/permissions/transfer.sql new file mode 100644 index 000000000..a2a370a2b --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer.sql @@ -0,0 +1,49 @@ +-- Permission transfer domain v1, independent of published common migration numbers. +-- This receipt is preparation evidence only, never Server configuration adoption. +CREATE TABLE permission_source_transfer_receipt ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), + schema_version INTEGER NOT NULL CHECK(schema_version=1), + receipt_json TEXT NOT NULL +); +CREATE TRIGGER permission_source_transfer_no_replace BEFORE INSERT ON permission_source_transfer_receipt +WHEN EXISTS(SELECT 1 FROM permission_source_transfer_receipt) +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt already exists'); END; +CREATE TRIGGER permission_source_transfer_no_update BEFORE UPDATE ON permission_source_transfer_receipt +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is immutable'); END; +CREATE TRIGGER permission_source_transfer_no_delete BEFORE DELETE ON permission_source_transfer_receipt +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is retained'); END; +-- Published Permission18's UPDATE/DELETE guards alone do not fence INSERT OR REPLACE. +CREATE TRIGGER permission_authority_seal_no_replace BEFORE INSERT ON permission_authority_seal +WHEN EXISTS(SELECT 1 FROM permission_authority_seal) +BEGIN SELECT RAISE(ABORT, 'permission authority seal already exists'); END; +CREATE TRIGGER subject_creation_receipts_no_replace BEFORE INSERT ON subject_creation_receipts +WHEN EXISTS(SELECT 1 FROM subject_creation_receipts WHERE operation_id=NEW.operation_id OR subject_id=NEW.subject_id) +BEGIN SELECT RAISE(ABORT, 'Subject creation receipt already exists'); END; + +-- Execution binding evidence only, not a policy mirror. Canonical source IDs cannot reference +-- legacy subject_permission_changes. Preserve that table/FK and all old grant rows unchanged. +CREATE TABLE canonical_worker_execution_grants ( + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY(runtime_id,worker_id) +); +CREATE TRIGGER canonical_worker_execution_grants_no_replace BEFORE INSERT ON canonical_worker_execution_grants +WHEN EXISTS(SELECT 1 FROM canonical_worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) + OR EXISTS(SELECT 1 FROM worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker execution grant already exists'); END; +CREATE TRIGGER canonical_worker_execution_grants_no_update BEFORE UPDATE ON canonical_worker_execution_grants +BEGIN SELECT RAISE(ABORT, 'Worker execution grants are immutable'); END; +CREATE TRIGGER canonical_worker_execution_grants_no_delete BEFORE DELETE ON canonical_worker_execution_grants +WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id) +BEGIN SELECT RAISE(ABORT, 'Worker execution grants are retained'); END; +CREATE TRIGGER canonical_worker_execution_grants_insert_guard BEFORE INSERT ON canonical_worker_execution_grants +WHEN NOT EXISTS(SELECT 1 FROM permission_authority_seal) + OR EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id) + OR EXISTS(SELECT 1 FROM subject_deleted_workers WHERE + (runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) + OR (runtime_id=json_extract(NEW.record_json,'$.origin_runtime_id') + AND worker_id=json_extract(NEW.record_json,'$.origin_worker_id'))) +BEGIN SELECT RAISE(ABORT, 'canonical grant requires sealed authority and live Subject execution identities'); END; diff --git a/crates/subjektiv/src/permissions/transfer/tests.rs b/crates/subjektiv/src/permissions/transfer/tests.rs new file mode 100644 index 000000000..57d94a12e --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer/tests.rs @@ -0,0 +1,357 @@ +//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim. +use super::*; +use crate::tests::{open_store, role}; +use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant}; +use std::collections::BTreeMap; +use std::sync::{Barrier, Mutex}; + +fn read_permission() -> ScopedPermission { + ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workdir { working_directory_id: "wd".into(), path: ".".into(), recursive: true }, + } +} + +fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: Vec) -> (SubjectPermissionsUpdate, SubjectPermissions) { + let update = SubjectPermissionsUpdate { + operation_id: operation.into(), + expected_change_id: store.subject_permissions(subject).unwrap().map(|source| source.change_id), + execution: vec![], delegation, + }; + let source = store.set_subject_permissions(subject, &update).unwrap(); + (update, source) +} + +fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), source_change_id: source.into(), + runtime_id: "runtime".into(), worker_id: worker.into(), + origin_runtime_id: "runtime".into(), origin_worker_id: "host-authenticated-root".into(), + profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), + permissions: vec![read_permission()], redelegation: vec![], + } +} + +fn counts(store: &SubjektivStore) -> Vec { + store.database.try_with_connection::<_, SubjektivError>(|connection| { + ["subject_permissions", "subject_permission_changes", "subject_permission_receipts", "worker_execution_grants"] + .iter().map(|table| Ok(connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?)).collect() + }).unwrap() +} + +fn seal(store: &SubjektivStore) -> PermissionTransferReceipt { + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store.seal_permission_sources(&identity, "server-request-fingerprint", &preview.export_digest).unwrap() +} + +#[test] +fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_component() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let identity = store.database_identity().unwrap(); + let native = store.database.identity(); + assert_eq!(identity.scope_id, native.scope); + assert_eq!(identity.feature_id, native.feature); + assert_eq!(identity.physical_id, native.physical); + let encoded = serde_json::to_value(&identity).unwrap(); + assert_eq!(encoded, serde_json::json!({ + "scope_id": native.scope, + "feature_id": native.feature, + "physical_id": native.physical, + })); + let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap(); + let restored = decoded.storage_identity(); + assert_eq!(restored.scope, native.scope); + assert_eq!(restored.feature, native.feature); + assert_eq!(restored.physical, native.physical); + drop(store.database.guard(&restored).unwrap()); + let preview = store.preview_permission_transfer(&decoded).unwrap(); + for component in ["scope", "feature", "physical"] { + let mut wrong = decoded.clone(); + match component { + "scope" => wrong.scope_id.push_str("-different"), + "feature" => wrong.feature_id.push_str("-different"), + "physical" => wrong.physical_id.push_str("-different"), + _ => unreachable!(), + } + assert!(matches!(store.preview_permission_transfer(&wrong), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!(store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!(store.permission_transfer_transaction::<()>(&wrong, |_| { + panic!("an identity mismatch must not enter the SQL transaction body") + }), Err(SubjektivError::PermissionTransferConflict))); + } + assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); +} + +#[test] +fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_after_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (manager, scope, store) = open_store(temp.path(), "workspace-a"); + let unset = store.create_subject(role()).unwrap().id; + let empty = store.create_subject(role()).unwrap().id; + let limited = store.create_subject(role()).unwrap().id; + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&unset])?; + Ok(()) + }).unwrap(); + let (_, empty_source) = replace(&store, &empty, "empty", vec![]); + let (old_update, limited_source) = replace(&store, &limited, "limited", vec![read_permission()]); + let grant = child(&limited, &limited_source.change_id, "historical-child"); + store.bind_worker_execution_grant(&grant).unwrap(); + let before = counts(&store); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + assert_eq!(preview.sources.len(), 3); + assert!(preview.sources.windows(2).all(|pair| pair[0].subject_id < pair[1].subject_id)); + assert_eq!(preview.sources.iter().find(|row| row.subject_id == unset).unwrap().permissions, None); + assert_eq!(preview.sources.iter().find(|row| row.subject_id == empty).unwrap().permissions, Some(empty_source)); + assert_eq!(preview.sources.iter().find(|row| row.subject_id == limited).unwrap().permissions, Some(limited_source.clone())); + assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + // Preview is physically read-only, including the domain schema. + assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + let receipt = store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap(); + assert_eq!(receipt.snapshot, preview); + assert_eq!(counts(&store), before); + assert_eq!(store.worker_execution_grant("runtime", "historical-child").unwrap(), Some(grant.clone())); + store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority. + assert!(matches!(store.set_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); + assert!(matches!(store.set_managed_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); + assert!(matches!(store.seal_permission_sources(&identity, "different", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!(store.seal_permission_sources(&identity, "transfer", "different-export"), Err(SubjektivError::PermissionTransferConflict))); + let mut wrong_identity = identity.clone(); + wrong_identity.scope_id = "workspace-b".into(); + assert!(matches!(store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!(store.preview_permission_transfer(&wrong_identity), Err(SubjektivError::PermissionTransferConflict))); + drop(store); + scope.shutdown().unwrap(); + drop(scope); + drop(manager); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert_eq!(identity, store.database_identity().unwrap()); + assert_eq!(receipt, store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap()); + assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + assert_eq!(counts(&store), before); + assert!(matches!(store.subject_permissions(&limited), Err(SubjektivError::AuthorizationDenied(_)))); +} + +#[test] +fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + replace(&store, &subject, "old-writer-after-preview", vec![]); + assert!(matches!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); + assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); + assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + replace(&store, &subject, "still-writable", vec![]); + let fresh = store.preview_permission_transfer(&identity).unwrap(); + assert_ne!(fresh.export_digest, preview.export_digest); + store.seal_permission_sources(&identity, "new-request", &fresh.export_digest).unwrap(); +} + +#[test] +fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() { + let temp = tempfile::tempdir().unwrap(); + let (manager, scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store.database.transaction(crate::storage_formats::install_permission_transfer).unwrap(); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; + Ok(()) + }).unwrap(); + assert!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest).is_err()); + assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); + assert!(store.database.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)).unwrap().is_none()); + replace(&store, &subject, "writer-after-rollback", vec![]); + drop(store); + scope.shutdown().unwrap(); + drop(scope); + drop(manager); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert!(store.subject_permissions(&subject).unwrap().is_some()); + seal(&store); +} + +struct Canonical { + sources: BTreeMap>, + calls: Mutex>, +} +impl CanonicalPermissionSource for Canonical { + fn subject_permissions(&self, workspace: &str, subject: &str) -> Result> { + self.calls.lock().unwrap().push((workspace.into(), subject.into())); + if workspace != "workspace-a" { return Err(denied("foreign Workspace")); } + self.sources.get(subject).cloned().ok_or_else(|| denied("canonical source unavailable")) + } +} + +#[test] +fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let (_, old) = replace(&store, &subject, "old", vec![read_permission()]); + let receipt = seal(&store); + assert!(matches!(store.subject_permissions(&subject), Err(SubjektivError::AuthorizationDenied(_)))); + assert!(matches!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), Err(SubjektivError::AuthorizationDenied(_)))); + let before = counts(&store); + let created = store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap(); + assert_eq!(created, store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap()); + let non_idempotent = store.create_subject(role()).unwrap(); + assert!(store.subject_permission_enrollment(&created.id).unwrap().is_some()); + assert!(store.subject_permission_enrollment(&non_idempotent.id).unwrap().is_some()); + assert!(store.subject_permission_enrollment(&subject).unwrap().is_none()); + assert_eq!(counts(&store), before); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + for id in [&created.id, &non_idempotent.id] { + let raw: String = connection.query_row("SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", [id], |row| row.get(0))?; + let enrollment: SubjectPermissions = serde_json::from_str(&raw)?; + assert!(!enrollment.execution.is_empty()); + assert!(source_in_connection(connection, id)?.is_none()); + } + Ok(()) + }).unwrap(); + let canonical = SubjectPermissions { change_id: "canonical-change".into(), execution: vec![], delegation: vec![read_permission()] }; + let provider = Arc::new(Canonical { + sources: BTreeMap::from([(subject.clone(), Some(canonical.clone())), (created.id.clone(), None)]), + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider.clone()); + assert_eq!(store.subject_permissions(&subject).unwrap(), Some(canonical.clone())); + assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default. + assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates. + assert!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")).is_err()); + store.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")).unwrap(); + assert_eq!(store.list_subject_worker_execution_grants(&subject).unwrap(), vec![child(&subject, &canonical.change_id, "canonical-child")]); + assert_eq!(store.worker_execution_grant("runtime", "canonical-child").unwrap(), Some(child(&subject, &canonical.change_id, "canonical-child"))); + for sql in [ + "INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants", + "UPDATE canonical_worker_execution_grants SET source_change_id='forged'", + "DELETE FROM canonical_worker_execution_grants", + ] { + assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err()); + } + assert!(provider.calls.lock().unwrap().iter().all(|(workspace, id)| workspace == "workspace-a" && [subject.as_str(), created.id.as_str(), non_idempotent.id.as_str()].contains(&id.as_str()))); + assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); + store.begin_subject_deletion(&subject, "delete-canonical-subject").unwrap(); + let workers = store.deletion_subject_workers(&subject).unwrap(); + assert!(workers.contains(&("runtime".into(), "canonical-child".into()))); + assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into()))); + assert!(store.finish_subject_deletion(&subject, "delete-canonical-subject").unwrap().completed); + assert!(store.worker_execution_grant("runtime", "canonical-child").is_err()); + assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); +} + +#[test] +fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_remains_possible() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + replace(&store, &subject, "old-receipt", vec![]); + let receipt = seal(&store); + // Reacquire a managed store handle; tests must not infer/open physical storage paths. + let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap(); + assert!(old_writer.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", [&subject])?; + Ok(()) + }).is_err()); + drop(old_writer); + for sql in [ + "INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions", + "UPDATE subject_permissions SET change_id=change_id", + "DELETE FROM subject_permissions", + "INSERT OR REPLACE INTO subject_permission_changes SELECT * FROM subject_permission_changes", + "UPDATE subject_permission_changes SET record_json=record_json", + "DELETE FROM subject_permission_changes", + "INSERT OR REPLACE INTO subject_permission_receipts SELECT * FROM subject_permission_receipts", + "UPDATE subject_permission_receipts SET request_fingerprint=request_fingerprint", + "DELETE FROM subject_permission_receipts", + "INSERT INTO worker_execution_grants SELECT 'runtime','late-writer',subject_id,change_id,'{}' FROM subject_permissions LIMIT 1", + "INSERT OR REPLACE INTO permission_authority_seal SELECT * FROM permission_authority_seal", + "UPDATE permission_authority_seal SET transfer_id='changed'", + "DELETE FROM permission_authority_seal", + "INSERT OR REPLACE INTO permission_source_transfer_receipt SELECT * FROM permission_source_transfer_receipt", + "UPDATE permission_source_transfer_receipt SET receipt_json='{}'", + "DELETE FROM permission_source_transfer_receipt", + ] { + assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err(), "{sql}"); + } + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?; + tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&subject])?; + tx.execute("DELETE FROM subject_permission_receipts WHERE subject_id=?1", [&subject])?; + tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?; + Ok(()) + }).unwrap(); + assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); + assert_eq!(store.seal_permission_sources(&receipt.snapshot.identity, &receipt.transfer_id, &receipt.snapshot.export_digest).unwrap(), receipt); +} + +#[test] +fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_export() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let writer = store.clone(); + let start = barrier.clone(); + let thread = std::thread::spawn(move || { + start.wait(); + writer.set_subject_permissions(&subject, &SubjectPermissionsUpdate { + operation_id: "racing-old-writer".into(), expected_change_id: Some(initial.change_id), execution: vec![], delegation: vec![], + }) + }); + barrier.wait(); + let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest); + let written = thread.join().unwrap(); + match (sealed, written) { + (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => assert_eq!(receipt.snapshot, preview), + (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(!store.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()), + other => panic!("unexpected serialized outcomes: {other:?}"), + } +} + +#[test] +fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + seal(&store); + let provider = Arc::new(Canonical { + sources: BTreeMap::from([(subject.clone(), Some(SubjectPermissions { + change_id: "foreign-source".into(), + execution: vec![ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() }, + }], + delegation: vec![read_permission()], + }))]), + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider); + assert!(store.subject_permissions(&subject).is_err()); + assert!(store.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")).is_err()); + assert!(store.worker_execution_grant("runtime", "foreign-child").unwrap().is_none()); +} + +#[test] +fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_workspace() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let (_manager_a, _scope_a, store_a) = open_store(first.path(), "workspace-a"); + let (_manager_b, _scope_b, store_b) = open_store(second.path(), "workspace-a"); + let a = store_a.database_identity().unwrap(); + let b = store_b.database_identity().unwrap(); + assert_ne!(a, b); + let preview = store_a.preview_permission_transfer(&a).unwrap(); + assert!(matches!(store_b.preview_permission_transfer(&a), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!(store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); + assert!(!store_b.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()); +} diff --git a/crates/subjektiv/src/storage_format_tests.rs b/crates/subjektiv/src/storage_format_tests.rs index fad671e2f..229180918 100644 --- a/crates/subjektiv/src/storage_format_tests.rs +++ b/crates/subjektiv/src/storage_format_tests.rs @@ -579,3 +579,95 @@ fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds( drop(db); scope.shutdown().unwrap(); } + +#[test] +fn permission_transfer_extension_reopens_on_every_published_family_without_relabelling_receipts() { + for format in PUBLISHED { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), format); + let (scope, db) = open(temp.path(), REGISTRATION); + let saved = db.with_connection(|connection| Ok(receipts(connection))).unwrap(); + let old_seal = db.with_connection(|connection| { + if format == Format::Permission18 { + Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?)) + } else { Ok(None) } + }).unwrap(); + db.transaction(install_permission_transfer).unwrap(); + db.with_connection(|connection| { + assert_eq!(receipts(connection), saved); + assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + if let Some(raw) = &old_seal { + assert_eq!(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?, *raw); + } + assert_foreign_keys(connection); + Ok(()) + }).unwrap(); + let store = SubjektivStore::from_database(db.clone()).unwrap(); + if old_seal.is_some() { + let identity = store.database_identity().unwrap(); + assert!(matches!(store.preview_permission_transfer(&identity), Err(crate::SubjektivError::PermissionAuthoritySealed))); + assert!(matches!(store.seal_permission_sources(&identity, "new-transfer", "export"), Err(crate::SubjektivError::PermissionAuthoritySealed))); + struct CanonicalEmpty; + impl crate::CanonicalPermissionSource for CanonicalEmpty { + fn subject_permissions(&self, workspace: &str, _subject: &str) -> crate::Result> { + assert_eq!(workspace, SCOPE); + Ok(Some(server_api::authorization::SubjectPermissions { + change_id: "canonical-source".into(), execution: vec![], delegation: vec![], + })) + } + } + let connected = store.clone().with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); + let subject = connected.list_subjects(1).unwrap().items[0].id.clone(); + assert_eq!(connected.subject_permissions(&subject).unwrap().unwrap().change_id, "canonical-source"); + connected.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { + subject_id: subject, source_change_id: "canonical-source".into(), + runtime_id: "runtime".into(), worker_id: "canonical-child".into(), + origin_runtime_id: "runtime".into(), origin_worker_id: "root".into(), + profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), + permissions: vec![], redelegation: vec![], + }).unwrap(); + } + drop(store); + drop(db); + scope.shutdown().unwrap(); + let (scope, db) = open(temp.path(), REGISTRATION); + db.with_connection(|connection| { + assert_eq!(receipts(connection), saved); + assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + assert_foreign_keys(connection); + Ok(()) + }).unwrap(); + drop(db); + scope.shutdown().unwrap(); + } +} + +#[test] +fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (scope, db) = open(temp.path(), REGISTRATION); + let before_shape = db.with_connection(domain_shape).unwrap(); + let before_rows = db.with_connection(|connection| Ok(all_rows(connection))).unwrap(); + let result = db.transaction(|tx| { + install_permission_transfer(tx)?; + tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?; + Ok(()) + }); + assert!(result.is_err()); + db.with_connection(|connection| { + assert_eq!(domain_shape(connection)?, before_shape); + assert_eq!(all_rows(connection), before_rows); + Ok(()) + }).unwrap(); + db.transaction(install_permission_transfer).unwrap(); + db.with_connection(|connection| { + connection.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; + Ok(()) + }).unwrap(); + drop(db); + scope.shutdown().unwrap(); + let scope = feature_storage::FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let registered = scope.register(REGISTRATION).unwrap(); + assert!(matches!(scope.open(®istered), Err(FeatureStorageError::MigrationHistory { .. }))); + scope.shutdown().unwrap(); +} diff --git a/crates/subjektiv/src/storage_formats.rs b/crates/subjektiv/src/storage_formats.rs index 4e2d29dbd..3f1c39643 100644 --- a/crates/subjektiv/src/storage_formats.rs +++ b/crates/subjektiv/src/storage_formats.rs @@ -81,7 +81,14 @@ pub(super) fn validate( applied: &[(u32, String)], ) -> feature_storage::Result<()> { let format = recognize(applied)?; - let expected = reference_shape(format)?; + let transfer = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='permission_source_transfer_receipt')", + [], |row| row.get::<_, bool>(0), + )?; + if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) { + return Err(incompatible("permission transfer requires complete common 1..17")); + } + let expected = reference_shape_with_transfer(format, transfer)?; let actual = domain_shape(tx)?; if actual != expected { let difference = actual @@ -115,9 +122,14 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result feature_storage::Result> { + reference_shape_with_transfer(format, false) +} + +fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_storage::Result> { // Cache reference shapes only; every target is reread under its caller's transaction. - static SHAPES: [OnceLock>; 25] = [const { OnceLock::new() }; 25]; + static SHAPES: [OnceLock>; 50] = [const { OnceLock::new() }; 50]; let index = match format { Format::Common(count) => count, Format::Permission18 => 18, @@ -127,7 +139,7 @@ fn reference_shape(format: Format) -> feature_storage::Result> Format::Consolidation20 => 22, Format::Fixed20 => 23, Format::Cancellation20 => 24, - }; + } + if transfer { 25 } else { 0 }; if let Some(shape) = SHAPES[index].get() { return Ok(shape.clone()); } @@ -184,11 +196,58 @@ fn reference_shape(format: Format) -> feature_storage::Result> tx.execute_batch(CONSOLIDATION_SQL)?; } } + if transfer { + install_transfer_shape(&tx, format == Format::Permission18)?; + } let shape = domain_shape(&tx)?; let _ = SHAPES[index].set(shape.clone()); Ok(shape) } +/// Installs a separate permission-domain v1 shape without changing common/published receipts. +/// Existing published seals and their serialized exports are never rewritten or relabelled. +/// The FeatureDatabase guard and caller's transaction cover validation and installation. +pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> { + let mut statement = tx.prepare(&format!("SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"))?; + let applied = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + let format = recognize(&applied)?; + if matches!(format, Format::Common(count) if count < COMMON_COUNT) { + return Ok(()); + } + validate(tx, &applied)?; + let installed: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='permission_source_transfer_receipt')", + [], |row| row.get(0), + )?; + if !installed { + install_transfer_shape(tx, format == Format::Permission18)?; + } + validate(tx, &applied) +} + +fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> feature_storage::Result<()> { + if !published_permission { + tx.execute_batch(PERMISSION_SEAL_SQL)?; + } + tx.execute_batch(include_str!("permissions/transfer.sql"))?; + // Seal every legacy writer. New execution binding evidence has no legacy-source FK and + // lives in the extension table, leaving published grants and their triggers intact. + for table in PERMISSION_FENCE_TABLES { + for action in ["INSERT", "UPDATE", "DELETE"] { + let deletion_exception = if action == "DELETE" { + " AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)" + } else { "" }; + tx.execute_batch(&format!( + "CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception} + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + ))?; + } + } + Ok(()) +} + const PERMISSION_FENCE_TABLES: [&str; 4] = [ "subject_permissions", "subject_permission_changes", @@ -196,7 +255,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [ "worker_execution_grants", ]; -// Frozen physical shape evidence, not target migrations. Permission SQL is from +// Published DDL is retained byte-for-byte as physical shape evidence. Only the permission +// fragment is also reused by the explicit separate-domain initializer above, never as migration 18. +// Permission SQL is from // 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs. // Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03: // crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql, diff --git a/crates/subjektiv/src/subject_creation.rs b/crates/subjektiv/src/subject_creation.rs index 38f169a7b..3ba7399df 100644 --- a/crates/subjektiv/src/subject_creation.rs +++ b/crates/subjektiv/src/subject_creation.rs @@ -1,6 +1,7 @@ -//! New Subjects receive their self-scoped conversation source in the creation transaction. -//! This path is never called by migration, launch, restore or permission reads. Receipts -//! retain only the issued identity and input fingerprint across erasure, preventing replay +//! Before sealing, new Subjects receive their self-scoped source in the creation transaction. +//! After sealing, creation retains enrollment evidence only; current reads never revive defaults. +//! This path is never called by migration, launch, restore or permission reads. Receipts retain +//! identity, input fingerprint and any post-seal enrollment across erasure, preventing replay //! from recreating an erased Subject or restoring a revoked policy. use super::*; use server_api::authorization::{SubjectPermissions, subject_conversation_permissions}; @@ -18,6 +19,22 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { } impl SubjektivStore { + /// Creation-only enrollment evidence for the canonical domain. Never a current permission + /// source or a read-time default; the Host must not apply it to an existing/unset policy. + pub fn subject_permission_enrollment(&self, subject_id: &str) -> Result> { + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + if !permissions::transfer::has_table(tx, "permission_authority_seal")? { + return Ok(None); + } + let raw: Option = tx.query_row( + "SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", + [subject_id], |row| row.get::<_, Option>(0), + ).optional()?.flatten(); + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)).transpose() + }) + } + pub(super) fn create_subject_request( &self, role: SubjectRole, @@ -60,10 +77,20 @@ impl SubjektivStore { execution: subject_conversation_permissions(self.scope_id(), &record.id), delegation: vec![], }; - tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", - params![record.id,source.change_id,serde_json::to_string(&source)?])?; - tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; - if let Some(operation) = operation_id { + let sealed = permissions::transfer::is_sealed(tx)?; + if !sealed { + tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", + params![record.id,source.change_id,serde_json::to_string(&source)?])?; + tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; + } + if sealed { + // Retain creation-only enrollment for the canonical domain, never a legacy + // source write or a read-time default. Even non-idempotent creation has evidence. + let enrollment_operation = operation_id.map(str::to_owned) + .unwrap_or_else(|| format!("subject-enrollment:{}", record.id)); + tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint,permission_enrollment_json) VALUES(?1,?2,?3,?4)", + params![enrollment_operation,record.id,fingerprint,serde_json::to_string(&source)?])?; + } else if let Some(operation) = operation_id { tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?; } Ok(record) diff --git a/crates/worker/src/feature/builtin/permission_catalog.rs b/crates/worker/src/feature/builtin/permission_catalog.rs new file mode 100644 index 000000000..2a026c1af --- /dev/null +++ b/crates/worker/src/feature/builtin/permission_catalog.rs @@ -0,0 +1,78 @@ +//! Server-side registration of the compiled builtin operation declarations. +//! +//! This is a trusted builtin loader, not a wire-catalog importer. Namespace +//! ownership is listed here explicitly; neither a permission spelling nor a +//! management request chooses an owner. The existing operation descriptors own +//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes +//! and actual operation authorization remain independent of this metadata load. + +use std::collections::BTreeMap; +use feature_permissions::{FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, + PermissionDefinition, PermissionHost, PermissionKey, PermissionReference, PermissionSetId, + SetContribution, WorkspaceId}; +use super::super::FeatureInstallError; + +const OWNERS: &[(&str, &str)] = &[ + ("filesystem", "builtin:workdir"), ("bash", "builtin:bash"), + ("workdir", "builtin:workdir"), ("worker", "builtin:worker"), + ("subworker", "builtin:sub-worker-control"), ("ticket", "builtin:ticket"), + ("objective", "builtin:objective"), ("merge_request", "builtin:merge-request"), + ("repository", "builtin:repository"), ("drive", "builtin:drive"), + ("workspace_config", "builtin:workspace-config"), ("memory", "builtin:memory"), + ("journal", "builtin:journal-recording"), ("chat", "builtin:subjektiv-chat"), + ("session", "builtin:session-explore"), ("web", "builtin:web"), + ("notification", "builtin:notification"), +]; + +pub fn builtin_permission_host(workspace: WorkspaceId) -> Result { + let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string()); + let mut host = PermissionHost::new(workspace); + let mut features = BTreeMap::new(); + let operations = server_api::authorization::subject_permission_operations(); + for (namespace, owner) in OWNERS { + let feature = match features.get(owner) { + Some(feature) => feature, + None => { + let feature = host.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?).map_err(|e| invalid(&e))?; + features.entry(owner).or_insert(feature) + }, + }; + let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?; + let handle = host.bind_namespace(feature, id.clone()).map_err(|e| invalid(&e))?; + let permissions = operations.iter().filter(|operation| operation.operation.split('.').next() == Some(namespace)) + .map(|operation| Ok(PermissionDefinition { + key:PermissionKey::parse(operation.operation.clone()).map_err(|e| invalid(&e))?, + description:operation.description.clone(),children:vec![], + })).collect::,FeatureInstallError>>()?; + let keys = permissions.iter().map(|definition| definition.key.clone()).collect(); + host.declare(&handle,NamespaceDeclaration { + permissions, + sets:vec![SetContribution { set:PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:id,keys})] }], + }).map_err(|e|invalid(&e))?; + } + // A new builtin operation needs an intentional namespace ownership decision. + for operation in operations { + if !OWNERS.iter().any(|(namespace,_)| operation.operation.split('.').next() == Some(*namespace)) { + return Err(FeatureInstallError::InvalidDescriptor(format!("builtin operation has no namespace owner: {}",operation.operation))); + } + } + Ok(host) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn builtin_keys_are_complete_exact_and_have_no_inferred_children() { + let host=builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); + let catalog=host.catalog(); + let mut keys=std::collections::BTreeSet::new(); + for namespace in catalog.namespaces { + for permission in namespace.declaration.permissions { + assert!(permission.children.is_empty()); + assert!(keys.insert(permission.key.as_str().to_owned())); + } + } + assert_eq!(keys,server_api::authorization::subject_permission_operations().into_iter().map(|o|o.operation).collect()); + } +} diff --git a/crates/worker/src/feature/permissions.rs b/crates/worker/src/feature/permissions.rs index ec9bdbd58..e19adc2d6 100644 --- a/crates/worker/src/feature/permissions.rs +++ b/crates/worker/src/feature/permissions.rs @@ -146,3 +146,7 @@ impl FeaturePermissionRegistry { #[cfg(test)] mod tests; + +#[path = "builtin/permission_catalog.rs"] +mod builtin_catalog; +pub use builtin_catalog::builtin_permission_host; diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 9a828dd41..388ef0447 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -21,6 +21,10 @@ axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } futures.workspace = true config-source.workspace = true +content-history.workspace = true +content-history-store.workspace = true +content-history-adapters.workspace = true +feature-permissions.workspace = true feature-storage.workspace = true workspace-drive.workspace = true subjektiv.workspace = true @@ -66,9 +70,6 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] } webauthn-rs-proto = "0.5.5" [dev-dependencies] -content-history.workspace = true -content-history-store.workspace = true -content-history-adapters.workspace = true rusqlite = { workspace = true, features = ["hooks"] } feature-storage = { workspace = true, features = ["test-support"] } native-tls = "0.2" diff --git a/crates/workspace-server/src/config_adoption.rs b/crates/workspace-server/src/config_adoption.rs new file mode 100644 index 000000000..7d2673f89 --- /dev/null +++ b/crates/workspace-server/src/config_adoption.rs @@ -0,0 +1,352 @@ +//! Owner-only adoption of Workspace source and Subject policy in the Server transaction. +//! +//! The old Feature database is exported and permanently sealed before its immutable +//! transfer is adopted here. That preparation is not a distributed commit: failure +//! after seal leaves an explicit, resumable transfer, never a legacy fallback. +//! Normal saves never touch the old database. Object preparation, source, +//! projection, policy, editing head and receipt commit in one caller-owned transaction. + +use std::collections::{BTreeMap, BTreeSet}; + +use config_source::{ConfigTreeSnapshot, WorkspaceConfigSchemaBundle}; +use content_history::HistoryId; +use content_history_store::{Access, Authority, ScopeKey, ScopedStore}; +use feature_permissions::PermissionHost; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{Deserialize, Serialize}; + +use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState}; +use crate::policy_config::{self, AdoptedSubjectPolicy, PermissionSource}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +pub(crate) const SCHEMA: &str = include_str!("config_adoption.sql"); +pub(crate) const MIGRATION_NAME: &str = "Atomic Workspace source and Subject policy adoption"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionRequest { + pub operation_id: String, + /// Editing history identity, not a content digest. Null is only the initial head. + pub expected_head: Option, + pub expected_catalog_digest: String, + pub expected_legacy_export: Option, + pub source: ConfigCommitRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimePolicyApplication { + NextCurrentPolicyCheck, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionResult { + pub workspace_id: String, + pub operation_id: String, + pub history_head: String, + pub state: WorkspaceConfigState, + pub source_stored: bool, + pub policy_committed: bool, + /// Consumers must reload current policy before their next protected operation. + /// This is not confirmation that a running Worker has stopped or reloaded. + pub runtime_application: RuntimePolicyApplication, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationPreview { + pub expected_head: Option, + pub state: WorkspaceConfigState, + pub changed_paths: Vec, + pub changed_subjects: Vec, +} + +/// Only the trusted migration boundary constructs this from a verified Feature +/// export. The transfer identity binds Workspace, physical DB, export and the full +/// adoption request. It is not an HTTP request or an authority credential. +pub(crate) struct ConfigurationPolicyTransfer { + pub transfer_identity: String, + pub physical_identity: String, + pub export_digest: String, + pub policies: BTreeMap, +} + +pub(crate) struct CanonicalConfigurationSource { + pub store: SqliteWorkspaceStore, + pub host: PermissionHost, +} + +impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource { + fn subject_permissions(&self, workspace: &str, subject: &str) -> subjektiv::Result> { + if self.host.catalog().workspace.as_str() != workspace { + return Err(subjektiv::SubjektivError::PermissionTransferConflict); + } + self.store.adopted_subject_policy(workspace, subject) + .and_then(|policy| policy.map(|policy| policy.legacy_read(&self.host)).transpose()) + .map_err(|error| subjektiv::SubjektivError::InvalidRecord(format!("canonical configuration policy unavailable: {error}"))) + } +} + +pub(crate) struct ConfigurationCurrent { + pub state: WorkspaceConfigState, + pub history_head: Option, + pub canonical: bool, + pub policies: BTreeMap, +} + +pub(crate) struct ConfigurationMigrationBaseline<'a> { + pub original: &'a WorkspaceConfigState, + pub state: &'a WorkspaceConfigState, + pub policies: &'a BTreeMap, + pub diagnostics: &'a [policy_config::MigrationDiagnostic], + pub export_digest: &'a str, +} + +struct OwnerScope<'a>(&'a str); +impl Authority for OwnerScope<'_> { + fn allows(&self, key: &ScopeKey, access: Access) -> bool { + key.workspace == self.0 && key.target == "configuration" + && matches!(access, Access::Read | Access::Write) + } +} + +fn content_error(e: impl std::fmt::Display) -> Error { + Error::Store(format!("configuration content: {e}")) +} +fn conflict(message: &str) -> Error { + Error::WorkspaceConfigConflict(message.into()) +} +fn json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| Error::Store(e.to_string())) +} +fn decode(value: &str) -> Result { + serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string())) +} +fn scope(workspace: &str) -> ScopeKey { + ScopeKey { workspace: workspace.into(), target: "configuration".into() } +} +fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> { + let allowed: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", + params![workspace, account], |r| r.get(0), + )?; + if !allowed { return Err(Error::WorkspacePermissionDenied("configuration management requires the Workspace owner".into())); } + Ok(()) +} + +pub(crate) fn head(conn: &Connection, workspace: &str) -> Result> { + let bytes: Option> = conn.query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", [workspace], |r| r.get(0), + ).optional()?.flatten(); + bytes.map(|bytes| bytes.try_into().map(HistoryId::from_bytes) + .map_err(|_| Error::RegistryInconsistency("invalid configuration editing head".into()))).transpose() +} + +pub(crate) fn request_fingerprint(workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id).map_err(Error::InvalidInput)?; + Ok(config_source::digest_bytes(json(&(workspace, account, request))?.as_bytes())) +} + +pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result { + Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |row| row.get(0))?) +} + +pub fn catalog_digest(host: &PermissionHost) -> Result { + Ok(config_source::digest_bytes(json(&host.catalog())?.as_bytes())) +} + +fn policies(conn: &Connection, workspace: &str) -> Result> { + let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?; + let mut result = BTreeMap::new(); + for row in query.query_map([workspace], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? { + let (subject, raw) = row?; + let policy: AdoptedSubjectPolicy = decode(&raw)?; + if subject != policy.source.subject_id { return Err(Error::RegistryInconsistency("Subject policy identity mismatch".into())); } + result.insert(subject, policy); + } + Ok(result) +} + +fn receipt(conn: &Connection, workspace: &str, operation: &str) -> Result> { + let row: Option<(String,String)> = conn.query_row( + "SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2", + params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)), + ).optional()?; + row.map(|(fingerprint,result)| Ok((fingerprint,decode(&result)?))).transpose() +} + +fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec { + before.entries.keys().chain(after.entries.keys()).collect::>().into_iter() + .filter(|path| before.entries.get(*path) != after.entries.get(*path)) + .map(|path| path.as_str().to_owned()).collect() +} +fn changed_subjects(before: &BTreeMap, after: &BTreeMap) -> Vec { + before.keys().chain(after.keys()).collect::>().into_iter() + .filter(|subject| before.get(*subject) != after.get(*subject)).cloned().collect() +} + +impl SqliteWorkspaceStore { + pub(crate) fn configuration_current(&self, workspace: &str, account: &str) -> Result { + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + require_owner(&tx, workspace, account)?; + Ok(ConfigurationCurrent { + state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?, + history_head: head(&tx, workspace)?.map(|id| id.to_string()), + canonical: canonical(&tx, workspace)?, + policies: policies(&tx, workspace)?, + }) + }) + } + + pub(crate) fn configuration_request_result(&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result> { + let fingerprint = request_fingerprint(workspace, account, request)?; + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + match receipt(conn, workspace, &request.operation_id)? { + Some((saved, result)) if saved == fingerprint => Ok(Some(result)), + Some(_) => Err(conflict("operation ID reused with another request")), + None => Ok(None), + } + }) + } + + /// Authentication supplies `account`; it is never accepted from an adoption DTO. + pub fn configuration_head(&self, workspace: &str, account: &str) -> Result> { + self.with_conn(|conn| { require_owner(conn, workspace, account)?; Ok(head(conn,workspace)?.map(|id|id.to_string())) }) + } + + /// Result lookup is a read. A historical receipt never reactivates policy. + pub fn configuration_adoption_result(&self, workspace: &str, account: &str, operation: &str) -> Result> { + self.with_conn(|conn| { require_owner(conn,workspace,account)?; Ok(receipt(conn,workspace,operation)?.map(|(_,result)|result)) }) + } + + pub(crate) fn preview_configuration_adoption( + &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + mut require_subject: impl FnMut(&str) -> Result<()>, + ) -> Result { + self.with_conn(|conn| { + require_owner(conn,workspace,account)?; + let tx = conn.unchecked_transaction()?; + let (current,state,source) = prepare(&tx,workspace,request,schema,host,baseline,&mut require_subject)?; + let before = match baseline { Some(baseline) => baseline.policies.clone(), None => policies(&tx,workspace)? }; + let mut after = before.clone(); + policy_config::apply(&mut after,&source,"preview"); + Ok(ConfigurationPreview { + expected_head: request.expected_head.clone(), + changed_paths: changed_paths(¤t.snapshot,&state.snapshot), + changed_subjects: changed_subjects(&before,&after), state, + }) + }) + } + + /// The transfer callback may seal the old DB only after all validation and + /// current-head checks. If subsequent Server persistence fails, that specific + /// transfer remains pending; callers reconcile/retry the same request, not a new ID. + pub(crate) fn adopt_configuration( + &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + mut require_subject: impl FnMut(&str) -> Result<()>, + mut prepare_transfer: impl FnMut(&str) -> Result, + ) -> Result { + let fingerprint = request_fingerprint(workspace, account, request)?; + self.with_immediate_transaction(|tx| { + require_owner(tx,workspace,account)?; + if let Some((saved,result)) = receipt(tx,workspace,&request.operation_id)? { + if saved != fingerprint { return Err(conflict("operation ID reused with another request")); } + return Ok(result); + } + let (_,state,source) = prepare(tx,workspace,request,schema,host,baseline,&mut require_subject)?; + let authority_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |r|r.get(0))?; + if authority_exists && (baseline.is_some() || request.expected_legacy_export.is_some()) { + return Err(conflict("legacy transfer is already adopted; reload current source")); + } + if !authority_exists && request.expected_legacy_export.is_none() { + return Err(conflict("legacy export identity must be observed before transfer")); + } + let mut current_policies = if authority_exists { policies(tx,workspace)? } else { + let transfer = prepare_transfer(&fingerprint)?; + if transfer.transfer_identity != fingerprint || request.expected_legacy_export.as_deref() != Some(transfer.export_digest.as_str()) { + return Err(conflict("transfer does not bind the complete adoption request and export")); + } + tx.execute("INSERT INTO workspace_configuration_authorities VALUES(?1,?2,?3,?4)",params![workspace,transfer.transfer_identity,transfer.physical_identity,transfer.export_digest])?; + transfer.policies + }; + // Client-selected operation IDs are not policy change identities: reusing + // an old legacy change ID must never make a stale child grant current. + policy_config::apply(&mut current_policies,&source,&format!("configuration:{fingerprint}")); + crate::config_source::sync_adopted_config_references(tx, workspace, &state)?; + let authority = OwnerScope(workspace); + let content = ScopedStore::new(tx,scope(workspace),&authority); + let prepared = content_history_adapters::settings::prepare(&state.snapshot).map_err(content_error)?; + for (id,object) in &prepared.objects { content.put(*id,&object.encode()).map_err(content_error)?; } + tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id=?1",[workspace])?; + crate::config_source::insert_materialized_state(tx,workspace,&state,&chrono::Utc::now().to_rfc3339())?; + tx.execute("DELETE FROM workspace_configuration_policies WHERE workspace_id=?1",[workspace])?; + for (subject,policy) in ¤t_policies { + tx.execute("INSERT INTO workspace_configuration_policies VALUES(?1,?2,?3)", params![workspace,subject,json(policy)?])?; + } + let new_head = content.adopt(prepared.root,head(tx,workspace)?,account.into(),chrono::Utc::now().timestamp_micros()).map_err(content_error)?; + let result = ConfigurationAdoptionResult { + workspace_id: workspace.into(), operation_id: request.operation_id.clone(), + history_head: new_head.to_string(), state, source_stored: true, policy_committed: true, + runtime_application: RuntimePolicyApplication::NextCurrentPolicyCheck, + }; + tx.execute("INSERT INTO workspace_configuration_receipts VALUES(?1,?2,?3,?4)",params![workspace,request.operation_id,fingerprint,json(&result)?])?; + Ok(result) + }) + } + + pub fn adopted_subject_policy(&self, workspace: &str, subject: &str) -> Result> { + self.with_conn(|conn| { + let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?; + if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); } + let raw: Option = conn.query_row("SELECT policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 AND subject_id=?2",params![workspace,subject],|r|r.get(0)).optional()?; + raw.map(|raw|decode(&raw)).transpose() + }) + } +} + +fn prepare( + tx: &Transaction<'_>, workspace: &str, request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + require_subject: &mut impl FnMut(&str) -> Result<()>, +) -> Result<(WorkspaceConfigState,WorkspaceConfigState,PermissionSource)> { + if let Some(baseline) = baseline { + if baseline.diagnostics.iter().any(|diagnostic| matches!(diagnostic, policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. })) { + return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into())); + } + } + if host.catalog().workspace.as_str() != workspace { + return Err(Error::WorkspacePermissionDenied("permission registry belongs to another Workspace".into())); + } + if catalog_digest(host)? != request.expected_catalog_digest { return Err(conflict("Feature catalog changed")); } + if head(tx,workspace)?.map(|id|id.to_string()) != request.expected_head { return Err(conflict("editing history head changed")); } + let current = crate::config_source::load_state(tx,workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; + let editing_base = if let Some(baseline) = baseline { + if *baseline.original != current || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) { + return Err(conflict("migration source or export changed")); + } + baseline.state + } else { ¤t }; + if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); } + crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?; + let candidate = crate::config_source::evaluate_candidate_in_transaction( + tx,workspace,editing_base.clone(),crate::config_source::load_evaluation_identity(tx,workspace)?, + &request.source.changes,schema, + )?; + crate::config_source::validate_workspace_config_candidate_projections(workspace,&candidate)?; + let source = policy_config::source(&candidate.evaluation)?; + policy_config::validate_source(host,&source,require_subject)?; + let state = WorkspaceConfigState { snapshot:candidate.snapshot,contract:candidate.contract,projection_digest:candidate.evaluation.projection_digest }; + Ok((current,state,source)) +} + +#[cfg(test)] +#[path = "config_adoption_tests.rs"] +mod tests; diff --git a/crates/workspace-server/src/config_adoption.sql b/crates/workspace-server/src/config_adoption.sql new file mode 100644 index 000000000..dfe967c8c --- /dev/null +++ b/crates/workspace-server/src/config_adoption.sql @@ -0,0 +1,31 @@ +CREATE TABLE workspace_configuration_authorities ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + transfer_identity TEXT NOT NULL, + legacy_physical_identity TEXT NOT NULL, + legacy_export_digest TEXT NOT NULL +); +CREATE TABLE workspace_configuration_policies ( + workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT, + subject_id TEXT NOT NULL, + policy_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, subject_id) +); +CREATE TABLE workspace_configuration_receipts ( + workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, operation_id) +); +CREATE TRIGGER workspace_configuration_authorities_no_update BEFORE UPDATE ON workspace_configuration_authorities BEGIN + SELECT RAISE(ABORT, 'configuration authority transfers are immutable'); +END; +CREATE TRIGGER workspace_configuration_authorities_no_delete BEFORE DELETE ON workspace_configuration_authorities BEGIN + SELECT RAISE(ABORT, 'configuration authority transfers are retained'); +END; +CREATE TRIGGER workspace_configuration_receipts_no_update BEFORE UPDATE ON workspace_configuration_receipts BEGIN + SELECT RAISE(ABORT, 'configuration adoption receipts are immutable'); +END; +CREATE TRIGGER workspace_configuration_receipts_no_delete BEFORE DELETE ON workspace_configuration_receipts BEGIN + SELECT RAISE(ABORT, 'configuration adoption receipts are retained'); +END; diff --git a/crates/workspace-server/src/config_adoption_tests.rs b/crates/workspace-server/src/config_adoption_tests.rs new file mode 100644 index 000000000..502ed7fad --- /dev/null +++ b/crates/workspace-server/src/config_adoption_tests.rs @@ -0,0 +1,155 @@ +//! Adoption component tests use the real Server SQLite transaction and content +//! store. The legacy transfer callback is empty in this fixture; physical Feature +//! transfer and authenticated HTTP are separate integration coverage. +use super::*; +use crate::config_source::{MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider}; +use crate::policy_config::PermissionConfigSchemaProvider; +use config_source::{ConfigTreeChange, VirtualPath}; +use feature_permissions::*; + +const WORKSPACE: &str = "adoption-ws"; +const OWNER: &str = "adoption-owner"; + +fn host(children: bool) -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()).unwrap(); + let namespace = host.bind_namespace(&feature,NamespaceId::parse("ticket").unwrap()).unwrap(); + host.declare(&namespace, NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { key: PermissionKey::parse("ticket.hoge").unwrap(), description:String::new(), children: if children {vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]} else {vec![]} }, + PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] }, + ], + sets: vec![SetContribution { set:PermissionSetId::parse("ticket").unwrap(),children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:NamespaceId::parse("ticket").unwrap(),keys:vec![PermissionKey::parse("ticket.hoge").unwrap()]})] }], + }).unwrap(); + host +} +fn schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ + PermissionConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider.contribution().unwrap(), + ]).unwrap() +} +fn open(path: &std::path::Path) -> SqliteWorkspaceStore { SqliteWorkspaceStore::open_for_test(path).unwrap() } +fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir.path().join("server.sqlite")); + db.with_immediate_transaction(|tx| { + tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?; + Ok(()) + }).unwrap(); + db.ensure_workspace_config_materialized_with_schema(WORKSPACE,"now",schema()).unwrap(); + (dir,db) +} +fn request(db: &SqliteWorkspaceStore, host: &PermissionHost, id: &str, source: Option<&str>) -> ConfigurationAdoptionRequest { + let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap(); + let changes = source.map(|source| vec![ConfigTreeChange::Update { + path: path.clone(), expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), content:source.into(), + }]).unwrap_or_default(); + ConfigurationAdoptionRequest { + operation_id:id.into(), expected_legacy_export:db.configuration_head(WORKSPACE,OWNER).unwrap().is_none().then(||"fixture-empty".into()), expected_head:db.configuration_head(WORKSPACE,OWNER).unwrap(),expected_catalog_digest:catalog_digest(host).unwrap(), + source:ConfigCommitRequest {base_digest:current.snapshot.digest,changes,entrypoints:vec![path]}, + } +} +fn adopt(db: &SqliteWorkspaceStore, host: &PermissionHost, request: &ConfigurationAdoptionRequest) -> Result { + db.adopt_configuration(WORKSPACE,OWNER,request,schema(),host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))}, |fingerprint|Ok(ConfigurationPolicyTransfer { + transfer_identity:fingerprint.into(),physical_identity:"fixture-physical".into(),export_digest:"fixture-empty".into(),policies:BTreeMap::new(), + })) +} +const A: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; +const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; + +#[test] +fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() { + let (_dir,db) = fixture(); let host=host(false); + let initial=request(&db,&host,"initial",Some(A)); + let first=adopt(&db,&host,&initial).unwrap(); + let stale=request(&db,&host,"stale-a",Some(B)); + adopt(&db,&host,&request(&db,&host,"to-b",Some(B))).unwrap(); + let final_a=adopt(&db,&host,&request(&db,&host,"back-a",Some(A))).unwrap(); + assert_eq!(first.state.snapshot,final_a.state.snapshot); + assert_ne!(first.history_head,final_a.history_head); + assert!(adopt(&db,&host,&stale).is_err()); + // Exact replay precedes stale checks and returns only the saved old result. + assert_eq!(adopt(&db,&host,&initial).unwrap(),first); + assert_eq!(db.configuration_adoption_result(WORKSPACE,OWNER,"initial").unwrap(),Some(first)); + assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),Some(final_a.history_head)); + let mut changed=initial; changed.source.changes.clear(); + assert!(adopt(&db,&host,&changed).is_err()); +} + +#[test] +fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() { + for (table,event) in [ + ("content_objects","INSERT"), ("workspace_config_entries","INSERT"), + ("workspace_configuration_policies","INSERT"), ("content_scopes","UPDATE"), + ("workspace_configuration_receipts","INSERT"), + ] { + let (dir,db)=fixture(); let host=host(false); + let before=adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); + let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); + let candidate=request(&db,&host,"failing",Some(B)); + db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap(); + assert!(adopt(&db,&host,&candidate).is_err(),"{table}"); + drop(db); + let reopened=open(&dir.path().join("server.sqlite")); + assert_eq!(reopened.load_workspace_config(WORKSPACE).unwrap(),Some(before.state),"{table}"); + assert_eq!(reopened.configuration_head(WORKSPACE,OWNER).unwrap(),Some(before.history_head),"{table}"); + assert_eq!(reopened.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),policy,"{table}"); + assert!(reopened.configuration_adoption_result(WORKSPACE,OWNER,"failing").unwrap().is_none(),"{table}"); + adopt(&reopened,&host,&candidate).unwrap(); + } +} + +#[test] +fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() { + let (_dir,db)=fixture(); let host=host(false); + let initial=db.load_workspace_config(WORKSPACE).unwrap(); + for source in [ + "not valid Decodal {", + r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#, + ] { + let request=request(&db,&host,"bad",Some(source)); + let result=db.adopt_configuration(WORKSPACE,OWNER,&request,schema(),&host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))},|_|panic!("validation failure must precede seal")); + assert!(result.is_err(),"{source}"); + assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(),initial); + assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),None); + } + let request=request(&db,&host,"other-owner",None); + assert!(db.adopt_configuration(WORKSPACE,"non-owner",&request,schema(),&host,None,|_|Ok(()),|_|panic!("owner refusal must precede seal")).is_err()); +} + +#[test] +fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() { + let (_dir,db)=fixture(); let host=host(false); + adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); + let original=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); + let preview=db.preview_configuration_adoption(WORKSPACE,OWNER,&request(&db,&host,"noop",None),schema(),&host,None,|_|Ok(())).unwrap(); + assert!(preview.changed_paths.is_empty()); + assert!(preview.changed_subjects.is_empty()); + adopt(&db,&host,&request(&db,&host,"noop",None)).unwrap(); + assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); + adopt(&db,&host,&request(&db,&host,"omit",Some("{} as WorkspaceConfigSchema"))).unwrap(); + assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); + adopt(&db,&host,&request(&db,&host,"remove",Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#))).unwrap(); + assert!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().is_none()); +} + +#[test] +fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() { + for children in [false,true] { + let (_dir,db)=fixture(); let host=host(children); + adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); + let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); + let resolution=host.resolve(Some(&policy.source.execution.assignment())); + assert_eq!(resolution.projection().allowed.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),children); + assert!(host.resolve(Some(&policy.source.delegation.assignment())).projection().allowed.is_empty()); + adopt(&db,&host,&request(&db,&host,"multi",Some(B))).unwrap(); + let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); + assert_eq!(policy.source.execution.namespaces[0].keys[0].as_str(),"ticket.hoge.fuga"); + } +} diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index b3de09449..9036a50ac 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -340,6 +340,9 @@ impl SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if crate::config_adoption::canonical(&tx, workspace_id)? { + return Err(config_conflict("configuration authority is adopted; use owner adoption with the observed editing head and operation ID")); + } let workspace_exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", [workspace_id], @@ -490,7 +493,7 @@ fn format_candidate_sources( } // Read persisted provenance, not load_state's upgraded in-memory contract. -fn load_evaluation_identity( +pub(crate) fn load_evaluation_identity( conn: &rusqlite::Connection, workspace_id: &str, ) -> Result<(String, String)> { @@ -509,7 +512,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool { .any(|provider| provider.provider_id == "builtin:plugins") } -fn evaluate_candidate_in_transaction( +pub(crate) fn evaluate_candidate_in_transaction( tx: &rusqlite::Transaction<'_>, workspace_id: &str, current: WorkspaceConfigState, @@ -566,6 +569,21 @@ fn evaluate_candidate( }) } +pub(crate) fn sync_adopted_config_references( + tx: &rusqlite::Transaction<'_>, workspace: &str, state: &WorkspaceConfigState, +) -> Result<()> { + if has_plugin_provider(&state.contract.schema_bundle) { + let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config( + tx, workspace, &state.snapshot, state.contract.schema_bundle.clone(), + )?; + if contract != state.contract || evaluation.projection_digest != state.projection_digest { + return Err(config_conflict("Plugin catalog/schema changed during adoption")); + } + crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?; + } + Ok(()) +} + pub(crate) fn load_state( conn: &rusqlite::Connection, workspace_id: &str, @@ -792,7 +810,7 @@ fn config_not_materialized() -> Error { Error::RegistryInconsistency("workspace config tree is not materialized".to_string()) } -fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> { +pub(crate) fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> { if entrypoints == [main_config_path()] { Ok(()) } else { diff --git a/crates/workspace-server/src/configuration_migration.rs b/crates/workspace-server/src/configuration_migration.rs new file mode 100644 index 000000000..e0cfd7c95 --- /dev/null +++ b/crates/workspace-server/src/configuration_migration.rs @@ -0,0 +1,440 @@ +//! Read-only preparation of an editable Workspace permission migration draft. +//! +//! The authenticated owner boundary lives in Server/core. This helper checks the +//! guarded stores and exact Workspace attribution, but neither saves the draft nor +//! seals the legacy writer. Adoption must check `original`, the actual editing +//! head and export identity/digest again, and reject blocking diagnostics before +//! sealing. A successful preview alone is not permission authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use config_source::{ + ConfigContentType, ConfigTreeChange, ConfigTreeSnapshot, SnapshotEnvironment, + ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, +}; +use feature_permissions::{NamespacePermissions, PermissionHost}; +use subjektiv::{PermissionTransferSnapshot, SubjektivStore}; + +use crate::config_source::{ + ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigState, + validate_workspace_config_candidate_projections, +}; +use crate::policy_config::{ + self, AdoptedSubjectPolicy, AssignmentSource, MigrationDiagnostic, PermissionSource, +}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +pub(crate) struct MigrationSeed { + pub original: WorkspaceConfigState, + pub state: WorkspaceConfigState, + pub snapshot: PermissionTransferSnapshot, + pub policies: BTreeMap, + pub diagnostics: Vec, +} + +pub(crate) fn preview_migration( + store: &SqliteWorkspaceStore, + legacy: &SubjektivStore, + workspace: &str, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, +) -> Result { + if legacy.scope_id() != workspace || host.catalog().workspace.as_str() != workspace { + return Err(Error::WorkspaceIdMismatch); + } + // This API validates native physical identity under the FeatureDatabase guard. + // Do not replace it with path inspection or SQL against the legacy database. + let identity = legacy.database_identity().map_err(legacy_error)?; + if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID { + return Err(Error::WorkspaceIdMismatch); + } + let original = store.with_conn(|conn| { + // with_conn also owns the managed Server catalog identity/retention guard. + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND state='active')", + [workspace], + |row| row.get(0), + )?; + if !exists { + return Err(Error::WorkspaceIdMismatch); + } + crate::config_source::load_state(conn, workspace)?.ok_or_else(|| { + Error::RegistryInconsistency("workspace config tree is not materialized".into()) + }) + })?; + let snapshot = legacy.preview_permission_transfer(&identity).map_err(legacy_error)?; + if snapshot.identity != identity { + return Err(Error::WorkspaceConfigConflict("legacy export identity changed".into())); + } + let (policies, diagnostics) = migrate_policies(host, &snapshot)?; + let source = PermissionSource { + subjects: policies.values().map(|policy| policy.source.clone()).collect(), + remove_subjects: vec![], + }; + let changes = draft_changes(&original, &source)?; + // Caller's real application provider bundle (including PromptSchema) is + // required: a migration-specific substitute cannot validate other settings. + let candidate = store.evaluate_workspace_config_candidate_with_schema( + workspace, + &ConfigCommitRequest { + base_digest: original.snapshot.digest.clone(), + changes, + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)?], + }, + schema, + )?; + validate_workspace_config_candidate_projections(workspace, &candidate)?; + let evaluated_source = policy_config::source(&candidate.evaluation)?; + if evaluated_source != source { + return Err(Error::InvalidInput( + "migration import did not produce the exact generated Subject assignments".into(), + )); + } + let subjects: BTreeSet<_> = snapshot.sources.iter().map(|item| item.subject_id.as_str()).collect(); + policy_config::validate_source(host, &evaluated_source, |subject| { + if subjects.contains(subject) { + Ok(()) + } else { + Err(Error::SubjektivSubjectNotFound(subject.into())) + } + })?; + let state = WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }; + Ok(MigrationSeed { original, state, snapshot, policies, diagnostics }) +} + +fn legacy_error(error: subjektiv::SubjektivError) -> Error { + Error::Store(format!("legacy permission migration preview: {error}")) +} + +fn path(value: &str) -> Result { + VirtualPath::parse(value).map_err(|error| Error::InvalidInput(error.to_string())) +} + +fn migrate_policies( + host: &PermissionHost, + snapshot: &PermissionTransferSnapshot, +) -> Result<(BTreeMap, Vec)> { + let mut policies = BTreeMap::new(); + let mut diagnostics = vec![]; + let mut seen = BTreeSet::new(); + for item in &snapshot.sources { + if !seen.insert(&item.subject_id) { + return Err(Error::RegistryInconsistency("duplicate Subject in legacy export".into())); + } + // None stays unset. In particular, never enroll it in a default Set. + let Some(legacy) = &item.permissions else { continue }; + let (policy, mut converted) = policy_config::migrate_subject(host, &item.subject_id, legacy)?; + policies.insert(item.subject_id.clone(), policy); + // ExplicitConfigurationRequired must survive preview, even though the + // unsupported operation is absent from the editable assignment. Core must + // block adoption on it; this is not a silently successful conversion. + diagnostics.append(&mut converted); + } + Ok((policies, diagnostics)) +} + +fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Result> { + // Evaluate with the existing open schema, not application defaults. This + // distinguishes an explicit `permissions = {}` (including imports/dotted + // fields) from an omitted field without introducing another Decodal parser. + let open_contract = ToolchainContract::with_schema_bundle( + original.contract.schema_version, + original.contract.entrypoints.clone(), + original.contract.import_policy_version, + WorkspaceConfigSchemaBundle::empty(), + ); + let raw = SnapshotEnvironment::new(original.snapshot.clone()) + .evaluate_contract(&open_contract) + .map_err(|diagnostics| Error::InvalidInput(format!( + "cannot inspect current configuration before permission migration: {diagnostics:?}" + )))?; + if raw.projections.iter().any(|projection| projection.data_json.get("permissions").is_some()) { + return Err(Error::InvalidInput( + "current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(), + )); + } + let generated_path = unused_source_path(&original.snapshot)?; + let main_path = path(MAIN_CONFIG_ENTRYPOINT)?; + let main = original.snapshot.get(&main_path).ok_or_else(|| { + Error::RegistryInconsistency("workspace config tree has no main.dcdl".into()) + })?; + if main.content_type != ConfigContentType::Decodal { + return Err(Error::InvalidInput("main.dcdl is not Decodal source".into())); + } + // Use the existing syntax formatter to supply a final field separator when + // needed. It preserves comments; no local lexer or alternate language rules. + let formatted_main = SnapshotEnvironment::new(original.snapshot.clone()) + .format(&main.content) + .map_err(|error| Error::InvalidInput(format!("cannot format migration entrypoint: {error}")))?; + Ok(vec![ + ConfigTreeChange::Create { + path: generated_path.clone(), + content_type: ConfigContentType::Decodal, + content: render_permission_source(source), + }, + ConfigTreeChange::Update { + path: main_path, + expected_digest: main.content_digest.clone(), + content: append_import(&formatted_main, &generated_path)?, + }, + ]) +} + +fn unused_source_path(snapshot: &ConfigTreeSnapshot) -> Result { + // With N entries, one of these N+1 names is unused. Never overwrite even an + // unreferenced preexisting migration file. + for suffix in 0..=snapshot.entries.len() { + let name = if suffix == 0 { + "permissions/migrated.dcdl".to_owned() + } else { + format!("permissions/migrated-{suffix}.dcdl") + }; + let candidate = path(&name)?; + if snapshot.get(&candidate).is_none() { + return Ok(candidate); + } + } + unreachable!("N entries cannot occupy N+1 distinct migration paths") +} + +fn append_import(source: &str, imported: &VirtualPath) -> Result { + // Materialized main.dcdl is a literal outer object with a trailing schema + // assertion. Limit this edit to that existing representation: no rewrites of + // arbitrary expressions, no JSON round-trip, no replacement of other fields. + let trimmed = source.trim_end(); + let object = trimmed.strip_suffix("as WorkspaceConfigSchema") + .map(str::trim_end).unwrap_or(trimmed); + if !object.starts_with('{') || !object.ends_with('}') { + return Err(Error::InvalidInput( + "permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(), + )); + } + let close = object.len() - 1; + let import = quote(&format!("./{}", imported.as_str())); + Ok(format!("{}\n permissions = import {import};\n{}", &source[..close], &source[close..])) +} + +// A deliberately specific, payload-free renderer. JSON quoting is used only for +// string literals, never as a renderer for the configuration/assignment objects. +fn quote(value: &str) -> String { + serde_json::to_string(value).expect("a string serializes") +} + +fn render_strings<'a>(values: impl IntoIterator) -> String { + format!("[{}]", values.into_iter().map(quote).collect::>().join(", ")) +} + +fn render_namespaces(values: &[NamespacePermissions]) -> String { + let items = values.iter().map(|value| format!( + "{{ namespace = {}; keys = {}; }}", + quote(value.namespace.as_str()), + render_strings(value.keys.iter().map(|key| key.as_str())), + )).collect::>(); + format!("[{}]", items.join(", ")) +} + +fn render_assignment(source: &AssignmentSource) -> String { + format!( + "{{ sets = {}; namespaces = {}; exclude_sets = {}; exclude_namespaces = {}; revoked = {}; }}", + render_strings(source.sets.iter().map(|set| set.as_str())), + render_namespaces(&source.namespaces), + render_strings(source.exclude_sets.iter().map(|set| set.as_str())), + render_namespaces(&source.exclude_namespaces), + source.revoked, + ) +} + +fn render_permission_source(source: &PermissionSource) -> String { + let subjects = source.subjects.iter().map(|item| format!( + " {{ subject_id = {}; execution = {}; delegation = {}; }}", + quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation), + )).collect::>().join(",\n"); + format!( + "{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n", + render_strings(source.remove_subjects.iter().map(String::as_str)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use config_source::{ConfigEntry, EvaluationResult}; + use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, + PermissionKey, PermissionSetId, WorkspaceId, + }; + use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions}; + use subjektiv::{DatabaseIdentity, PermissionSourceExport}; + + const WORKSPACE: &str = "migration-workspace"; + + fn host(children: bool) -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host.register_feature(FeatureIdentity::parse("builtin:file").unwrap()).unwrap(); + let namespace = host.bind_namespace(&feature, NamespaceId::parse("file").unwrap()).unwrap(); + host.declare(&namespace, NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("file.read").unwrap(), + description: String::new(), + children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] }, + }, + PermissionDefinition { + key: PermissionKey::parse("file.read.text").unwrap(), + description: String::new(), children: vec![], + }, + ], + sets: vec![], + }).unwrap(); + host + } + + fn limited(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), path: "only-this.txt".into(), recursive: false, + }, + } + } + + fn export(sources: Vec) -> PermissionTransferSnapshot { + PermissionTransferSnapshot { + identity: DatabaseIdentity { + scope_id: WORKSPACE.into(), feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), + physical_id: "typed-test-identity".into(), + }, + sources, export_digest: "not-used-by-pure-conversion-tests".into(), + } + } + + fn current(main: &str, extra: Vec) -> WorkspaceConfigState { + let mut current = crate::config_source::initial_state().unwrap(); + let mut entries = vec![ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, main, + ).unwrap()]; + entries.extend(extra); + current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap(); + current + } + + fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult { + let contract = crate::config_source::initial_state().unwrap().contract; + SnapshotEnvironment::new(snapshot).evaluate_contract(&contract).unwrap() + } + + #[test] + fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() { + let empty = SubjectPermissions { change_id: "explicit-empty".into(), execution: vec![], delegation: vec![] }; + let limited = SubjectPermissions { + change_id: "keep-limited-change".into(), execution: vec![limited("file.read")], + delegation: vec![limited("file.read.text")], + }; + let snapshot = export(vec![ + PermissionSourceExport { subject_id: "unset".into(), permissions: None }, + PermissionSourceExport { subject_id: "empty".into(), permissions: Some(empty.clone()) }, + PermissionSourceExport { subject_id: "limited".into(), permissions: Some(limited.clone()) }, + ]); + let host = host(false); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert!(!policies.contains_key("unset")); + assert_eq!(policies["empty"].change_id, empty.change_id); + assert_eq!(policies["empty"].source.execution, AssignmentSource::default()); + assert_eq!(policies["empty"].source.delegation, AssignmentSource::default()); + assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics.iter().all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))); + } + + #[test] + fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() { + for (host, permission) in [(host(false), limited("file.unknown")), (host(true), limited("file.read"))] { + let legacy = SubjectPermissions { + change_id: "keep-change".into(), execution: vec![permission.clone()], delegation: vec![], + }; + let snapshot = export(vec![PermissionSourceExport { subject_id: "blocked".into(), permissions: Some(legacy.clone()) }]); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert_eq!(diagnostics, vec![MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: "blocked".into(), permission, + }]); + assert_eq!(policies["blocked"].legacy_constraints, Some(legacy)); + assert!(policies["blocked"].source.execution.namespaces.is_empty()); + assert!(policies["blocked"].source.execution.sets.is_empty()); + } + } + + #[test] + fn renderer_round_trips_sets_namespaces_exclusions_revocation_and_quoted_strings() { + let assignment = AssignmentSource { + sets: vec![PermissionSetId::parse("shared").unwrap()], + namespaces: vec![NamespacePermissions { + namespace: NamespaceId::parse("file").unwrap(), + keys: vec![PermissionKey::parse("file.read.text").unwrap()], + }], + exclude_sets: vec![PermissionSetId::parse("excluded").unwrap()], + exclude_namespaces: vec![NamespacePermissions { + namespace: NamespaceId::parse("file").unwrap(), + keys: vec![PermissionKey::parse("file.read").unwrap()], + }], + revoked: true, + }; + let source = PermissionSource { + subjects: vec![policy_config::SubjectAssignmentSource { + subject_id: "subject-\"quoted\\日本語".into(), execution: assignment, + delegation: AssignmentSource::default(), + }], + remove_subjects: vec!["removed".into()], + }; + let rendered = render_permission_source(&source); + assert!(rendered.contains("sets = [\"shared\"]")); + assert!(rendered.contains("keys = [\"file.read.text\"]")); + assert!(!rendered.contains("legacy_constraints")); + let snapshot = ConfigTreeSnapshot::from_entries([ + ConfigEntry::new(path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, + format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema")).unwrap(), + ]).unwrap(); + assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source); + } + + #[test] + fn draft_preserves_main_comments_other_settings_and_existing_migration_files() { + let main = "{\n // permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; + let occupied = ConfigEntry::new(path("permissions/migrated.dcdl").unwrap(), ConfigContentType::Decodal, "{ keep = true; }\n").unwrap(); + let original = current(main, vec![occupied.clone()]); + let source = PermissionSource::default(); + let changes = draft_changes(&original, &source).unwrap(); + let draft = original.snapshot.apply(&changes).unwrap(); + assert_eq!(draft.get(&occupied.path), Some(&occupied)); + assert!(draft.get(&path("permissions/migrated-1.dcdl").unwrap()).is_some()); + let updated_main = &draft.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content; + assert!(updated_main.contains("// permissions in a comment are not a field")); + assert!(updated_main.contains("brace } and permissions")); + assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); + assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n")); + let before = evaluate(original.snapshot.clone()); + let after = evaluate(draft); + assert_eq!(after.projections[0].data_json["other"], before.projections[0].data_json["other"]); + assert_eq!(policy_config::source(&after).unwrap(), source); + assert_eq!(original.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content, main); + } + + #[test] + fn existing_explicit_permissions_are_not_merged_or_discarded_even_when_empty() { + for main in [ + "{ permissions = {}; } as WorkspaceConfigSchema", + "{ permissions.subjects = []; } as WorkspaceConfigSchema", + "{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema", + ] { + let existing = ConfigEntry::new(path("permissions/existing.dcdl").unwrap(), ConfigContentType::Decodal, "{ subjects = []; }").unwrap(); + let original = current(main, vec![existing]); + let before = original.clone(); + let error = draft_changes(&original, &PermissionSource::default()).unwrap_err(); + assert!(error.to_string().contains("explicitly defines permissions"), "{error}"); + assert_eq!(original, before); + } + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 26e411c0b..6113fc670 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -5,6 +5,9 @@ pub mod authority; pub mod backend_job; pub mod companion; pub mod config; +pub mod config_adoption; +mod configuration_migration; +pub mod policy_config; pub mod config_source; #[cfg(test)] mod content_history_tests; diff --git a/crates/workspace-server/src/policy_config.rs b/crates/workspace-server/src/policy_config.rs new file mode 100644 index 000000000..165c3eef7 --- /dev/null +++ b/crates/workspace-server/src/policy_config.rs @@ -0,0 +1,270 @@ +//! Subject assignments in the existing Workspace virtual configuration. +//! +//! An omitted Subject is unchanged; removal is explicit. Assignments contain only +//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions +//! remain separate domain constraints, never payloads on permission keys. + +use std::collections::{BTreeMap, BTreeSet}; + +use config_source::{ConfigSchemaContribution, EvaluationResult}; +use feature_permissions::{ + NamespaceId, NamespacePermissions, PermissionAssignment, PermissionDiagnostic, PermissionHost, + PermissionKey, PermissionReference, PermissionSetId, +}; +use serde::{Deserialize, Serialize}; +use server_api::authorization::{ScopedPermission, SubjectPermissions}; + +use crate::config_source::WorkspaceConfigSchemaProvider; +use crate::{Error, Result}; + +const SCHEMA: &str = r#"{ + permissions = { + subjects = [...{ + subject_id = String; + execution = { + sets = [...String] default []; + namespaces = [...{ namespace = String; keys = [...String]; }] default []; + exclude_sets = [...String] default []; + exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default []; + revoked = Bool default false; + } default {}; + delegation = { + sets = [...String] default []; + namespaces = [...{ namespace = String; keys = [...String]; }] default []; + exclude_sets = [...String] default []; + exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default []; + revoked = Bool default false; + } default {}; + }] default []; + remove_subjects = [...String] default []; + }; +}"#; + +#[derive(Debug, Default)] +pub struct PermissionConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for PermissionConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new("builtin:permissions", "permissions", "1", SCHEMA) + .map_err(|e| Error::Config(e.to_string())) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AssignmentSource { + #[serde(default)] + pub sets: Vec, + #[serde(default)] + pub namespaces: Vec, + #[serde(default)] + pub exclude_sets: Vec, + #[serde(default)] + pub exclude_namespaces: Vec, + #[serde(default)] + pub revoked: bool, +} + +impl AssignmentSource { + pub fn assignment(&self) -> PermissionAssignment { + let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| { + sets.iter().cloned().map(PermissionReference::Set) + .chain(namespaces.iter().cloned().map(PermissionReference::Namespace)).collect() + }; + PermissionAssignment { + grants: references(&self.sets, &self.namespaces), + exclusions: references(&self.exclude_sets, &self.exclude_namespaces), + revoked: self.revoked, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectAssignmentSource { + pub subject_id: String, + #[serde(default)] + pub execution: AssignmentSource, + #[serde(default)] + pub delegation: AssignmentSource, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionSource { + #[serde(default)] + pub subjects: Vec, + #[serde(default)] + pub remove_subjects: Vec, +} + +/// Retained exact legacy restrictions belong to the domain, not the editable source. +/// Keeping the original change identity preserves the distinction between live and +/// already stale child grants during representation-only migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdoptedSubjectPolicy { + pub source: SubjectAssignmentSource, + pub change_id: String, + pub legacy_constraints: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)] +pub enum MigrationDiagnostic { + Converted { subject_id: String, operation: String }, + DomainConstraint { subject_id: String, permission: ScopedPermission }, + ExplicitConfigurationRequired { subject_id: String, permission: ScopedPermission }, +} + +pub fn source(evaluation: &EvaluationResult) -> Result { + let value = evaluation.projections.first() + .and_then(|p| p.data_json.get("permissions")) + .cloned().unwrap_or_else(|| serde_json::json!({})); + serde_json::from_value(value).map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) +} + +fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> { + // Validate even revoked assignments. A revocation must not hide a misspelled + // reference which would become active on a later edit. + let mut assignment = source.assignment(); + assignment.revoked = false; + let resolution = host.resolve(Some(&assignment)); + if resolution.projection().diagnostics.iter().any(|d| matches!(d, + PermissionDiagnostic::UnknownKey(_) | PermissionDiagnostic::UnknownNamespace(_) + | PermissionDiagnostic::UnknownSet(_))) { + return Err(Error::InvalidInput(format!("invalid permission references: {:?}", resolution.projection().diagnostics))); + } + Ok(()) +} + +pub fn validate_source( + host: &PermissionHost, + source: &PermissionSource, + mut require_subject: impl FnMut(&str) -> Result<()>, +) -> Result<()> { + let mut subjects = BTreeSet::new(); + for item in &source.subjects { + server_api::authorization::validate_authorization_identifier(&item.subject_id) + .map_err(Error::InvalidInput)?; + if !subjects.insert(&item.subject_id) { + return Err(Error::InvalidInput("duplicate Subject assignment".into())); + } + require_subject(&item.subject_id)?; + validate_assignment(host, &item.execution)?; + validate_assignment(host, &item.delegation)?; + } + for subject in &source.remove_subjects { + server_api::authorization::validate_authorization_identifier(subject) + .map_err(Error::InvalidInput)?; + if !subjects.insert(subject) { + return Err(Error::InvalidInput("Subject both assigned and removed, or removed twice".into())); + } + require_subject(subject)?; + } + Ok(()) +} + +/// A patch deliberately does not replace the entire policy map. In particular a +/// schema default empty list never deletes omitted policies or reinstates defaults. +pub fn apply( + policies: &mut BTreeMap, + source: &PermissionSource, + change_id: &str, +) { + for subject in &source.remove_subjects { policies.remove(subject); } + for item in &source.subjects { + match policies.get_mut(&item.subject_id) { + Some(previous) if previous.source == *item => {}, + Some(previous) => { + previous.source = item.clone(); + previous.change_id = change_id.to_owned(); + }, + None => { + policies.insert(item.subject_id.clone(), AdoptedSubjectPolicy { + source: item.clone(), change_id: change_id.to_owned(), legacy_constraints: None, + }); + }, + } + } +} + +/// Convert exact legacy operations without expanding explicit children. Restrictions +/// which cannot be represented by the owning domain must be diagnosed before seal. +/// This routine does not issue a grant or install a policy. +pub fn migrate_subject( + host: &PermissionHost, + subject_id: &str, + legacy: &SubjectPermissions, +) -> Result<(AdoptedSubjectPolicy, Vec)> { + let mut diagnostics = vec![]; + let mut lane = |permissions: &[ScopedPermission]| -> Result { + let mut by_namespace: BTreeMap> = BTreeMap::new(); + for permission in permissions { + let operation = server_api::authorization::canonical_worker_operation(&permission.operation).unwrap_or(&permission.operation); + let key = PermissionKey::parse(operation.to_owned()).map_err(Error::InvalidInput)?; + let assignment = PermissionAssignment { grants: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: key.namespace(), keys: vec![key.clone()], + })], exclusions: vec![], revoked: false }; + let resolution = host.resolve(Some(&assignment)); + let exact = resolution.projection().diagnostics.is_empty() + && resolution.projection().allowed.len() == 1 + && resolution.projection().allowed.contains_key(&key); + if !exact { + diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: subject_id.into(), permission: permission.clone(), + }); + continue; + } + by_namespace.entry(key.namespace()).or_default().insert(key); + diagnostics.push(MigrationDiagnostic::DomainConstraint { + subject_id: subject_id.into(), permission: permission.clone(), + }); + } + Ok(AssignmentSource { namespaces: by_namespace.into_iter().map(|(namespace, keys)| NamespacePermissions { + namespace, keys: keys.into_iter().collect(), + }).collect(), ..Default::default() }) + }; + let execution = lane(&legacy.execution)?; + let delegation = lane(&legacy.delegation)?; + Ok((AdoptedSubjectPolicy { + source: SubjectAssignmentSource { subject_id: subject_id.into(), execution, delegation }, + change_id: legacy.change_id.clone(), legacy_constraints: Some(legacy.clone()), + }, diagnostics)) +} + +impl AdoptedSubjectPolicy { + /// Read bridge for the old scoped-operation consumer. New payload-free + /// consumers resolve `source` through the current Feature Host and also enforce + /// retained domain constraints. This bridge cannot turn a key into an all-target + /// legacy resource: an operation without a representable constraint is unavailable. + pub fn legacy_read(&self, host: &PermissionHost) -> Result { + let execution = host.resolve(Some(&self.source.execution.assignment())); + let delegation = host.resolve(Some(&self.source.delegation.assignment())); + let filter = |allowed: &feature_permissions::PermissionResolution, constraints: &[ScopedPermission]| -> Result> { + let mut represented = BTreeSet::new(); + let mut output = vec![]; + for permission in constraints { + let operation = server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); + if let Ok(key) = PermissionKey::parse(operation.to_owned()) { + if allowed.allowed.contains_key(&key) { + represented.insert(key); + output.push(permission.clone()); + } + } + } + if allowed.allowed.keys().any(|key| !represented.contains(key)) { + return Err(Error::InvalidInput("current key policy requires the payload-free operation consumer; no legacy all-target fallback is available".into())); + } + Ok(output) + }; + let empty = SubjectPermissions { change_id:self.change_id.clone(),execution:vec![],delegation:vec![] }; + let retained = self.legacy_constraints.as_ref().unwrap_or(&empty); + Ok(SubjectPermissions { + change_id:self.change_id.clone(), + execution:filter(execution.projection(),&retained.execution)?, + delegation:filter(delegation.projection(),&retained.delegation)?, + }) + } +} diff --git a/crates/workspace-server/src/storage_format_tests.rs b/crates/workspace-server/src/storage_format_tests.rs index 762839881..d523da6a6 100644 --- a/crates/workspace-server/src/storage_format_tests.rs +++ b/crates/workspace-server/src/storage_format_tests.rs @@ -110,8 +110,10 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) { verify_history(conn, current).unwrap(); if current >= 114 { let plan = published_plan(conn, current).unwrap().unwrap(); - assert!(plan.migrations.is_empty()); - assert_eq!(plan.target_schema_version, current); + assert_eq!(plan.migrations.len(), 1); + assert_eq!(plan.migrations[0].version, 122); + assert_eq!(plan.migrations[0].name, crate::config_adoption::MIGRATION_NAME); + assert_eq!(plan.target_schema_version, 122); } assert_eq!(rows, saved_rows(conn)); assert_eq!(shape, snapshot(conn).unwrap()); @@ -195,11 +197,16 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad let conn = Connection::open(&path).unwrap(); // Production store owner, not a separate test migration implementation. super::super::prepare_connection(&conn).unwrap(); - assert_eq!( - super::super::current_schema_version(&conn).unwrap(), - current - ); - assert_eq!(saved_rows(&conn), expected); + assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122); + for (table, rows) in &expected { + if table != "__yoi_schema_migrations" { + assert_eq!(saved_rows(&conn).into_iter().find(|(name, _)| name == table).unwrap().1, *rows, "{table}, from {current}"); + } + } + let mut history = super::super::workspace_schema_migration_history(&conn).unwrap(); + assert_eq!(history.pop(), Some((122, crate::config_adoption::MIGRATION_NAME.into()))); + assert_eq!(history, super::super::workspace_schema_migration_history(&source).unwrap()); + verify_history(&conn, 122).unwrap(); } } } @@ -290,7 +297,7 @@ fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabe let (_temp, conn) = retained_to(103, alternate); let original = super::super::workspace_schema_migration_history(&conn).unwrap(); super::super::prepare_connection(&conn).unwrap(); - verify_history(&conn, 113).unwrap(); + verify_history(&conn, super::super::LATEST_SCHEMA_VERSION).unwrap(); let updated = super::super::workspace_schema_migration_history(&conn).unwrap(); assert_eq!(&updated[..original.len()], &original); let name: String = conn diff --git a/crates/workspace-server/src/storage_formats.rs b/crates/workspace-server/src/storage_formats.rs index 29a057301..2a797c995 100644 --- a/crates/workspace-server/src/storage_formats.rs +++ b/crates/workspace-server/src/storage_formats.rs @@ -71,6 +71,26 @@ fn invalid(message: impl std::fmt::Display) -> Error { /// Every other row, gap, mixed family, unknown suffix and missing object fails. pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> { let rows = super::workspace_schema_migration_history(conn)?; + verify_history_records(conn, current, &rows) +} + +fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> { + if current == 122 { + verify_configuration_shape(conn)?; + if rows == [(122, super::SCHEMA_BASELINE_NAME.to_owned())] { + require_shape(conn, Stage::Base)?; + return verify_103_features(conn, current, rows); + } + if rows.last().map(|(version, name)| (*version, name.as_str())) + != Some((122, crate::config_adoption::MIGRATION_NAME)) + { + return Err(invalid("unrecognized configuration adoption receipt")); + } + let prefix = &rows[..rows.len() - 1]; + let prior = prefix.last().map(|row| row.0).ok_or_else(|| invalid("missing configuration predecessor"))?; + if !(113..=121).contains(&prior) { return Err(invalid("invalid configuration predecessor")); } + return verify_history_records(conn, prior, prefix); + } let Some((baseline, name)) = rows.first() else { return Err(invalid("missing migration receipts")); }; @@ -191,11 +211,28 @@ pub(super) fn published_plan( verify_history(conn, current)?; Ok(Some(super::WorkspaceSchemaMigrationPlan { current_schema_version: current, - target_schema_version: current, - migrations: vec![], + target_schema_version: 122, + migrations: if current < 122 { + vec![super::WorkspaceSchemaMigrationStep { + version: 122, + name: crate::config_adoption::MIGRATION_NAME.to_owned(), + }] + } else { vec![] }, })) } +fn verify_configuration_shape(conn: &Connection) -> Result<()> { + let expected_db = Connection::open_in_memory()?; + expected_db.execute_batch(content_history_store::SCHEMA)?; + expected_db.execute_batch(crate::config_adoption::SCHEMA)?; + let expected = snapshot(&expected_db)?; + let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect(); + let actual: Shape = snapshot(conn)?.into_iter() + .filter(|(_, object)| tables.contains(&object.1)).collect(); + if actual != expected { return Err(invalid("configuration adoption receipt/shape mismatch")); } + Ok(()) +} + // sqlite_schema programs, not table/column presence alone. SQL literal bytes, // CHECK/FK clauses, indexes and trigger programs remain part of the contract. type Shape = BTreeMap)>; @@ -233,7 +270,7 @@ fn reference_base() -> Result { merge_request::migrate(&conn).map_err(invalid)?; conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; let tx = conn.unchecked_transaction()?; - super::create_latest_workspace_schema(&tx)?; + super::create_workspace_schema_through_v113(&tx)?; tx.commit()?; Ok(conn) } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index da7709d40..83ea0f1a2 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50; #[path = "storage_formats.rs"] mod storage_formats; -const LATEST_SCHEMA_VERSION: i64 = 113; +const LATEST_SCHEMA_VERSION: i64 = 122; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Ticket work-decomposition parent links", apply: migrate_ticket_parents_v112_to_v113, }, + Migration { + version: 122, + name: crate::config_adoption::MIGRATION_NAME, + apply: migrate_configuration_adoption_to_v122, + }, ]; // Both schema-103 identities shipped. Keep the alternate implementation and accept @@ -16069,6 +16074,20 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if !(113..=121).contains(¤t) { + return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into())); + } + storage_formats::verify_history(conn, current)?; + let tx = conn.unchecked_transaction()?; + tx.execute_batch(content_history_store::SCHEMA)?; + tx.execute_batch(crate::config_adoption::SCHEMA)?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", [crate::config_adoption::MIGRATION_NAME])?; + tx.commit()?; + Ok(()) +} + fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 112 { return Err(Error::Store( @@ -16165,6 +16184,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> { } fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + create_workspace_schema_through_v113(conn)?; + conn.execute_batch(content_history_store::SCHEMA)?; + conn.execute_batch(crate::config_adoption::SCHEMA)?; + Ok(()) +} + +fn create_workspace_schema_through_v113(conn: &Connection) -> Result<()> { create_workspace_schema_structure(conn)?; assignment_delivery::install(conn)?; assignment_delivery::install_post_inputs(conn)?; @@ -17083,7 +17109,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result Result<()> { - if current > LATEST_SCHEMA_VERSION { + if current > 113 { return storage_formats::verify_history(conn, current); } let rows = workspace_schema_migration_history(conn)?; @@ -18878,9 +18904,13 @@ mod tests { #[test] fn current_schema_accepts_every_retained_canonical_provenance() { - for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION { + for baseline_version in (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + create_latest_workspace_schema(&conn).unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", params![baseline_version, SCHEMA_BASELINE_NAME], From 9f8aad79a3c4b1b8274c2b905ae4c01db2d6b761 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 16:54:54 +0900 Subject: [PATCH 2/6] feat: connect owner configuration adoption and guarded policy cutover Integration checkpoint for T-838, not a completed reviewed handoff. Includes published T-840 startup dependencies. Root check, API artifacts and focused HTTP/cutover tests executed; Unicode renderer roundtrip still fails on the separately owned Decodal 0.4.0 issue (T-830). Web validation/visual review, broad suites and independent review remain outstanding. --- crates/server-api/README.md | 1 + ...generate_configuration_adoption_openapi.rs | 7 +- .../generate_configuration_adoption_types.rs | 11 +- .../server-api/src/configuration_adoption.rs | 182 +- crates/server-api/src/lib.rs | 3 +- crates/subjektiv/src/deletion.rs | 5 +- crates/subjektiv/src/lib.rs | 9 +- crates/subjektiv/src/permissions.rs | 8 +- crates/subjektiv/src/permissions/transfer.rs | 265 ++- .../src/permissions/transfer/tests.rs | 792 +++++++- crates/subjektiv/src/storage_format_tests.rs | 124 +- crates/subjektiv/src/storage_formats.rs | 25 +- crates/subjektiv/src/subject_creation.rs | 5 +- .../src/feature/builtin/permission_catalog.rs | 122 +- crates/workspace-server/README.md | 1 + .../workspace-server/src/config_adoption.rs | 312 ++- .../src/config_adoption_tests.rs | 428 +++- crates/workspace-server/src/config_source.rs | 92 +- .../src/configuration_migration.rs | 319 ++- .../src/configuration_transfer_tests.rs | 1062 ++++++++++ crates/workspace-server/src/lib.rs | 6 +- crates/workspace-server/src/policy_config.rs | 189 +- crates/workspace-server/src/server.rs | 50 +- .../src/server_configuration_adoption.rs | 523 +++++ .../server_configuration_adoption_tests.rs | 945 +++++++++ .../src/server_workspace_config.rs | 38 +- .../src/storage_format_tests.rs | 25 +- .../workspace-server/src/storage_formats.rs | 23 +- crates/workspace-server/src/store.rs | 16 +- openapi/configuration-adoption-api.json | 1776 +++++++++++++++++ openapi/feature-permissions-api.json | 17 +- web/workspace/deno.json | 2 +- .../configuration-adoption.generated.ts | 30 + .../ConfigSourceEditor.browser.spec.ts | 216 +- .../config-source/ConfigSourceEditor.svelte | 232 ++- .../config-source/adoption-api.test.ts | 143 ++ .../src/lib/workspace/config-source/api.ts | 184 +- web/workspace/test/config-source/api.test.ts | 216 +- .../test/config-source/editor-state.test.ts | 61 +- 39 files changed, 7647 insertions(+), 818 deletions(-) create mode 100644 crates/workspace-server/src/configuration_transfer_tests.rs create mode 100644 crates/workspace-server/src/server_configuration_adoption.rs create mode 100644 crates/workspace-server/src/server_configuration_adoption_tests.rs create mode 100644 openapi/configuration-adoption-api.json create mode 100644 web/workspace/src/lib/generated/configuration-adoption.generated.ts create mode 100644 web/workspace/src/lib/workspace/config-source/adoption-api.test.ts diff --git a/crates/server-api/README.md b/crates/server-api/README.md index ec5e41c1a..5ee2b587e 100644 --- a/crates/server-api/README.md +++ b/crates/server-api/README.md @@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API - [`ServerApi`] — HTTP operations and transport metadata - [`ServerRequestContext`] — Request context passed to Server authentication adapters +- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation - [`RequestActor`] — Human identity and authentication method - [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result diff --git a/crates/server-api/examples/generate_configuration_adoption_openapi.rs b/crates/server-api/examples/generate_configuration_adoption_openapi.rs index fbc58b1d5..882f50fb4 100644 --- a/crates/server-api/examples/generate_configuration_adoption_openapi.rs +++ b/crates/server-api/examples/generate_configuration_adoption_openapi.rs @@ -1,4 +1,7 @@ -fn main() -> Result<(),Box> { - print!("{}", server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?); +fn main() -> Result<(), Box> { + print!( + "{}", + server_api::configuration_adoption::configuration_contract_openapi()?.to_json()? + ); Ok(()) } diff --git a/crates/server-api/examples/generate_configuration_adoption_types.rs b/crates/server-api/examples/generate_configuration_adoption_types.rs index fe0798e49..b1e136cb8 100644 --- a/crates/server-api/examples/generate_configuration_adoption_types.rs +++ b/crates/server-api/examples/generate_configuration_adoption_types.rs @@ -1,8 +1,13 @@ -#[cfg(feature="typescript")] +#[cfg(feature = "typescript")] fn main() { - print!("{}",server_api::normalized_typescript(server_api::configuration_adoption::configuration_typescript())); + print!( + "{}", + server_api::normalized_typescript( + server_api::configuration_adoption::configuration_typescript() + ) + ); } -#[cfg(not(feature="typescript"))] +#[cfg(not(feature = "typescript"))] fn main() { eprintln!("generate_configuration_adoption_types requires --features typescript"); std::process::exit(2); diff --git a/crates/server-api/src/configuration_adoption.rs b/crates/server-api/src/configuration_adoption.rs index 6ea7b75cc..5fb8c0325 100644 --- a/crates/server-api/src/configuration_adoption.rs +++ b/crates/server-api/src/configuration_adoption.rs @@ -1,13 +1,15 @@ //! Workspace-owner source adoption transport. The Server authenticates the owner //! before reading source, previewing, sealing migration data, adopting or reconciling. //! A result lookup is not a new adoption and absence is not proof of failure. -use crate::{ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse}; +use crate::{ + ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse, +}; use feature_permissions::{PermissionAssignment, PermissionCatalog}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationAdoptionRequest { pub operation_id: String, @@ -18,90 +20,125 @@ pub struct ConfigurationAdoptionRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(rename_all="snake_case")] -pub enum RuntimePolicyApplication { NextCurrentPolicyCheck } +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimePolicyApplication { + NextCurrentPolicyCheck, +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationAdoptionReceipt { - pub workspace_id:String, - pub operation_id:String, - pub history_head:String, - pub state:WorkspaceConfigTreeResponse, - pub source_stored:bool, - pub policy_committed:bool, - pub runtime_application:RuntimePolicyApplication, + pub workspace_id: String, + pub operation_id: String, + pub history_head: String, + pub state: WorkspaceConfigTreeResponse, + pub source_stored: bool, + pub policy_committed: bool, + pub runtime_application: RuntimePolicyApplication, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationSubjectPolicy { - pub subject_id:String, - pub change_id:String, - pub execution:PermissionAssignment, - pub delegation:PermissionAssignment, + pub subject_id: String, + pub change_id: String, + pub execution: PermissionAssignment, + pub delegation: PermissionAssignment, /// Exact retained domain restrictions, not editable payloads on permission keys. - pub legacy_constraints:Option, + pub legacy_constraints: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(tag="kind",rename_all="snake_case",deny_unknown_fields)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ConfigurationMigrationDiagnostic { - Converted {subject_id:String,operation:String}, - DomainConstraint {subject_id:String,permission:crate::authorization::ScopedPermission}, - ExplicitConfigurationRequired {subject_id:String,permission:crate::authorization::ScopedPermission}, + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationSourceResponse { /// Before the first adoption this is an editable, evaluated migration draft. /// source_stored distinguishes it from the unchanged stored source. - pub state:WorkspaceConfigTreeResponse, - pub history_head:Option, - pub source_stored:bool, - pub catalog:PermissionCatalog, - pub catalog_digest:String, - pub expected_legacy_export:Option, - pub policies:Vec, - pub migration_diagnostics:Vec, + pub state: WorkspaceConfigTreeResponse, + pub history_head: Option, + pub source_stored: bool, + pub catalog: PermissionCatalog, + pub catalog_digest: String, + pub expected_legacy_export: Option, + pub policies: Vec, + pub migration_diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationPreviewResponse { - pub expected_head:Option, - pub state:WorkspaceConfigTreeResponse, - pub changed_paths:Vec, - pub changed_subjects:Vec, + pub expected_head: Option, + pub state: WorkspaceConfigTreeResponse, + pub changed_paths: Vec, + pub changed_subjects: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(tag="status",rename_all="snake_case",deny_unknown_fields)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] pub enum ConfigurationReconciliation { - Adopted {receipt:ConfigurationAdoptionReceipt}, - PendingTransfer {transfer_identity:String,export_digest:String}, + Adopted { + receipt: ConfigurationAdoptionReceipt, + }, + PendingTransfer { + transfer_identity: String, + export_digest: String, + }, /// No matching evidence was observed. This is not permission to change the ID. NotObserved, } -#[api_macros::api(reqwest,axum,openapi)] +#[api_macros::api(reqwest, axum, openapi)] pub trait ConfigurationAdoptionApi { #[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)] - async fn configuration_source(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String)->Result; + async fn configuration_source( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_preview(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_preview( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_adopt(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_adopt( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_reconcile(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_reconcile( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; } impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {} @@ -110,25 +147,46 @@ impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {} impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {} impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {} -pub fn configuration_contract_openapi() -> Result { - use sha2::{Digest,Sha256}; - let document=|source_digest:&str| configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { - title:"Workspace configuration adoption",version:env!("CARGO_PKG_VERSION"),source_digest, - }); - let normalized=document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?.to_json()?; - let digest:String=Sha256::digest(normalized.as_bytes()).iter().map(|byte|format!("{byte:02x}")).collect(); +pub fn configuration_contract_openapi() +-> Result { + use sha2::{Digest, Sha256}; + let document = |source_digest: &str| { + configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Workspace configuration adoption", + version: env!("CARGO_PKG_VERSION"), + source_digest, + }) + }; + let normalized = + document("sha256:0000000000000000000000000000000000000000000000000000000000000000")? + .to_json()?; + let digest: String = Sha256::digest(normalized.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); document(&format!("sha256:{digest}")) } -#[cfg(feature="typescript")] +#[cfg(feature = "typescript")] pub fn configuration_typescript() -> String { use ts_rs::TS; - let config=ts_rs::Config::default(); - let declarations=[ - ConfigurationAdoptionRequest::decl(&config), RuntimePolicyApplication::decl(&config), - ConfigurationAdoptionReceipt::decl(&config), ConfigurationSubjectPolicy::decl(&config), - ConfigurationMigrationDiagnostic::decl(&config), ConfigurationSourceResponse::decl(&config), - ConfigurationPreviewResponse::decl(&config), ConfigurationReconciliation::decl(&config), + let config = ts_rs::Config::default(); + let declarations = [ + ConfigurationAdoptionRequest::decl(&config), + RuntimePolicyApplication::decl(&config), + ConfigurationAdoptionReceipt::decl(&config), + ConfigurationSubjectPolicy::decl(&config), + ConfigurationMigrationDiagnostic::decl(&config), + ConfigurationSourceResponse::decl(&config), + ConfigurationPreviewResponse::decl(&config), + ConfigurationReconciliation::decl(&config), ]; - format!("// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",declarations.into_iter().map(|declaration|format!("export {declaration}")).collect::>().join("\n\n")) + format!( + "// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e9a8b77f3..fcc212361 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,9 +8,10 @@ pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod authorization; mod chat; -pub mod feature_permissions; pub mod configuration_adoption; +pub mod feature_permissions; pub use chat::*; +pub use configuration_adoption::*; mod subject_body; pub use subject_body::*; mod chat_transport; diff --git a/crates/subjektiv/src/deletion.rs b/crates/subjektiv/src/deletion.rs index fb2d4c6c2..5b964f9e8 100644 --- a/crates/subjektiv/src/deletion.rs +++ b/crates/subjektiv/src/deletion.rs @@ -407,7 +407,10 @@ impl SubjektivStore { )?; } if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { - tx.execute("DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; + tx.execute( + "DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", + [subject], + )?; } for table in DIRECT { tx.execute( diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index e425a8818..4fbe7e2b1 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -9,8 +9,8 @@ pub mod journal; mod legacy_migrations; pub mod permissions; pub use permissions::{ - CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, - PermissionTransferReceipt, PermissionTransferSnapshot, + CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt, + PermissionTransferSnapshot, }; mod schema; mod subject_creation; @@ -755,7 +755,10 @@ impl SubjektivStore { } } })?; - Ok(Self { database, canonical_permission_source: None }) + Ok(Self { + database, + canonical_permission_source: None, + }) } pub fn scope_id(&self) -> &str { diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs index 38f3f6f91..007fbc9ee 100644 --- a/crates/subjektiv/src/permissions.rs +++ b/crates/subjektiv/src/permissions.rs @@ -14,7 +14,7 @@ use sha2::{Digest, Sha256}; pub(crate) mod transfer; pub use transfer::{ - CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, + CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport, PermissionTransferReceipt, PermissionTransferSnapshot, }; @@ -238,11 +238,7 @@ fn grant_in_connection( "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)" }; let raw: Option = connection - .query_row( - sql, - params![runtime, worker], - |row| row.get(0), - ) + .query_row(sql, params![runtime, worker], |row| row.get(0)) .optional()?; raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) .transpose() diff --git a/crates/subjektiv/src/permissions/transfer.rs b/crates/subjektiv/src/permissions/transfer.rs index 059da3e61..aa2ce06c5 100644 --- a/crates/subjektiv/src/permissions/transfer.rs +++ b/crates/subjektiv/src/permissions/transfer.rs @@ -1,5 +1,7 @@ //! One-way preparation of a permission-domain transfer. No Server adoption or dual commit. use super::*; +use rusqlite::TransactionBehavior; +use std::cell::RefCell; use std::sync::Arc; /// Host connection to the canonical permission domain, not an ACL or grant issuer. @@ -69,8 +71,95 @@ pub struct PermissionTransferReceipt { pub snapshot: PermissionTransferSnapshot, } +/// Short-lived Feature authority fence for a synchronous Host operation. +/// +/// The IMMEDIATE transaction is acquired before the Host enters its Server transaction, +/// serializing a legacy logical-source check through Server commit against cutover even +/// across processes. Sealing commits only the Feature transaction, not the Server operation. +/// The outer native connection/lifecycle guard remains held until the action returns; +/// callbacks must not reenter this store, including through a canonical source provider. +/// No raw connection or physical storage path is exposed. +pub struct PermissionAuthorityGuard<'connection> { + transaction: RefCell>>, + expected: DatabaseIdentity, +} + +impl PermissionAuthorityGuard<'_> { + /// Gate a legacy logical-source writer before entering its Server transaction. + /// The physical write lock remains held through the enclosing action's return. + pub fn require_unsealed(&self) -> Result<()> { + let transaction = self.transaction.borrow(); + let tx = transaction + .as_ref() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + require_unsealed(tx) + } + + /// Validate Subject metadata and deletion fences only, without canonical-provider calls. + /// All such validation must precede sealing; this guard is unusable after a seal attempt. + pub fn require_subject(&self, subject: &str) -> Result<()> { + validate_authorization_identifier(subject).map_err(SubjektivError::InvalidRecord)?; + let transaction = self.transaction.borrow(); + let tx = transaction + .as_ref() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + require_subject_in_connection(tx, subject)?; + Ok(()) + } + + /// Commit the exact Feature seal independently of the enclosing action's result. + /// Exact receipt retries use the same identity, fingerprint and export checks, and + /// commit their read-only transaction too. Failure drops the transaction to roll back; + /// success releases the SQLite write lock but not the outer native connection guard. + pub fn seal_permission_sources( + &self, + transfer_id: &str, + expected_export_digest: &str, + ) -> Result { + let tx = self + .transaction + .borrow_mut() + .take() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + let receipt = + seal_in_transaction(&tx, &self.expected, transfer_id, expected_export_digest)?; + tx.commit()?; + Ok(receipt) + } +} + impl SubjektivStore { - pub fn with_canonical_permission_source(mut self, source: Arc) -> Self { + /// Hold the native Feature connection/lifecycle fence across a synchronous Host action. + /// An IMMEDIATE Feature transaction is entered before invoking the action (and thus before + /// any nested Server transaction). Without a seal it rolls back on every return path. + /// A successful seal survives even an action error: this is not a distributed transaction. + /// Callbacks must use this guard for validation and must not reenter Feature storage. + /// Hosts can retain Server error/status types by returning `Ok(server_result)` as `T`. + pub fn with_permission_authority_guard( + &self, + expected: &DatabaseIdentity, + action: impl FnOnce(&PermissionAuthorityGuard<'_>) -> Result, + ) -> Result { + if self.database.identity() != &expected.storage_identity() { + return Err(SubjektivError::PermissionTransferConflict); + } + // try_with_connection verifies the native physical identity before/after the action + // and retains its connection mutex and lifecycle lease even after the seal commits. + self.database.try_with_connection(|connection| { + let tx = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?; + require_store_scope(&tx, self.scope_id())?; + let guard = PermissionAuthorityGuard { + transaction: RefCell::new(Some(tx)), + expected: expected.clone(), + }; + action(&guard) + }) + } + + pub fn with_canonical_permission_source( + mut self, + source: Arc, + ) -> Self { self.canonical_permission_source = Some(source); self } @@ -83,7 +172,10 @@ impl SubjektivStore { /// Reads one coherent pre-seal snapshot without installing schema or changing authority. /// After a transfer, returns the retained exact snapshot, never a changed/deleted live set. - pub fn preview_permission_transfer(&self, expected: &DatabaseIdentity) -> Result { + pub fn preview_permission_transfer( + &self, + expected: &DatabaseIdentity, + ) -> Result { self.permission_transfer_transaction(expected, |tx| { if let Some(receipt) = retained_receipt(tx)? { require_receipt_identity(&receipt, expected)?; @@ -94,6 +186,22 @@ impl SubjektivStore { }) } + /// Observe saved transfer evidence without exporting, sealing or changing policy. + pub fn permission_transfer_receipt( + &self, + expected: &DatabaseIdentity, + ) -> Result> { + self.permission_transfer_transaction(expected, |tx| { + let receipt = retained_receipt(tx)?; + if let Some(receipt) = &receipt { + require_receipt_identity(receipt, expected)?; + } else { + require_unsealed(tx)?; + } + Ok(receipt) + }) + } + /// Permanently seals the old source writer after preview/CAS. All changes, the exact export /// and its identity-bound receipt commit in one old-DB IMMEDIATE transaction. The Host must /// authorize/preview first and bind transfer_id to the complete Server request fingerprint. @@ -104,31 +212,8 @@ impl SubjektivStore { transfer_id: &str, expected_export_digest: &str, ) -> Result { - validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?; self.permission_transfer_transaction(expected, |tx| { - if let Some(receipt) = retained_receipt(tx)? { - require_receipt_identity(&receipt, expected)?; - if receipt.transfer_id != transfer_id || receipt.snapshot.export_digest != expected_export_digest { - return Err(SubjektivError::PermissionTransferConflict); - } - return Ok(receipt); - } - // A published Permission18 seal is not ours to rewrite or relabel as this receipt. - require_unsealed(tx)?; - let snapshot = export_in_connection(tx, expected.clone())?; - if snapshot.export_digest != expected_export_digest { - return Err(SubjektivError::PermissionTransferConflict); - } - crate::storage_formats::install_permission_transfer(tx)?; - if !has_table(tx, "permission_source_transfer_receipt")? { - return Err(SubjektivError::InvalidRecord("permission transfer requires schema 1..17".into())); - } - let receipt = PermissionTransferReceipt { transfer_id: transfer_id.into(), snapshot }; - let raw = serde_json::to_string(&receipt)?; - tx.execute("INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)", - params![transfer_id, serde_json::to_string(&receipt.snapshot)?])?; - tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?; - Ok(receipt) + seal_in_transaction(tx, expected, transfer_id, expected_export_digest) }) } @@ -150,7 +235,11 @@ impl SubjektivStore { }) } - pub(super) fn current_permission_source(&self, connection: &Connection, subject: &str) -> Result> { + pub(super) fn current_permission_source( + &self, + connection: &Connection, + subject: &str, + ) -> Result> { if !is_sealed(connection)? { return source_in_connection(connection, subject); } @@ -158,7 +247,8 @@ impl SubjektivStore { .ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?; let source = provider.subject_permissions(self.scope_id(), subject)?; if let Some(source) = &source { - validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; validate_scope(self.scope_id(), &source.execution)?; validate_scope(self.scope_id(), &source.delegation)?; } @@ -166,51 +256,123 @@ impl SubjektivStore { } } +/// Shared exact seal/retry logic. The caller owns the transaction and its commit boundary. +fn seal_in_transaction( + tx: &Transaction<'_>, + expected: &DatabaseIdentity, + transfer_id: &str, + expected_export_digest: &str, +) -> Result { + validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?; + if let Some(receipt) = retained_receipt(tx)? { + require_receipt_identity(&receipt, expected)?; + if receipt.transfer_id != transfer_id + || receipt.snapshot.export_digest != expected_export_digest + { + return Err(SubjektivError::PermissionTransferConflict); + } + return Ok(receipt); + } + // A published Permission18 seal is not ours to rewrite or relabel as this receipt. + require_unsealed(tx)?; + let snapshot = export_in_connection(tx, expected.clone())?; + if snapshot.export_digest != expected_export_digest { + return Err(SubjektivError::PermissionTransferConflict); + } + crate::storage_formats::install_permission_transfer(tx)?; + if !has_table(tx, "permission_source_transfer_receipt")? { + return Err(SubjektivError::InvalidRecord( + "permission transfer requires schema 1..17".into(), + )); + } + let receipt = PermissionTransferReceipt { + transfer_id: transfer_id.into(), + snapshot, + }; + let raw = serde_json::to_string(&receipt)?; + tx.execute( + "INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)", + params![transfer_id, serde_json::to_string(&receipt.snapshot)?], + )?; + tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?; + Ok(receipt) +} + pub(crate) fn has_table(connection: &Connection, name: &str) -> Result { - Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", [name], |row| row.get(0))?) + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", + [name], + |row| row.get(0), + )?) } pub(crate) fn is_sealed(connection: &Connection) -> Result { - if !has_table(connection, "permission_authority_seal")? { return Ok(false); } - Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", [], |row| row.get(0))?) + if !has_table(connection, "permission_authority_seal")? { + return Ok(false); + } + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", + [], + |row| row.get(0), + )?) } pub(super) fn require_unsealed(connection: &Connection) -> Result<()> { - if is_sealed(connection)? { return Err(SubjektivError::PermissionAuthoritySealed); } + if is_sealed(connection)? { + return Err(SubjektivError::PermissionAuthoritySealed); + } Ok(()) } fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> { let stored: String = connection.query_row( "SELECT workspace_id FROM store_scope WHERE singleton=1", - [], |row| row.get(0), + [], + |row| row.get(0), )?; - if stored != scope { return Err(SubjektivError::PermissionTransferConflict); } + if stored != scope { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(()) } -fn require_receipt_identity(receipt: &PermissionTransferReceipt, expected: &DatabaseIdentity) -> Result<()> { - if receipt.snapshot.identity != *expected { return Err(SubjektivError::PermissionTransferConflict); } +fn require_receipt_identity( + receipt: &PermissionTransferReceipt, + expected: &DatabaseIdentity, +) -> Result<()> { + if receipt.snapshot.identity != *expected { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(()) } fn retained_receipt(connection: &Connection) -> Result> { - if !has_table(connection, "permission_source_transfer_receipt")? { return Ok(None); } + if !has_table(connection, "permission_source_transfer_receipt")? { + return Ok(None); + } let raw: Option = connection.query_row( "SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1", [], |row| row.get(0), ).optional()?; - let receipt: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; - if receipt.is_some() && !is_sealed(connection)? { return Err(SubjektivError::PermissionTransferConflict); } + let receipt: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if receipt.is_some() && !is_sealed(connection)? { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(receipt) } -fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> Result { +fn export_in_connection( + connection: &Connection, + identity: DatabaseIdentity, +) -> Result { let orphaned: bool = connection.query_row( "SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)", [], |row| row.get(0), )?; - if orphaned { return Err(SubjektivError::PermissionTransferConflict); } + if orphaned { + return Err(SubjektivError::PermissionTransferConflict); + } let mut statement = connection.prepare( "SELECT s.subject_id,p.change_id,c.record_json FROM subjects s LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id @@ -224,20 +386,31 @@ fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?; let change: Option = row.get(1)?; let raw: Option = row.get(2)?; - let permissions: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + let permissions: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() { return Err(SubjektivError::PermissionTransferConflict); } if let Some(source) = &permissions { - validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; validate_scope(&identity.scope_id, &source.execution)?; validate_scope(&identity.scope_id, &source.delegation)?; } - sources.push(PermissionSourceExport { subject_id, permissions }); + sources.push(PermissionSourceExport { + subject_id, + permissions, + }); } let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?) - .iter().map(|byte| format!("{byte:02x}")).collect(); - Ok(PermissionTransferSnapshot { identity, sources, export_digest }) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(PermissionTransferSnapshot { + identity, + sources, + export_digest, + }) } #[cfg(test)] diff --git a/crates/subjektiv/src/permissions/transfer/tests.rs b/crates/subjektiv/src/permissions/transfer/tests.rs index 57d94a12e..e237136c0 100644 --- a/crates/subjektiv/src/permissions/transfer/tests.rs +++ b/crates/subjektiv/src/permissions/transfer/tests.rs @@ -1,22 +1,37 @@ //! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim. use super::*; use crate::tests::{open_store, role}; -use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant}; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant, +}; use std::collections::BTreeMap; use std::sync::{Barrier, Mutex}; fn read_permission() -> ScopedPermission { ScopedPermission { operation: "file.read".into(), - resource: PermissionResource::Workdir { working_directory_id: "wd".into(), path: ".".into(), recursive: true }, + resource: PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: ".".into(), + recursive: true, + }, } } -fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: Vec) -> (SubjectPermissionsUpdate, SubjectPermissions) { +fn replace( + store: &SubjektivStore, + subject: &str, + operation: &str, + delegation: Vec, +) -> (SubjectPermissionsUpdate, SubjectPermissions) { let update = SubjectPermissionsUpdate { operation_id: operation.into(), - expected_change_id: store.subject_permissions(subject).unwrap().map(|source| source.change_id), - execution: vec![], delegation, + expected_change_id: store + .subject_permissions(subject) + .unwrap() + .map(|source| source.change_id), + execution: vec![], + delegation, }; let source = store.set_subject_permissions(subject, &update).unwrap(); (update, source) @@ -24,25 +39,52 @@ fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: V fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { WorkerExecutionGrant { - subject_id: subject.into(), source_change_id: source.into(), - runtime_id: "runtime".into(), worker_id: worker.into(), - origin_runtime_id: "runtime".into(), origin_worker_id: "host-authenticated-root".into(), - profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), - permissions: vec![read_permission()], redelegation: vec![], + subject_id: subject.into(), + source_change_id: source.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "host-authenticated-root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![read_permission()], + redelegation: vec![], } } fn counts(store: &SubjektivStore) -> Vec { - store.database.try_with_connection::<_, SubjektivError>(|connection| { - ["subject_permissions", "subject_permission_changes", "subject_permission_receipts", "worker_execution_grants"] - .iter().map(|table| Ok(connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?)).collect() - }).unwrap() + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", + ] + .iter() + .map(|table| { + Ok( + connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + })?, + ) + }) + .collect() + }) + .unwrap() } fn seal(store: &SubjektivStore) -> PermissionTransferReceipt { let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); - store.seal_permission_sources(&identity, "server-request-fingerprint", &preview.export_digest).unwrap() + store + .seal_permission_sources( + &identity, + "server-request-fingerprint", + &preview.export_digest, + ) + .unwrap() } #[test] @@ -55,11 +97,14 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon assert_eq!(identity.feature_id, native.feature); assert_eq!(identity.physical_id, native.physical); let encoded = serde_json::to_value(&identity).unwrap(); - assert_eq!(encoded, serde_json::json!({ - "scope_id": native.scope, - "feature_id": native.feature, - "physical_id": native.physical, - })); + assert_eq!( + encoded, + serde_json::json!({ + "scope_id": native.scope, + "feature_id": native.feature, + "physical_id": native.physical, + }) + ); let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap(); let restored = decoded.storage_identity(); assert_eq!(restored.scope, native.scope); @@ -75,13 +120,36 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon "physical" => wrong.physical_id.push_str("-different"), _ => unreachable!(), } - assert!(matches!(store.preview_permission_transfer(&wrong), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.permission_transfer_transaction::<()>(&wrong, |_| { - panic!("an identity mismatch must not enter the SQL transaction body") - }), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.preview_permission_transfer(&wrong), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.with_permission_authority_guard::<()>(&wrong, |_| { + panic!("an identity mismatch must not enter the guarded action") + }), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.permission_transfer_transaction::<()>(&wrong, |_| { + panic!("an identity mismatch must not enter the SQL transaction body") + }), + Err(SubjektivError::PermissionTransferConflict) + )); } - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); } #[test] @@ -91,48 +159,131 @@ fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_ let unset = store.create_subject(role()).unwrap().id; let empty = store.create_subject(role()).unwrap().id; let limited = store.create_subject(role()).unwrap().id; - store.database.try_transaction::<_, SubjektivError>(|tx| { - tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&unset])?; - Ok(()) - }).unwrap(); + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset], + )?; + Ok(()) + }) + .unwrap(); let (_, empty_source) = replace(&store, &empty, "empty", vec![]); - let (old_update, limited_source) = replace(&store, &limited, "limited", vec![read_permission()]); + let (old_update, limited_source) = + replace(&store, &limited, "limited", vec![read_permission()]); let grant = child(&limited, &limited_source.change_id, "historical-child"); store.bind_worker_execution_grant(&grant).unwrap(); let before = counts(&store); let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); assert_eq!(preview.sources.len(), 3); - assert!(preview.sources.windows(2).all(|pair| pair[0].subject_id < pair[1].subject_id)); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == unset).unwrap().permissions, None); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == empty).unwrap().permissions, Some(empty_source)); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == limited).unwrap().permissions, Some(limited_source.clone())); - assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + assert!( + preview + .sources + .windows(2) + .all(|pair| pair[0].subject_id < pair[1].subject_id) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == unset) + .unwrap() + .permissions, + None + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == empty) + .unwrap() + .permissions, + Some(empty_source) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == limited) + .unwrap() + .permissions, + Some(limited_source.clone()) + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); // Preview is physically read-only, including the domain schema. - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); - let receipt = store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap(); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); + let receipt = store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap(); assert_eq!(receipt.snapshot, preview); assert_eq!(counts(&store), before); - assert_eq!(store.worker_execution_grant("runtime", "historical-child").unwrap(), Some(grant.clone())); + assert_eq!( + store + .worker_execution_grant("runtime", "historical-child") + .unwrap(), + Some(grant.clone()) + ); store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority. - assert!(matches!(store.set_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.set_managed_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.seal_permission_sources(&identity, "different", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.seal_permission_sources(&identity, "transfer", "different-export"), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.set_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.set_managed_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "different", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", "different-export"), + Err(SubjektivError::PermissionTransferConflict) + )); let mut wrong_identity = identity.clone(); wrong_identity.scope_id = "workspace-b".into(); - assert!(matches!(store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.preview_permission_transfer(&wrong_identity), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.preview_permission_transfer(&wrong_identity), + Err(SubjektivError::PermissionTransferConflict) + )); drop(store); scope.shutdown().unwrap(); drop(scope); drop(manager); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); assert_eq!(identity, store.database_identity().unwrap()); - assert_eq!(receipt, store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap()); - assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + assert_eq!( + receipt, + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap() + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); assert_eq!(counts(&store), before); - assert!(matches!(store.subject_permissions(&limited), Err(SubjektivError::AuthorizationDenied(_)))); + assert!(matches!( + store.subject_permissions(&limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); } #[test] @@ -143,13 +294,31 @@ fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); replace(&store, &subject, "old-writer-after-preview", vec![]); - assert!(matches!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); replace(&store, &subject, "still-writable", vec![]); let fresh = store.preview_permission_transfer(&identity).unwrap(); assert_ne!(fresh.export_digest, preview.export_digest); - store.seal_permission_sources(&identity, "new-request", &fresh.export_digest).unwrap(); + store + .seal_permission_sources(&identity, "new-request", &fresh.export_digest) + .unwrap(); } #[test] @@ -159,14 +328,32 @@ fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() { let subject = store.create_subject(role()).unwrap().id; let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); - store.database.transaction(crate::storage_formats::install_permission_transfer).unwrap(); + store + .database + .transaction(crate::storage_formats::install_permission_transfer) + .unwrap(); store.database.try_with_connection::<_, SubjektivError>(|connection| { connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; Ok(()) }).unwrap(); - assert!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest).is_err()); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)).unwrap().is_none()); + assert!( + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .is_err() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)) + .unwrap() + .is_none() + ); replace(&store, &subject, "writer-after-rollback", vec![]); drop(store); scope.shutdown().unwrap(); @@ -182,29 +369,70 @@ struct Canonical { calls: Mutex>, } impl CanonicalPermissionSource for Canonical { - fn subject_permissions(&self, workspace: &str, subject: &str) -> Result> { - self.calls.lock().unwrap().push((workspace.into(), subject.into())); - if workspace != "workspace-a" { return Err(denied("foreign Workspace")); } - self.sources.get(subject).cloned().ok_or_else(|| denied("canonical source unavailable")) + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> Result> { + self.calls + .lock() + .unwrap() + .push((workspace.into(), subject.into())); + if workspace != "workspace-a" { + return Err(denied("foreign Workspace")); + } + self.sources + .get(subject) + .cloned() + .ok_or_else(|| denied("canonical source unavailable")) } } #[test] -fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() { +fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() + { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; let (_, old) = replace(&store, &subject, "old", vec![read_permission()]); let receipt = seal(&store); - assert!(matches!(store.subject_permissions(&subject), Err(SubjektivError::AuthorizationDenied(_)))); - assert!(matches!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), Err(SubjektivError::AuthorizationDenied(_)))); + assert!(matches!( + store.subject_permissions(&subject), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), + Err(SubjektivError::AuthorizationDenied(_)) + )); let before = counts(&store); - let created = store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap(); - assert_eq!(created, store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap()); + let created = store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap(); + assert_eq!( + created, + store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap() + ); let non_idempotent = store.create_subject(role()).unwrap(); - assert!(store.subject_permission_enrollment(&created.id).unwrap().is_some()); - assert!(store.subject_permission_enrollment(&non_idempotent.id).unwrap().is_some()); - assert!(store.subject_permission_enrollment(&subject).unwrap().is_none()); + assert!( + store + .subject_permission_enrollment(&created.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&non_idempotent.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&subject) + .unwrap() + .is_none() + ); assert_eq!(counts(&store), before); store.database.try_with_connection::<_, SubjektivError>(|connection| { for id in [&created.id, &non_idempotent.id] { @@ -215,35 +443,103 @@ fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_ } Ok(()) }).unwrap(); - let canonical = SubjectPermissions { change_id: "canonical-change".into(), execution: vec![], delegation: vec![read_permission()] }; + let canonical = SubjectPermissions { + change_id: "canonical-change".into(), + execution: vec![], + delegation: vec![read_permission()], + }; let provider = Arc::new(Canonical { - sources: BTreeMap::from([(subject.clone(), Some(canonical.clone())), (created.id.clone(), None)]), + sources: BTreeMap::from([ + (subject.clone(), Some(canonical.clone())), + (created.id.clone(), None), + ]), calls: Mutex::new(vec![]), }); let store = store.with_canonical_permission_source(provider.clone()); - assert_eq!(store.subject_permissions(&subject).unwrap(), Some(canonical.clone())); + assert_eq!( + store.subject_permissions(&subject).unwrap(), + Some(canonical.clone()) + ); assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default. assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates. - assert!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")).is_err()); - store.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")).unwrap(); - assert_eq!(store.list_subject_worker_execution_grants(&subject).unwrap(), vec![child(&subject, &canonical.change_id, "canonical-child")]); - assert_eq!(store.worker_execution_grant("runtime", "canonical-child").unwrap(), Some(child(&subject, &canonical.change_id, "canonical-child"))); + assert!( + store + .bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")) + .is_err() + ); + store + .bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")) + .unwrap(); + assert_eq!( + store + .list_subject_worker_execution_grants(&subject) + .unwrap(), + vec![child(&subject, &canonical.change_id, "canonical-child")] + ); + assert_eq!( + store + .worker_execution_grant("runtime", "canonical-child") + .unwrap(), + Some(child(&subject, &canonical.change_id, "canonical-child")) + ); for sql in [ "INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants", "UPDATE canonical_worker_execution_grants SET source_change_id='forged'", "DELETE FROM canonical_worker_execution_grants", ] { - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err()); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err() + ); } - assert!(provider.calls.lock().unwrap().iter().all(|(workspace, id)| workspace == "workspace-a" && [subject.as_str(), created.id.as_str(), non_idempotent.id.as_str()].contains(&id.as_str()))); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); - store.begin_subject_deletion(&subject, "delete-canonical-subject").unwrap(); + assert!( + provider + .calls + .lock() + .unwrap() + .iter() + .all(|(workspace, id)| workspace == "workspace-a" + && [ + subject.as_str(), + created.id.as_str(), + non_idempotent.id.as_str() + ] + .contains(&id.as_str())) + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + store + .begin_subject_deletion(&subject, "delete-canonical-subject") + .unwrap(); let workers = store.deletion_subject_workers(&subject).unwrap(); assert!(workers.contains(&("runtime".into(), "canonical-child".into()))); assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into()))); - assert!(store.finish_subject_deletion(&subject, "delete-canonical-subject").unwrap().completed); - assert!(store.worker_execution_grant("runtime", "canonical-child").is_err()); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); + assert!( + store + .finish_subject_deletion(&subject, "delete-canonical-subject") + .unwrap() + .completed + ); + assert!( + store + .worker_execution_grant("runtime", "canonical-child") + .is_err() + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); } #[test] @@ -255,10 +551,18 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r let receipt = seal(&store); // Reacquire a managed store handle; tests must not infer/open physical storage paths. let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap(); - assert!(old_writer.database.try_transaction::<_, SubjektivError>(|tx| { - tx.execute("UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", [&subject])?; - Ok(()) - }).is_err()); + assert!( + old_writer + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", + [&subject], + )?; + Ok(()) + }) + .is_err() + ); drop(old_writer); for sql in [ "INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions", @@ -278,7 +582,16 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r "UPDATE permission_source_transfer_receipt SET receipt_json='{}'", "DELETE FROM permission_source_transfer_receipt", ] { - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err(), "{sql}"); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err(), + "{sql}" + ); } store.database.try_transaction::<_, SubjektivError>(|tx| { tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?; @@ -287,8 +600,22 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?; Ok(()) }).unwrap(); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); - assert_eq!(store.seal_permission_sources(&receipt.snapshot.identity, &receipt.transfer_id, &receipt.snapshot.export_digest).unwrap(), receipt); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + assert_eq!( + store + .seal_permission_sources( + &receipt.snapshot.identity, + &receipt.transfer_id, + &receipt.snapshot.export_digest + ) + .unwrap(), + receipt + ); } #[test] @@ -304,16 +631,29 @@ fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_expor let start = barrier.clone(); let thread = std::thread::spawn(move || { start.wait(); - writer.set_subject_permissions(&subject, &SubjectPermissionsUpdate { - operation_id: "racing-old-writer".into(), expected_change_id: Some(initial.change_id), execution: vec![], delegation: vec![], - }) + writer.set_subject_permissions( + &subject, + &SubjectPermissionsUpdate { + operation_id: "racing-old-writer".into(), + expected_change_id: Some(initial.change_id), + execution: vec![], + delegation: vec![], + }, + ) }); barrier.wait(); let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest); let written = thread.join().unwrap(); match (sealed, written) { - (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => assert_eq!(receipt.snapshot, preview), - (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(!store.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()), + (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => { + assert_eq!(receipt.snapshot, preview) + } + (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(is_sealed) + .unwrap() + ), other => panic!("unexpected serialized outcomes: {other:?}"), } } @@ -325,20 +665,241 @@ fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() let subject = store.create_subject(role()).unwrap().id; seal(&store); let provider = Arc::new(Canonical { - sources: BTreeMap::from([(subject.clone(), Some(SubjectPermissions { - change_id: "foreign-source".into(), - execution: vec![ScopedPermission { - operation: "file.read".into(), - resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() }, - }], - delegation: vec![read_permission()], - }))]), + sources: BTreeMap::from([( + subject.clone(), + Some(SubjectPermissions { + change_id: "foreign-source".into(), + execution: vec![ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workspace { + workspace_id: "workspace-b".into(), + }, + }], + delegation: vec![read_permission()], + }), + )]), calls: Mutex::new(vec![]), }); let store = store.with_canonical_permission_source(provider); assert!(store.subject_permissions(&subject).is_err()); - assert!(store.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")).is_err()); - assert!(store.worker_execution_grant("runtime", "foreign-child").unwrap().is_none()); + assert!( + store + .bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")) + .is_err() + ); + assert!( + store + .worker_execution_grant("runtime", "foreign-child") + .unwrap() + .is_none() + ); +} + +#[test] +fn authority_guard_gates_legacy_callback_and_releases_an_unsealed_transaction() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let calls = std::cell::Cell::new(0); + let legacy_write = || { + store.with_permission_authority_guard(&identity, |guard| { + assert!( + !guard.transaction.borrow().as_ref().unwrap().is_autocommit(), + "the Feature transaction must already be active on callback entry" + ); + guard.require_unsealed()?; + guard.require_subject(&subject)?; + // Stand-in for a logical Server write/commit. Never reenter Feature storage here. + calls.set(calls.get() + 1); + Ok(()) + }) + }; + legacy_write().unwrap(); + assert_eq!(calls.get(), 1); + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + assert!(connection.is_autocommit()); + assert!(!has_table( + connection, + "permission_source_transfer_receipt" + )?); + assert!(!is_sealed(connection)?); + Ok(()) + }) + .unwrap(); + // A logical write does not mutate Feature, and an early callback failure does not seal. + let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| { + guard.require_unsealed()?; + Err(SubjektivError::InvalidRecord("callback before seal".into())) + }); + assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_)))); + replace(&store, &subject, "writer-after-guard-return", vec![]); + seal(&store); + assert!(matches!( + legacy_write(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(calls.get(), 1, "a sealed legacy callback must not execute"); +} + +#[test] +fn authority_guard_metadata_checks_do_not_call_canonical_source_and_honor_deletion() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let deleting = store.create_subject(role()).unwrap().id; + seal(&store); + store + .begin_subject_deletion(&deleting, "deleting-before-guard") + .unwrap(); + let provider = Arc::new(Canonical { + sources: BTreeMap::new(), // Any provider call would fail, and could reenter Server. + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider.clone()); + let identity = store.database_identity().unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; // Metadata still exists even when authority is sealed. + assert!(matches!( + guard.require_subject("missing-subject"), + Err(SubjektivError::SubjectNotFound(_)) + )); + assert!(matches!( + guard.require_subject(&deleting), + Err(SubjektivError::SubjectNotFound(_)) + )); + assert!(matches!( + guard.require_unsealed(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + Ok(()) + }) + .unwrap(); + assert!(provider.calls.lock().unwrap().is_empty()); +} + +#[test] +fn authority_guard_seal_survives_callback_failure_and_exact_retry_preserves_server_error() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + let before = counts(&store); + let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; + let receipt = + guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?; + assert_eq!(receipt.snapshot, preview); + assert!( + guard.transaction.borrow().is_none(), + "seal must end the Feature transaction before adoption" + ); + assert!(matches!( + guard.require_subject(&subject), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + guard.require_unsealed(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + Err(SubjektivError::InvalidRecord( + "callback after committed seal".into(), + )) + }); + assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_)))); + let receipt = store + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!(receipt.transfer_id, "server-fingerprint"); + assert_eq!(receipt.snapshot, preview); + assert_eq!(counts(&store), before); + let nested: Result> = + store.with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; + assert_eq!( + guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?, + receipt + ); + assert!( + guard.transaction.borrow().is_none(), + "receipt retry must also end its transaction" + ); + // Preserve the Server error/status independently of the Feature result. + Ok(Err(409)) + }); + assert!(matches!(nested, Ok(Err(409)))); + assert_eq!( + store.permission_transfer_receipt(&identity).unwrap(), + Some(receipt) + ); + for (transfer, digest) in [ + ("another-request", preview.export_digest.as_str()), + ("server-fingerprint", "changed-export"), + ] { + assert!(matches!( + store.with_permission_authority_guard(&identity, |guard| { + guard.seal_permission_sources(transfer, digest) + }), + Err(SubjektivError::PermissionTransferConflict) + )); + } + assert_eq!(counts(&store), before); +} + +#[test] +fn authority_guard_seal_sql_failure_rolls_back_before_callback_returns() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + store.create_subject(role()).unwrap(); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store + .database + .transaction(crate::storage_formats::install_permission_transfer) + .unwrap(); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + connection.execute_batch("CREATE TEMP TRIGGER fail_guard_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected guarded receipt failure'); END;")?; + Ok(()) + }).unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + assert!( + guard + .seal_permission_sources("transfer", &preview.export_digest) + .is_err() + ); + assert!(guard.transaction.borrow().is_none()); + // Failure consumes the transaction; a caught error must not enable an unfenced writer. + assert!(guard.require_unsealed().is_err()); + assert!( + guard + .seal_permission_sources("transfer", &preview.export_digest) + .is_err() + ); + Ok(()) + }) + .unwrap(); + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + assert!(connection.is_autocommit()); + assert!(!is_sealed(connection)?); + assert!(retained_receipt(connection)?.is_none()); + connection.execute_batch("DROP TRIGGER fail_guard_transfer")?; + Ok(()) + }) + .unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + guard.require_unsealed()?; + guard.seal_permission_sources("transfer", &preview.export_digest) + }) + .unwrap(); } #[test] @@ -351,7 +912,24 @@ fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_works let b = store_b.database_identity().unwrap(); assert_ne!(a, b); let preview = store_a.preview_permission_transfer(&a).unwrap(); - assert!(matches!(store_b.preview_permission_transfer(&a), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(!store_b.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()); + assert!(matches!( + store_b.preview_permission_transfer(&a), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store_b.with_permission_authority_guard::<()>(&a, |_| { + panic!("a different physical database must not enter the guarded action") + }), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!( + !store_b + .database + .try_with_connection::<_, SubjektivError>(is_sealed) + .unwrap() + ); } diff --git a/crates/subjektiv/src/storage_format_tests.rs b/crates/subjektiv/src/storage_format_tests.rs index 229180918..e46933b2c 100644 --- a/crates/subjektiv/src/storage_format_tests.rs +++ b/crates/subjektiv/src/storage_format_tests.rs @@ -586,46 +586,96 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab let temp = tempfile::tempdir().unwrap(); published_fixture(temp.path(), format); let (scope, db) = open(temp.path(), REGISTRATION); - let saved = db.with_connection(|connection| Ok(receipts(connection))).unwrap(); - let old_seal = db.with_connection(|connection| { - if format == Format::Permission18 { - Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?)) - } else { Ok(None) } - }).unwrap(); + let saved = db + .with_connection(|connection| Ok(receipts(connection))) + .unwrap(); + let old_seal = db + .with_connection(|connection| { + if format == Format::Permission18 { + Ok(Some(connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0), + )?)) + } else { + Ok(None) + } + }) + .unwrap(); db.transaction(install_permission_transfer).unwrap(); db.with_connection(|connection| { assert_eq!(receipts(connection), saved); - assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); if let Some(raw) = &old_seal { - assert_eq!(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?, *raw); + assert_eq!( + connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0) + )?, + *raw + ); } assert_foreign_keys(connection); Ok(()) - }).unwrap(); + }) + .unwrap(); let store = SubjektivStore::from_database(db.clone()).unwrap(); if old_seal.is_some() { let identity = store.database_identity().unwrap(); - assert!(matches!(store.preview_permission_transfer(&identity), Err(crate::SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.seal_permission_sources(&identity, "new-transfer", "export"), Err(crate::SubjektivError::PermissionAuthoritySealed))); + assert!(matches!( + store.preview_permission_transfer(&identity), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "new-transfer", "export"), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); struct CanonicalEmpty; impl crate::CanonicalPermissionSource for CanonicalEmpty { - fn subject_permissions(&self, workspace: &str, _subject: &str) -> crate::Result> { + fn subject_permissions( + &self, + workspace: &str, + _subject: &str, + ) -> crate::Result> + { assert_eq!(workspace, SCOPE); Ok(Some(server_api::authorization::SubjectPermissions { - change_id: "canonical-source".into(), execution: vec![], delegation: vec![], + change_id: "canonical-source".into(), + execution: vec![], + delegation: vec![], })) } } - let connected = store.clone().with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); + let connected = store + .clone() + .with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); let subject = connected.list_subjects(1).unwrap().items[0].id.clone(); - assert_eq!(connected.subject_permissions(&subject).unwrap().unwrap().change_id, "canonical-source"); - connected.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { - subject_id: subject, source_change_id: "canonical-source".into(), - runtime_id: "runtime".into(), worker_id: "canonical-child".into(), - origin_runtime_id: "runtime".into(), origin_worker_id: "root".into(), - profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), - permissions: vec![], redelegation: vec![], - }).unwrap(); + assert_eq!( + connected + .subject_permissions(&subject) + .unwrap() + .unwrap() + .change_id, + "canonical-source" + ); + connected + .bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { + subject_id: subject, + source_change_id: "canonical-source".into(), + runtime_id: "runtime".into(), + worker_id: "canonical-child".into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![], + redelegation: vec![], + }) + .unwrap(); } drop(store); drop(db); @@ -633,10 +683,14 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab let (scope, db) = open(temp.path(), REGISTRATION); db.with_connection(|connection| { assert_eq!(receipts(connection), saved); - assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); assert_foreign_keys(connection); Ok(()) - }).unwrap(); + }) + .unwrap(); drop(db); scope.shutdown().unwrap(); } @@ -647,7 +701,9 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope let temp = tempfile::tempdir().unwrap(); let (scope, db) = open(temp.path(), REGISTRATION); let before_shape = db.with_connection(domain_shape).unwrap(); - let before_rows = db.with_connection(|connection| Ok(all_rows(connection))).unwrap(); + let before_rows = db + .with_connection(|connection| Ok(all_rows(connection))) + .unwrap(); let result = db.transaction(|tx| { install_permission_transfer(tx)?; tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?; @@ -658,16 +714,24 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope assert_eq!(domain_shape(connection)?, before_shape); assert_eq!(all_rows(connection), before_rows); Ok(()) - }).unwrap(); + }) + .unwrap(); db.transaction(install_permission_transfer).unwrap(); db.with_connection(|connection| { - connection.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; + connection + .execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; Ok(()) - }).unwrap(); + }) + .unwrap(); drop(db); scope.shutdown().unwrap(); - let scope = feature_storage::FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let scope = feature_storage::FeatureStorage::new(temp.path()) + .scope(SCOPE) + .unwrap(); let registered = scope.register(REGISTRATION).unwrap(); - assert!(matches!(scope.open(®istered), Err(FeatureStorageError::MigrationHistory { .. }))); + assert!(matches!( + scope.open(®istered), + Err(FeatureStorageError::MigrationHistory { .. }) + )); scope.shutdown().unwrap(); } diff --git a/crates/subjektiv/src/storage_formats.rs b/crates/subjektiv/src/storage_formats.rs index 3f1c39643..f6991a15f 100644 --- a/crates/subjektiv/src/storage_formats.rs +++ b/crates/subjektiv/src/storage_formats.rs @@ -86,7 +86,9 @@ pub(super) fn validate( [], |row| row.get::<_, bool>(0), )?; if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) { - return Err(incompatible("permission transfer requires complete common 1..17")); + return Err(incompatible( + "permission transfer requires complete common 1..17", + )); } let expected = reference_shape_with_transfer(format, transfer)?; let actual = domain_shape(tx)?; @@ -127,7 +129,10 @@ fn reference_shape(format: Format) -> feature_storage::Result> reference_shape_with_transfer(format, false) } -fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_storage::Result> { +fn reference_shape_with_transfer( + format: Format, + transfer: bool, +) -> feature_storage::Result> { // Cache reference shapes only; every target is reread under its caller's transaction. static SHAPES: [OnceLock>; 50] = [const { OnceLock::new() }; 50]; let index = match format { @@ -208,8 +213,11 @@ fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_stor /// Existing published seals and their serialized exports are never rewritten or relabelled. /// The FeatureDatabase guard and caller's transaction cover validation and installation. pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> { - let mut statement = tx.prepare(&format!("SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"))?; - let applied = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + let mut statement = tx.prepare(&format!( + "SELECT version,name FROM {HISTORY_TABLE} ORDER BY version" + ))?; + let applied = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::, _>>()?; let format = recognize(&applied)?; if matches!(format, Format::Common(count) if count < COMMON_COUNT) { @@ -226,7 +234,10 @@ pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_stora validate(tx, &applied) } -fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> feature_storage::Result<()> { +fn install_transfer_shape( + tx: &Transaction<'_>, + published_permission: bool, +) -> feature_storage::Result<()> { if !published_permission { tx.execute_batch(PERMISSION_SEAL_SQL)?; } @@ -237,7 +248,9 @@ fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> f for action in ["INSERT", "UPDATE", "DELETE"] { let deletion_exception = if action == "DELETE" { " AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)" - } else { "" }; + } else { + "" + }; tx.execute_batch(&format!( "CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table} WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception} diff --git a/crates/subjektiv/src/subject_creation.rs b/crates/subjektiv/src/subject_creation.rs index 3ba7399df..388eb1fed 100644 --- a/crates/subjektiv/src/subject_creation.rs +++ b/crates/subjektiv/src/subject_creation.rs @@ -21,7 +21,10 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { impl SubjektivStore { /// Creation-only enrollment evidence for the canonical domain. Never a current permission /// source or a read-time default; the Host must not apply it to an existing/unset policy. - pub fn subject_permission_enrollment(&self, subject_id: &str) -> Result> { + pub fn subject_permission_enrollment( + &self, + subject_id: &str, + ) -> Result> { self.database.try_transaction(|tx| { require_subject(tx, subject_id)?; if !permissions::transfer::has_table(tx, "permission_authority_seal")? { diff --git a/crates/worker/src/feature/builtin/permission_catalog.rs b/crates/worker/src/feature/builtin/permission_catalog.rs index 2a026c1af..16d952cbb 100644 --- a/crates/worker/src/feature/builtin/permission_catalog.rs +++ b/crates/worker/src/feature/builtin/permission_catalog.rs @@ -6,54 +6,102 @@ //! exact names. No dotted-prefix children are inferred. Profiles/provider scopes //! and actual operation authorization remain independent of this metadata load. -use std::collections::BTreeMap; -use feature_permissions::{FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, - PermissionDefinition, PermissionHost, PermissionKey, PermissionReference, PermissionSetId, - SetContribution, WorkspaceId}; use super::super::FeatureInstallError; +use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, PermissionDefinition, + PermissionHost, PermissionKey, PermissionReference, PermissionSetId, SetContribution, + WorkspaceId, +}; +use std::collections::BTreeMap; const OWNERS: &[(&str, &str)] = &[ - ("filesystem", "builtin:workdir"), ("bash", "builtin:bash"), - ("workdir", "builtin:workdir"), ("worker", "builtin:worker"), - ("subworker", "builtin:sub-worker-control"), ("ticket", "builtin:ticket"), - ("objective", "builtin:objective"), ("merge_request", "builtin:merge-request"), - ("repository", "builtin:repository"), ("drive", "builtin:drive"), - ("workspace_config", "builtin:workspace-config"), ("memory", "builtin:memory"), - ("journal", "builtin:journal-recording"), ("chat", "builtin:subjektiv-chat"), - ("session", "builtin:session-explore"), ("web", "builtin:web"), + ("filesystem", "builtin:workdir"), + ("bash", "builtin:bash"), + ("workdir", "builtin:workdir"), + ("worker", "builtin:worker"), + ("subworker", "builtin:sub-worker-control"), + ("ticket", "builtin:ticket"), + ("objective", "builtin:objective"), + ("merge_request", "builtin:merge-request"), + ("repository", "builtin:repository"), + ("drive", "builtin:drive"), + ("workspace_config", "builtin:workspace-config"), + ("memory", "builtin:memory"), + ("journal", "builtin:journal-recording"), + ("chat", "builtin:subjektiv-chat"), + ("session", "builtin:session-explore"), + ("web", "builtin:web"), ("notification", "builtin:notification"), ]; -pub fn builtin_permission_host(workspace: WorkspaceId) -> Result { +pub fn builtin_permission_host( + workspace: WorkspaceId, +) -> Result { let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string()); let mut host = PermissionHost::new(workspace); let mut features = BTreeMap::new(); - let operations = server_api::authorization::subject_permission_operations(); + // MCP's scalar startup permission is the retained T-787 exception, not a + // payload-free operation key. Do not invent mcp.* authority or a Set for it. + // Legacy migration retains it as an explicit-configuration-required diagnostic. + let operations = server_api::authorization::subject_permission_operations() + .into_iter() + .filter(|operation| operation.operation != "mcp") + .collect::>(); for (namespace, owner) in OWNERS { let feature = match features.get(owner) { Some(feature) => feature, None => { - let feature = host.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?).map_err(|e| invalid(&e))?; + let feature = host + .register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?) + .map_err(|e| invalid(&e))?; features.entry(owner).or_insert(feature) - }, + } }; let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?; - let handle = host.bind_namespace(feature, id.clone()).map_err(|e| invalid(&e))?; - let permissions = operations.iter().filter(|operation| operation.operation.split('.').next() == Some(namespace)) - .map(|operation| Ok(PermissionDefinition { - key:PermissionKey::parse(operation.operation.clone()).map_err(|e| invalid(&e))?, - description:operation.description.clone(),children:vec![], - })).collect::,FeatureInstallError>>()?; - let keys = permissions.iter().map(|definition| definition.key.clone()).collect(); - host.declare(&handle,NamespaceDeclaration { - permissions, - sets:vec![SetContribution { set:PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:id,keys})] }], - }).map_err(|e|invalid(&e))?; + let handle = host + .bind_namespace(feature, id.clone()) + .map_err(|e| invalid(&e))?; + let permissions = operations + .iter() + .filter(|operation| operation.operation.split('.').next() == Some(namespace)) + .map(|operation| { + Ok(PermissionDefinition { + key: PermissionKey::parse(operation.operation.clone()) + .map_err(|e| invalid(&e))?, + description: operation.description.clone(), + children: vec![], + }) + }) + .collect::, FeatureInstallError>>()?; + let keys = permissions + .iter() + .map(|definition| definition.key.clone()) + .collect(); + host.declare( + &handle, + NamespaceDeclaration { + permissions, + sets: vec![SetContribution { + set: PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: id, + keys, + })], + }], + }, + ) + .map_err(|e| invalid(&e))?; } // A new builtin operation needs an intentional namespace ownership decision. for operation in operations { - if !OWNERS.iter().any(|(namespace,_)| operation.operation.split('.').next() == Some(*namespace)) { - return Err(FeatureInstallError::InvalidDescriptor(format!("builtin operation has no namespace owner: {}",operation.operation))); + if !OWNERS + .iter() + .any(|(namespace, _)| operation.operation.split('.').next() == Some(*namespace)) + { + return Err(FeatureInstallError::InvalidDescriptor(format!( + "builtin operation has no namespace owner: {}", + operation.operation + ))); } } Ok(host) @@ -64,15 +112,23 @@ mod tests { use super::*; #[test] fn builtin_keys_are_complete_exact_and_have_no_inferred_children() { - let host=builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); - let catalog=host.catalog(); - let mut keys=std::collections::BTreeSet::new(); + let host = builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); + let catalog = host.catalog(); + let mut keys = std::collections::BTreeSet::new(); for namespace in catalog.namespaces { for permission in namespace.declaration.permissions { assert!(permission.children.is_empty()); assert!(keys.insert(permission.key.as_str().to_owned())); } } - assert_eq!(keys,server_api::authorization::subject_permission_operations().into_iter().map(|o|o.operation).collect()); + assert!(!keys.contains("mcp")); + assert_eq!( + keys, + server_api::authorization::subject_permission_operations() + .into_iter() + .filter(|operation| operation.operation != "mcp") + .map(|o| o.operation) + .collect() + ); } } diff --git a/crates/workspace-server/README.md b/crates/workspace-server/README.md index bbffda327..28c7c0e09 100644 --- a/crates/workspace-server/README.md +++ b/crates/workspace-server/README.md @@ -9,3 +9,4 @@ Backend that manages persistent Workspace resources and authorization and provid - [`hosts`] / [`worker_source`] — Runtime delegation and request source verification - [`backend_job`] — Immutable input, attempt, and structured result boundaries - [`workspace_catalog`] — Workspace creation and replay contracts +- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments diff --git a/crates/workspace-server/src/config_adoption.rs b/crates/workspace-server/src/config_adoption.rs index 7d2673f89..018bec136 100644 --- a/crates/workspace-server/src/config_adoption.rs +++ b/crates/workspace-server/src/config_adoption.rs @@ -5,6 +5,53 @@ //! after seal leaves an explicit, resumable transfer, never a legacy fallback. //! Normal saves never touch the old database. Object preparation, source, //! projection, policy, editing head and receipt commit in one caller-owned transaction. +//! +//! # Editing and reconciliation +//! +//! Owner authentication precedes all source, preview, adoption and reconciliation +//! operations. The public transport contract belongs to +//! [`server_api::configuration_adoption`]. It extends the existing virtual tree; +//! imports resolve within one snapshot and no host filesystem path grants access. +//! Before cutover, retrieval returns an evaluated editable migration draft, not a +//! saved change. Preview is read-only and reports changed paths and Subjects. +//! +//! Content identity and editing history are distinct. Adoption compares the +//! observed history head as well as the source digest, so A -> B -> A cannot +//! authorize an old editor. The request fingerprint binds Workspace, authenticated +//! owner and the entire request (including catalog, export, head and changes). +//! Reuse of an operation ID with different input is a conflict. Exact replay +//! returns historical evidence before stale-head checks; it never reapplies policy. +//! +//! After a lost response, retain the full request and reconcile it with that same +//! ID. An adopted receipt proves that request's stored result, not today's head. +//! A pending transfer proves the immutable legacy seal, not Server adoption. +//! Absence of either is not evidence of failure or permission to generate a new +//! ID. Retry the exact request; read current source before a distinct edit. +//! +//! # Cutover boundary +//! +//! Initial cutover and legacy logical config writes acquire the guarded Feature +//! authority transaction before Server storage. Sealing commits independently; +//! the outer Feature connection guard remains held through the Server result. +//! After seal, both old policy writes and old logical source writes reject, +//! including when Server persistence rolls back. Reopen/retry uses the same +//! identity-bound export and request. No in-memory mutex is the durable fence. +//! Validation (including blocking migration diagnostics) precedes seal. Failure +//! there retains legacy authority and the previous source/policy/head/receipt. +//! A failure after seal retains the old Server source but fails closed for runtime +//! reads until the same transfer is adopted. This is not a distributed atomic +//! commit or a guarantee for arbitrary machine loss at every instruction. +//! +//! Existing explicit permissions are never automatically merged or replaced, +//! even when empty. Unsupported operations or child-expanding conversions block +//! adoption rather than dropping entries. Exact limited legacy constraints and +//! change identities remain in the domain, separate from editable permission keys. +//! History and child grants are retained, not regenerated as new authorizations. +//! +//! A receipt separately confirms source storage and policy commit. Its typed +//! runtime application means the next current-policy check, not interruption, +//! reload or termination of an already-running Worker. Legacy consumers cannot +//! turn a new unrepresented key into an all-target grant. use std::collections::{BTreeMap, BTreeSet}; @@ -78,13 +125,26 @@ pub(crate) struct CanonicalConfigurationSource { } impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource { - fn subject_permissions(&self, workspace: &str, subject: &str) -> subjektiv::Result> { + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> subjektiv::Result> { if self.host.catalog().workspace.as_str() != workspace { return Err(subjektiv::SubjektivError::PermissionTransferConflict); } - self.store.adopted_subject_policy(workspace, subject) - .and_then(|policy| policy.map(|policy| policy.legacy_read(&self.host)).transpose()) - .map_err(|error| subjektiv::SubjektivError::InvalidRecord(format!("canonical configuration policy unavailable: {error}"))) + self.store + .adopted_subject_policy(workspace, subject) + .and_then(|policy| { + policy + .map(|policy| policy.legacy_read(&self.host)) + .transpose() + }) + .map_err(|error| { + subjektiv::SubjektivError::InvalidRecord(format!( + "canonical configuration policy unavailable: {error}" + )) + }) } } @@ -106,7 +166,8 @@ pub(crate) struct ConfigurationMigrationBaseline<'a> { struct OwnerScope<'a>(&'a str); impl Authority for OwnerScope<'_> { fn allows(&self, key: &ScopeKey, access: Access) -> bool { - key.workspace == self.0 && key.target == "configuration" + key.workspace == self.0 + && key.target == "configuration" && matches!(access, Access::Read | Access::Write) } } @@ -124,75 +185,137 @@ fn decode(value: &str) -> Result { serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string())) } fn scope(workspace: &str) -> ScopeKey { - ScopeKey { workspace: workspace.into(), target: "configuration".into() } + ScopeKey { + workspace: workspace.into(), + target: "configuration".into(), + } } fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> { let allowed: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", params![workspace, account], |r| r.get(0), )?; - if !allowed { return Err(Error::WorkspacePermissionDenied("configuration management requires the Workspace owner".into())); } + if !allowed { + return Err(Error::WorkspacePermissionDenied( + "configuration management requires the Workspace owner".into(), + )); + } Ok(()) } pub(crate) fn head(conn: &Connection, workspace: &str) -> Result> { - let bytes: Option> = conn.query_row( - "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", [workspace], |r| r.get(0), - ).optional()?.flatten(); - bytes.map(|bytes| bytes.try_into().map(HistoryId::from_bytes) - .map_err(|_| Error::RegistryInconsistency("invalid configuration editing head".into()))).transpose() + let bytes: Option> = conn + .query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", + [workspace], + |r| r.get(0), + ) + .optional()? + .flatten(); + bytes + .map(|bytes| { + bytes.try_into().map(HistoryId::from_bytes).map_err(|_| { + Error::RegistryInconsistency("invalid configuration editing head".into()) + }) + }) + .transpose() } -pub(crate) fn request_fingerprint(workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result { - server_api::authorization::validate_authorization_identifier(&request.operation_id).map_err(Error::InvalidInput)?; - Ok(config_source::digest_bytes(json(&(workspace, account, request))?.as_bytes())) +pub(crate) fn request_fingerprint( + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, +) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id) + .map_err(Error::InvalidInput)?; + Ok(config_source::digest_bytes( + json(&(workspace, account, request))?.as_bytes(), + )) } pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result { - Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |row| row.get(0))?) + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", + [workspace], + |row| row.get(0), + )?) } pub fn catalog_digest(host: &PermissionHost) -> Result { - Ok(config_source::digest_bytes(json(&host.catalog())?.as_bytes())) + Ok(config_source::digest_bytes( + json(&host.catalog())?.as_bytes(), + )) } fn policies(conn: &Connection, workspace: &str) -> Result> { let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?; let mut result = BTreeMap::new(); - for row in query.query_map([workspace], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? { + for row in query.query_map([workspace], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? { let (subject, raw) = row?; let policy: AdoptedSubjectPolicy = decode(&raw)?; - if subject != policy.source.subject_id { return Err(Error::RegistryInconsistency("Subject policy identity mismatch".into())); } + if subject != policy.source.subject_id { + return Err(Error::RegistryInconsistency( + "Subject policy identity mismatch".into(), + )); + } result.insert(subject, policy); } Ok(result) } -fn receipt(conn: &Connection, workspace: &str, operation: &str) -> Result> { +fn receipt( + conn: &Connection, + workspace: &str, + operation: &str, +) -> Result> { let row: Option<(String,String)> = conn.query_row( "SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2", params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)), ).optional()?; - row.map(|(fingerprint,result)| Ok((fingerprint,decode(&result)?))).transpose() + row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?))) + .transpose() } fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec { - before.entries.keys().chain(after.entries.keys()).collect::>().into_iter() + before + .entries + .keys() + .chain(after.entries.keys()) + .collect::>() + .into_iter() .filter(|path| before.entries.get(*path) != after.entries.get(*path)) - .map(|path| path.as_str().to_owned()).collect() + .map(|path| path.as_str().to_owned()) + .collect() } -fn changed_subjects(before: &BTreeMap, after: &BTreeMap) -> Vec { - before.keys().chain(after.keys()).collect::>().into_iter() - .filter(|subject| before.get(*subject) != after.get(*subject)).cloned().collect() +fn changed_subjects( + before: &BTreeMap, + after: &BTreeMap, +) -> Vec { + before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter(|subject| before.get(*subject) != after.get(*subject)) + .cloned() + .collect() } impl SqliteWorkspaceStore { - pub(crate) fn configuration_current(&self, workspace: &str, account: &str) -> Result { + pub(crate) fn configuration_current( + &self, + workspace: &str, + account: &str, + ) -> Result { self.with_conn(|conn| { let tx = conn.unchecked_transaction()?; require_owner(&tx, workspace, account)?; Ok(ConfigurationCurrent { - state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?, + state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| { + Error::InvalidInput("Workspace source not materialized".into()) + })?, history_head: head(&tx, workspace)?.map(|id| id.to_string()), canonical: canonical(&tx, workspace)?, policies: policies(&tx, workspace)?, @@ -200,7 +323,12 @@ impl SqliteWorkspaceStore { }) } - pub(crate) fn configuration_request_result(&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result> { + pub(crate) fn configuration_request_result( + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + ) -> Result> { let fingerprint = request_fingerprint(workspace, account, request)?; self.with_conn(|conn| { require_owner(conn, workspace, account)?; @@ -214,31 +342,58 @@ impl SqliteWorkspaceStore { /// Authentication supplies `account`; it is never accepted from an adoption DTO. pub fn configuration_head(&self, workspace: &str, account: &str) -> Result> { - self.with_conn(|conn| { require_owner(conn, workspace, account)?; Ok(head(conn,workspace)?.map(|id|id.to_string())) }) + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(head(conn, workspace)?.map(|id| id.to_string())) + }) } /// Result lookup is a read. A historical receipt never reactivates policy. - pub fn configuration_adoption_result(&self, workspace: &str, account: &str, operation: &str) -> Result> { - self.with_conn(|conn| { require_owner(conn,workspace,account)?; Ok(receipt(conn,workspace,operation)?.map(|(_,result)|result)) }) + pub fn configuration_adoption_result( + &self, + workspace: &str, + account: &str, + operation: &str, + ) -> Result> { + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result)) + }) } pub(crate) fn preview_configuration_adoption( - &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, mut require_subject: impl FnMut(&str) -> Result<()>, ) -> Result { self.with_conn(|conn| { - require_owner(conn,workspace,account)?; + require_owner(conn, workspace, account)?; let tx = conn.unchecked_transaction()?; - let (current,state,source) = prepare(&tx,workspace,request,schema,host,baseline,&mut require_subject)?; - let before = match baseline { Some(baseline) => baseline.policies.clone(), None => policies(&tx,workspace)? }; + let (current, state, source) = prepare( + &tx, + workspace, + request, + schema, + host, + baseline, + &mut require_subject, + )?; + let before = match baseline { + Some(baseline) => baseline.policies.clone(), + None => policies(&tx, workspace)?, + }; let mut after = before.clone(); - policy_config::apply(&mut after,&source,"preview"); + policy_config::apply(&mut after, &source, "preview"); Ok(ConfigurationPreview { expected_head: request.expected_head.clone(), - changed_paths: changed_paths(¤t.snapshot,&state.snapshot), - changed_subjects: changed_subjects(&before,&after), state, + changed_paths: changed_paths(¤t.snapshot, &state.snapshot), + changed_subjects: changed_subjects(&before, &after), + state, }) }) } @@ -247,8 +402,12 @@ impl SqliteWorkspaceStore { /// current-head checks. If subsequent Server persistence fails, that specific /// transfer remains pending; callers reconcile/retry the same request, not a new ID. pub(crate) fn adopt_configuration( - &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, mut require_subject: impl FnMut(&str) -> Result<()>, mut prepare_transfer: impl FnMut(&str) -> Result, @@ -301,7 +460,11 @@ impl SqliteWorkspaceStore { }) } - pub fn adopted_subject_policy(&self, workspace: &str, subject: &str) -> Result> { + pub fn adopted_subject_policy( + &self, + workspace: &str, + subject: &str, + ) -> Result> { self.with_conn(|conn| { let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?; if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); } @@ -312,39 +475,68 @@ impl SqliteWorkspaceStore { } fn prepare( - tx: &Transaction<'_>, workspace: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + tx: &Transaction<'_>, + workspace: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, require_subject: &mut impl FnMut(&str) -> Result<()>, -) -> Result<(WorkspaceConfigState,WorkspaceConfigState,PermissionSource)> { +) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> { if let Some(baseline) = baseline { - if baseline.diagnostics.iter().any(|diagnostic| matches!(diagnostic, policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. })) { + if baseline.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic, + policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. } + ) + }) { return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into())); } } if host.catalog().workspace.as_str() != workspace { - return Err(Error::WorkspacePermissionDenied("permission registry belongs to another Workspace".into())); + return Err(Error::WorkspacePermissionDenied( + "permission registry belongs to another Workspace".into(), + )); } - if catalog_digest(host)? != request.expected_catalog_digest { return Err(conflict("Feature catalog changed")); } - if head(tx,workspace)?.map(|id|id.to_string()) != request.expected_head { return Err(conflict("editing history head changed")); } - let current = crate::config_source::load_state(tx,workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; + if catalog_digest(host)? != request.expected_catalog_digest { + return Err(conflict("Feature catalog changed")); + } + if head(tx, workspace)?.map(|id| id.to_string()) != request.expected_head { + return Err(conflict("editing history head changed")); + } + let current = crate::config_source::load_state(tx, workspace)? + .ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; let editing_base = if let Some(baseline) = baseline { - if *baseline.original != current || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) { + if *baseline.original != current + || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) + { return Err(conflict("migration source or export changed")); } baseline.state - } else { ¤t }; - if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); } + } else { + ¤t + }; + if editing_base.snapshot.digest != request.source.base_digest { + return Err(conflict("source content changed")); + } crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?; let candidate = crate::config_source::evaluate_candidate_in_transaction( - tx,workspace,editing_base.clone(),crate::config_source::load_evaluation_identity(tx,workspace)?, - &request.source.changes,schema, + tx, + workspace, + editing_base.clone(), + crate::config_source::load_evaluation_identity(tx, workspace)?, + &request.source.changes, + schema, )?; - crate::config_source::validate_workspace_config_candidate_projections(workspace,&candidate)?; + crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?; let source = policy_config::source(&candidate.evaluation)?; - policy_config::validate_source(host,&source,require_subject)?; - let state = WorkspaceConfigState { snapshot:candidate.snapshot,contract:candidate.contract,projection_digest:candidate.evaluation.projection_digest }; - Ok((current,state,source)) + policy_config::validate_source(host, &source, require_subject)?; + let state = WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }; + Ok((current, state, source)) } #[cfg(test)] diff --git a/crates/workspace-server/src/config_adoption_tests.rs b/crates/workspace-server/src/config_adoption_tests.rs index 502ed7fad..379be9446 100644 --- a/crates/workspace-server/src/config_adoption_tests.rs +++ b/crates/workspace-server/src/config_adoption_tests.rs @@ -12,24 +12,55 @@ const OWNER: &str = "adoption-owner"; fn host(children: bool) -> PermissionHost { let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); - let feature = host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()).unwrap(); - let namespace = host.bind_namespace(&feature,NamespaceId::parse("ticket").unwrap()).unwrap(); - host.declare(&namespace, NamespaceDeclaration { - permissions: vec![ - PermissionDefinition { key: PermissionKey::parse("ticket.hoge").unwrap(), description:String::new(), children: if children {vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]} else {vec![]} }, - PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] }, - ], - sets: vec![SetContribution { set:PermissionSetId::parse("ticket").unwrap(),children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:NamespaceId::parse("ticket").unwrap(),keys:vec![PermissionKey::parse("ticket.hoge").unwrap()]})] }], - }).unwrap(); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("ticket").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![SetContribution { + set: PermissionSetId::parse("ticket").unwrap(), + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse("ticket").unwrap(), + keys: vec![PermissionKey::parse("ticket.hoge").unwrap()], + })], + }], + }, + ) + .unwrap(); host } fn schema() -> WorkspaceConfigSchemaBundle { WorkspaceConfigSchemaBundle::compose([ PermissionConfigSchemaProvider.contribution().unwrap(), - crate::prompt_settings::PromptConfigSchemaProvider.contribution().unwrap(), - ]).unwrap() + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} +fn open(path: &std::path::Path) -> SqliteWorkspaceStore { + SqliteWorkspaceStore::open_for_test(path).unwrap() } -fn open(path: &std::path::Path) -> SqliteWorkspaceStore { SqliteWorkspaceStore::open_for_test(path).unwrap() } fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { let dir = tempfile::tempdir().unwrap(); let db = open(&dir.path().join("server.sqlite")); @@ -37,119 +68,350 @@ fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?; Ok(()) }).unwrap(); - db.ensure_workspace_config_materialized_with_schema(WORKSPACE,"now",schema()).unwrap(); - (dir,db) + db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema()) + .unwrap(); + (dir, db) } -fn request(db: &SqliteWorkspaceStore, host: &PermissionHost, id: &str, source: Option<&str>) -> ConfigurationAdoptionRequest { +fn request( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + id: &str, + source: Option<&str>, +) -> ConfigurationAdoptionRequest { let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap(); let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap(); - let changes = source.map(|source| vec![ConfigTreeChange::Update { - path: path.clone(), expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), content:source.into(), - }]).unwrap_or_default(); + let changes = source + .map(|source| { + vec![ConfigTreeChange::Update { + path: path.clone(), + expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), + content: source.into(), + }] + }) + .unwrap_or_default(); ConfigurationAdoptionRequest { - operation_id:id.into(), expected_legacy_export:db.configuration_head(WORKSPACE,OWNER).unwrap().is_none().then(||"fixture-empty".into()), expected_head:db.configuration_head(WORKSPACE,OWNER).unwrap(),expected_catalog_digest:catalog_digest(host).unwrap(), - source:ConfigCommitRequest {base_digest:current.snapshot.digest,changes,entrypoints:vec![path]}, + operation_id: id.into(), + expected_legacy_export: db + .configuration_head(WORKSPACE, OWNER) + .unwrap() + .is_none() + .then(|| "fixture-empty".into()), + expected_head: db.configuration_head(WORKSPACE, OWNER).unwrap(), + expected_catalog_digest: catalog_digest(host).unwrap(), + source: ConfigCommitRequest { + base_digest: current.snapshot.digest, + changes, + entrypoints: vec![path], + }, } } -fn adopt(db: &SqliteWorkspaceStore, host: &PermissionHost, request: &ConfigurationAdoptionRequest) -> Result { - db.adopt_configuration(WORKSPACE,OWNER,request,schema(),host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))}, |fingerprint|Ok(ConfigurationPolicyTransfer { - transfer_identity:fingerprint.into(),physical_identity:"fixture-physical".into(),export_digest:"fixture-empty".into(),policies:BTreeMap::new(), - })) +fn adopt( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + request: &ConfigurationAdoptionRequest, +) -> Result { + db.adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |fingerprint| { + Ok(ConfigurationPolicyTransfer { + transfer_identity: fingerprint.into(), + physical_identity: "fixture-physical".into(), + export_digest: "fixture-empty".into(), + policies: BTreeMap::new(), + }) + }, + ) } const A: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; #[test] fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() { - let (_dir,db) = fixture(); let host=host(false); - let initial=request(&db,&host,"initial",Some(A)); - let first=adopt(&db,&host,&initial).unwrap(); - let stale=request(&db,&host,"stale-a",Some(B)); - adopt(&db,&host,&request(&db,&host,"to-b",Some(B))).unwrap(); - let final_a=adopt(&db,&host,&request(&db,&host,"back-a",Some(A))).unwrap(); - assert_eq!(first.state.snapshot,final_a.state.snapshot); - assert_ne!(first.history_head,final_a.history_head); - assert!(adopt(&db,&host,&stale).is_err()); + let (_dir, db) = fixture(); + let host = host(false); + let initial = request(&db, &host, "initial", Some(A)); + let first = adopt(&db, &host, &initial).unwrap(); + let stale = request(&db, &host, "stale-a", Some(B)); + adopt(&db, &host, &request(&db, &host, "to-b", Some(B))).unwrap(); + let final_a = adopt(&db, &host, &request(&db, &host, "back-a", Some(A))).unwrap(); + assert_eq!(first.state.snapshot, final_a.state.snapshot); + assert_ne!(first.history_head, final_a.history_head); + assert!(adopt(&db, &host, &stale).is_err()); // Exact replay precedes stale checks and returns only the saved old result. - assert_eq!(adopt(&db,&host,&initial).unwrap(),first); - assert_eq!(db.configuration_adoption_result(WORKSPACE,OWNER,"initial").unwrap(),Some(first)); - assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),Some(final_a.history_head)); - let mut changed=initial; changed.source.changes.clear(); - assert!(adopt(&db,&host,&changed).is_err()); + assert_eq!(adopt(&db, &host, &initial).unwrap(), first); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, "initial") + .unwrap(), + Some(first) + ); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(final_a.history_head) + ); + let mut changed = initial; + changed.source.changes.clear(); + assert!(adopt(&db, &host, &changed).is_err()); } #[test] fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() { - for (table,event) in [ - ("content_objects","INSERT"), ("workspace_config_entries","INSERT"), - ("workspace_configuration_policies","INSERT"), ("content_scopes","UPDATE"), - ("workspace_configuration_receipts","INSERT"), + for (table, event) in [ + ("content_objects", "INSERT"), + ("workspace_config_entries", "INSERT"), + ("workspace_configuration_policies", "INSERT"), + ("content_scopes", "UPDATE"), + ("workspace_configuration_receipts", "INSERT"), ] { - let (dir,db)=fixture(); let host=host(false); - let before=adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); - let candidate=request(&db,&host,"failing",Some(B)); + let (dir, db) = fixture(); + let host = host(false); + let before = adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let candidate = request(&db, &host, "failing", Some(B)); db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap(); - assert!(adopt(&db,&host,&candidate).is_err(),"{table}"); + assert!(adopt(&db, &host, &candidate).is_err(), "{table}"); drop(db); - let reopened=open(&dir.path().join("server.sqlite")); - assert_eq!(reopened.load_workspace_config(WORKSPACE).unwrap(),Some(before.state),"{table}"); - assert_eq!(reopened.configuration_head(WORKSPACE,OWNER).unwrap(),Some(before.history_head),"{table}"); - assert_eq!(reopened.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),policy,"{table}"); - assert!(reopened.configuration_adoption_result(WORKSPACE,OWNER,"failing").unwrap().is_none(),"{table}"); - adopt(&reopened,&host,&candidate).unwrap(); + let reopened = open(&dir.path().join("server.sqlite")); + assert_eq!( + reopened.load_workspace_config(WORKSPACE).unwrap(), + Some(before.state), + "{table}" + ); + assert_eq!( + reopened.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(before.history_head), + "{table}" + ); + assert_eq!( + reopened + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap(), + policy, + "{table}" + ); + assert!( + reopened + .configuration_adoption_result(WORKSPACE, OWNER, "failing") + .unwrap() + .is_none(), + "{table}" + ); + adopt(&reopened, &host, &candidate).unwrap(); } } #[test] fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() { - let (_dir,db)=fixture(); let host=host(false); - let initial=db.load_workspace_config(WORKSPACE).unwrap(); + let (_dir, db) = fixture(); + let host = host(false); + let initial = db.load_workspace_config(WORKSPACE).unwrap(); for source in [ - "not valid Decodal {", + "not valid Decodal {", r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#, ] { - let request=request(&db,&host,"bad",Some(source)); - let result=db.adopt_configuration(WORKSPACE,OWNER,&request,schema(),&host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))},|_|panic!("validation failure must precede seal")); - assert!(result.is_err(),"{source}"); - assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(),initial); - assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),None); + let request = request(&db, &host, "bad", Some(source)); + let result = db.adopt_configuration( + WORKSPACE, + OWNER, + &request, + schema(), + &host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |_| panic!("validation failure must precede seal"), + ); + assert!(result.is_err(), "{source}"); + assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(), initial); + assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None); } - let request=request(&db,&host,"other-owner",None); - assert!(db.adopt_configuration(WORKSPACE,"non-owner",&request,schema(),&host,None,|_|Ok(()),|_|panic!("owner refusal must precede seal")).is_err()); + let request = request(&db, &host, "other-owner", None); + assert!( + db.adopt_configuration( + WORKSPACE, + "non-owner", + &request, + schema(), + &host, + None, + |_| Ok(()), + |_| panic!("owner refusal must precede seal") + ) + .is_err() + ); } #[test] fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() { - let (_dir,db)=fixture(); let host=host(false); - adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let original=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); - let preview=db.preview_configuration_adoption(WORKSPACE,OWNER,&request(&db,&host,"noop",None),schema(),&host,None,|_|Ok(())).unwrap(); + let (_dir, db) = fixture(); + let host = host(false); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let original = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let preview = db + .preview_configuration_adoption( + WORKSPACE, + OWNER, + &request(&db, &host, "noop", None), + schema(), + &host, + None, + |_| Ok(()), + ) + .unwrap(); assert!(preview.changed_paths.is_empty()); assert!(preview.changed_subjects.is_empty()); - adopt(&db,&host,&request(&db,&host,"noop",None)).unwrap(); - assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); - adopt(&db,&host,&request(&db,&host,"omit",Some("{} as WorkspaceConfigSchema"))).unwrap(); - assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); - adopt(&db,&host,&request(&db,&host,"remove",Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#))).unwrap(); - assert!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().is_none()); + adopt(&db, &host, &request(&db, &host, "noop", None)).unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request(&db, &host, "omit", Some("{} as WorkspaceConfigSchema")), + ) + .unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request( + &db, + &host, + "remove", + Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#), + ), + ) + .unwrap(); + assert!( + db.adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .is_none() + ); } #[test] fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() { - for children in [false,true] { - let (_dir,db)=fixture(); let host=host(children); - adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); - let resolution=host.resolve(Some(&policy.source.execution.assignment())); - assert_eq!(resolution.projection().allowed.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),children); - assert!(host.resolve(Some(&policy.source.delegation.assignment())).projection().allowed.is_empty()); - adopt(&db,&host,&request(&db,&host,"multi",Some(B))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); - assert_eq!(policy.source.execution.namespaces[0].keys[0].as_str(),"ticket.hoge.fuga"); + for children in [false, true] { + let (_dir, db) = fixture(); + let host = host(children); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + let resolution = host.resolve(Some(&policy.source.execution.assignment())); + assert_eq!( + resolution + .projection() + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()), + children + ); + assert!( + host.resolve(Some(&policy.source.delegation.assignment())) + .projection() + .allowed + .is_empty() + ); + adopt(&db, &host, &request(&db, &host, "multi", Some(B))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + assert_eq!( + policy.source.execution.namespaces[0].keys[0].as_str(), + "ticket.hoge.fuga" + ); } } + +#[test] +fn client_operation_id_cannot_reuse_a_previous_policy_change_identity() { + let (_dir, db) = fixture(); + let host = host(false); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let before = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + let change = request(&db, &host, &before.change_id, Some(B)); + adopt(&db, &host, &change).unwrap(); + let after = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + assert_ne!(after.change_id, before.change_id); + assert_ne!(after.source, before.source); +} + +#[test] +fn independent_database_connections_serialize_concurrent_web_and_cli_heads() { + let (dir, db) = fixture(); + let registry = host(false); + adopt(&db, ®istry, &request(&db, ®istry, "initial", Some(A))).unwrap(); + let other = open(&dir.path().join("server.sqlite")); + let web = request(&db, ®istry, "web-edit", Some(B)); + let cli = request( + &other, + ®istry, + "cli-edit", + Some("{} as WorkspaceConfigSchema"), + ); + assert_eq!(web.expected_head, cli.expected_head); + let barrier = std::sync::Barrier::new(2); + let (web, cli) = std::thread::scope(|scope| { + let web = scope.spawn(|| { + barrier.wait(); + adopt(&db, &host(false), &web) + }); + let cli = scope.spawn(|| { + barrier.wait(); + adopt(&other, &host(false), &cli) + }); + (web.join().unwrap(), cli.join().unwrap()) + }); + let (winner, loser) = match (web, cli) { + (Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser), + results => panic!("exactly one observed head may adopt: {results:?}"), + }; + assert!(matches!(loser, Error::WorkspaceConfigConflict(_))); + assert_eq!( + db.load_workspace_config(WORKSPACE).unwrap(), + Some(winner.state) + ); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(winner.history_head) + ); + assert_eq!( + other + .configuration_current(WORKSPACE, OWNER) + .unwrap() + .history_head, + db.configuration_head(WORKSPACE, OWNER).unwrap() + ); +} diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 9036a50ac..2162d4045 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -183,49 +183,56 @@ impl SqliteWorkspaceStore { schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { let desired_schema = schema_bundle.clone(); - let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| { - let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let workspace_exists: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", - [workspace_id], - |row| row.get(0), - )?; - if !workspace_exists { - return Err(Error::WorkspaceIdMismatch); - } - let stored_decodal_version = tx + let (state, requires_toolchain_refresh, base_identity, canonical) = + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let workspace_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + [workspace_id], + |row| row.get(0), + )?; + if !workspace_exists { + return Err(Error::WorkspaceIdMismatch); + } + let stored_decodal_version = tx .query_row( "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", [workspace_id], |row| row.get::<_, String>(0), ) .optional()?; - let requires_toolchain_refresh = stored_decodal_version - .as_deref() - .is_some_and(|version| version != DECODAL_VERSION); - let state = match load_state(&tx, workspace_id)? { - Some(state) => state, - None => { - let mut state = initial_state_with_schema(schema_bundle.clone())?; - if has_plugin_provider(&schema_bundle) { - let (contract, evaluation, _) = - crate::plugin_settings::evaluate_catalog_config( - &tx, - workspace_id, - &state.snapshot, - schema_bundle.clone(), - )?; - state.contract = contract; - state.projection_digest = evaluation.projection_digest; + let requires_toolchain_refresh = stored_decodal_version + .as_deref() + .is_some_and(|version| version != DECODAL_VERSION); + let state = match load_state(&tx, workspace_id)? { + Some(state) => state, + None => { + let mut state = initial_state_with_schema(schema_bundle.clone())?; + if has_plugin_provider(&schema_bundle) { + let (contract, evaluation, _) = + crate::plugin_settings::evaluate_catalog_config( + &tx, + workspace_id, + &state.snapshot, + schema_bundle.clone(), + )?; + state.contract = contract; + state.projection_digest = evaluation.projection_digest; + } + insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; + state } - insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; - state - } - }; - let base_identity = load_evaluation_identity(&tx, workspace_id)?; - tx.commit()?; - Ok((state, requires_toolchain_refresh, base_identity)) - })?; + }; + let base_identity = load_evaluation_identity(&tx, workspace_id)?; + let canonical = crate::config_adoption::canonical(&tx, workspace_id)?; + tx.commit()?; + Ok((state, requires_toolchain_refresh, base_identity, canonical)) + })?; + // Once adopted, schema changes must be previewed/adopted by the owner, + // not persisted as a second startup writer outside the policy/history tx. + if canonical { + return Ok(state); + } let desired_schema = crate::plugin_settings::materialization_schema( desired_schema, &state.contract.schema_bundle, @@ -570,14 +577,21 @@ fn evaluate_candidate( } pub(crate) fn sync_adopted_config_references( - tx: &rusqlite::Transaction<'_>, workspace: &str, state: &WorkspaceConfigState, + tx: &rusqlite::Transaction<'_>, + workspace: &str, + state: &WorkspaceConfigState, ) -> Result<()> { if has_plugin_provider(&state.contract.schema_bundle) { let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config( - tx, workspace, &state.snapshot, state.contract.schema_bundle.clone(), + tx, + workspace, + &state.snapshot, + state.contract.schema_bundle.clone(), )?; if contract != state.contract || evaluation.projection_digest != state.projection_digest { - return Err(config_conflict("Plugin catalog/schema changed during adoption")); + return Err(config_conflict( + "Plugin catalog/schema changed during adoption", + )); } crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?; } diff --git a/crates/workspace-server/src/configuration_migration.rs b/crates/workspace-server/src/configuration_migration.rs index e0cfd7c95..c0fb37625 100644 --- a/crates/workspace-server/src/configuration_migration.rs +++ b/crates/workspace-server/src/configuration_migration.rs @@ -62,13 +62,20 @@ pub(crate) fn preview_migration( Error::RegistryInconsistency("workspace config tree is not materialized".into()) }) })?; - let snapshot = legacy.preview_permission_transfer(&identity).map_err(legacy_error)?; + let snapshot = legacy + .preview_permission_transfer(&identity) + .map_err(legacy_error)?; if snapshot.identity != identity { - return Err(Error::WorkspaceConfigConflict("legacy export identity changed".into())); + return Err(Error::WorkspaceConfigConflict( + "legacy export identity changed".into(), + )); } let (policies, diagnostics) = migrate_policies(host, &snapshot)?; let source = PermissionSource { - subjects: policies.values().map(|policy| policy.source.clone()).collect(), + subjects: policies + .values() + .map(|policy| policy.source.clone()) + .collect(), remove_subjects: vec![], }; let changes = draft_changes(&original, &source)?; @@ -90,7 +97,11 @@ pub(crate) fn preview_migration( "migration import did not produce the exact generated Subject assignments".into(), )); } - let subjects: BTreeSet<_> = snapshot.sources.iter().map(|item| item.subject_id.as_str()).collect(); + let subjects: BTreeSet<_> = snapshot + .sources + .iter() + .map(|item| item.subject_id.as_str()) + .collect(); policy_config::validate_source(host, &evaluated_source, |subject| { if subjects.contains(subject) { Ok(()) @@ -103,7 +114,13 @@ pub(crate) fn preview_migration( contract: candidate.contract, projection_digest: candidate.evaluation.projection_digest, }; - Ok(MigrationSeed { original, state, snapshot, policies, diagnostics }) + Ok(MigrationSeed { + original, + state, + snapshot, + policies, + diagnostics, + }) } fn legacy_error(error: subjektiv::SubjektivError) -> Error { @@ -117,17 +134,25 @@ fn path(value: &str) -> Result { fn migrate_policies( host: &PermissionHost, snapshot: &PermissionTransferSnapshot, -) -> Result<(BTreeMap, Vec)> { +) -> Result<( + BTreeMap, + Vec, +)> { let mut policies = BTreeMap::new(); let mut diagnostics = vec![]; let mut seen = BTreeSet::new(); for item in &snapshot.sources { if !seen.insert(&item.subject_id) { - return Err(Error::RegistryInconsistency("duplicate Subject in legacy export".into())); + return Err(Error::RegistryInconsistency( + "duplicate Subject in legacy export".into(), + )); } // None stays unset. In particular, never enroll it in a default Set. - let Some(legacy) = &item.permissions else { continue }; - let (policy, mut converted) = policy_config::migrate_subject(host, &item.subject_id, legacy)?; + let Some(legacy) = &item.permissions else { + continue; + }; + let (policy, mut converted) = + policy_config::migrate_subject(host, &item.subject_id, legacy)?; policies.insert(item.subject_id.clone(), policy); // ExplicitConfigurationRequired must survive preview, even though the // unsupported operation is absent from the editable assignment. Core must @@ -137,7 +162,10 @@ fn migrate_policies( Ok((policies, diagnostics)) } -fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Result> { +fn draft_changes( + original: &WorkspaceConfigState, + source: &PermissionSource, +) -> Result> { // Evaluate with the existing open schema, not application defaults. This // distinguishes an explicit `permissions = {}` (including imports/dotted // fields) from an omitted field without introducing another Decodal parser. @@ -149,10 +177,16 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> ); let raw = SnapshotEnvironment::new(original.snapshot.clone()) .evaluate_contract(&open_contract) - .map_err(|diagnostics| Error::InvalidInput(format!( - "cannot inspect current configuration before permission migration: {diagnostics:?}" - )))?; - if raw.projections.iter().any(|projection| projection.data_json.get("permissions").is_some()) { + .map_err(|diagnostics| { + Error::InvalidInput(format!( + "cannot inspect current configuration before permission migration: {diagnostics:?}" + )) + })?; + if raw + .projections + .iter() + .any(|projection| projection.data_json.get("permissions").is_some()) + { return Err(Error::InvalidInput( "current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(), )); @@ -163,13 +197,17 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Error::RegistryInconsistency("workspace config tree has no main.dcdl".into()) })?; if main.content_type != ConfigContentType::Decodal { - return Err(Error::InvalidInput("main.dcdl is not Decodal source".into())); + return Err(Error::InvalidInput( + "main.dcdl is not Decodal source".into(), + )); } // Use the existing syntax formatter to supply a final field separator when // needed. It preserves comments; no local lexer or alternate language rules. let formatted_main = SnapshotEnvironment::new(original.snapshot.clone()) .format(&main.content) - .map_err(|error| Error::InvalidInput(format!("cannot format migration entrypoint: {error}")))?; + .map_err(|error| { + Error::InvalidInput(format!("cannot format migration entrypoint: {error}")) + })?; Ok(vec![ ConfigTreeChange::Create { path: generated_path.clone(), @@ -206,8 +244,10 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result { // assertion. Limit this edit to that existing representation: no rewrites of // arbitrary expressions, no JSON round-trip, no replacement of other fields. let trimmed = source.trim_end(); - let object = trimmed.strip_suffix("as WorkspaceConfigSchema") - .map(str::trim_end).unwrap_or(trimmed); + let object = trimmed + .strip_suffix("as WorkspaceConfigSchema") + .map(str::trim_end) + .unwrap_or(trimmed); if !object.starts_with('{') || !object.ends_with('}') { return Err(Error::InvalidInput( "permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(), @@ -215,7 +255,11 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result { } let close = object.len() - 1; let import = quote(&format!("./{}", imported.as_str())); - Ok(format!("{}\n permissions = import {import};\n{}", &source[..close], &source[close..])) + Ok(format!( + "{}\n permissions = import {import};\n{}", + &source[..close], + &source[close..] + )) } // A deliberately specific, payload-free renderer. JSON quoting is used only for @@ -225,15 +269,23 @@ fn quote(value: &str) -> String { } fn render_strings<'a>(values: impl IntoIterator) -> String { - format!("[{}]", values.into_iter().map(quote).collect::>().join(", ")) + format!( + "[{}]", + values.into_iter().map(quote).collect::>().join(", ") + ) } fn render_namespaces(values: &[NamespacePermissions]) -> String { - let items = values.iter().map(|value| format!( - "{{ namespace = {}; keys = {}; }}", - quote(value.namespace.as_str()), - render_strings(value.keys.iter().map(|key| key.as_str())), - )).collect::>(); + let items = values + .iter() + .map(|value| { + format!( + "{{ namespace = {}; keys = {}; }}", + quote(value.namespace.as_str()), + render_strings(value.keys.iter().map(|key| key.as_str())), + ) + }) + .collect::>(); format!("[{}]", items.join(", ")) } @@ -249,10 +301,19 @@ fn render_assignment(source: &AssignmentSource) -> String { } fn render_permission_source(source: &PermissionSource) -> String { - let subjects = source.subjects.iter().map(|item| format!( - " {{ subject_id = {}; execution = {}; delegation = {}; }}", - quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation), - )).collect::>().join(",\n"); + let subjects = source + .subjects + .iter() + .map(|item| { + format!( + " {{ subject_id = {}; execution = {}; delegation = {}; }}", + quote(&item.subject_id), + render_assignment(&item.execution), + render_assignment(&item.delegation), + ) + }) + .collect::>() + .join(",\n"); format!( "{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n", render_strings(source.remove_subjects.iter().map(String::as_str)), @@ -264,8 +325,8 @@ mod tests { use super::*; use config_source::{ConfigEntry, EvaluationResult}; use feature_permissions::{ - FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, - PermissionKey, PermissionSetId, WorkspaceId, + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionKey, + PermissionSetId, WorkspaceId, }; use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions}; use subjektiv::{DatabaseIdentity, PermissionSourceExport}; @@ -274,22 +335,35 @@ mod tests { fn host(children: bool) -> PermissionHost { let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); - let feature = host.register_feature(FeatureIdentity::parse("builtin:file").unwrap()).unwrap(); - let namespace = host.bind_namespace(&feature, NamespaceId::parse("file").unwrap()).unwrap(); - host.declare(&namespace, NamespaceDeclaration { - permissions: vec![ - PermissionDefinition { - key: PermissionKey::parse("file.read").unwrap(), - description: String::new(), - children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] }, - }, - PermissionDefinition { - key: PermissionKey::parse("file.read.text").unwrap(), - description: String::new(), children: vec![], - }, - ], - sets: vec![], - }).unwrap(); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:file").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("file").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("file.read").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("file.read.text").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("file.read.text").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![], + }, + ) + .unwrap(); host } @@ -297,7 +371,9 @@ mod tests { ScopedPermission { operation: operation.into(), resource: PermissionResource::Workdir { - working_directory_id: "one-workdir".into(), path: "only-this.txt".into(), recursive: false, + working_directory_id: "one-workdir".into(), + path: "only-this.txt".into(), + recursive: false, }, } } @@ -305,18 +381,25 @@ mod tests { fn export(sources: Vec) -> PermissionTransferSnapshot { PermissionTransferSnapshot { identity: DatabaseIdentity { - scope_id: WORKSPACE.into(), feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), + scope_id: WORKSPACE.into(), + feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), physical_id: "typed-test-identity".into(), }, - sources, export_digest: "not-used-by-pure-conversion-tests".into(), + sources, + export_digest: "not-used-by-pure-conversion-tests".into(), } } fn current(main: &str, extra: Vec) -> WorkspaceConfigState { let mut current = crate::config_source::initial_state().unwrap(); - let mut entries = vec![ConfigEntry::new( - path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, main, - ).unwrap()]; + let mut entries = vec![ + ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + main, + ) + .unwrap(), + ]; entries.extend(extra); current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap(); current @@ -324,43 +407,81 @@ mod tests { fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult { let contract = crate::config_source::initial_state().unwrap().contract; - SnapshotEnvironment::new(snapshot).evaluate_contract(&contract).unwrap() + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap() } #[test] fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() { - let empty = SubjectPermissions { change_id: "explicit-empty".into(), execution: vec![], delegation: vec![] }; + let empty = SubjectPermissions { + change_id: "explicit-empty".into(), + execution: vec![], + delegation: vec![], + }; let limited = SubjectPermissions { - change_id: "keep-limited-change".into(), execution: vec![limited("file.read")], + change_id: "keep-limited-change".into(), + execution: vec![limited("file.read")], delegation: vec![limited("file.read.text")], }; let snapshot = export(vec![ - PermissionSourceExport { subject_id: "unset".into(), permissions: None }, - PermissionSourceExport { subject_id: "empty".into(), permissions: Some(empty.clone()) }, - PermissionSourceExport { subject_id: "limited".into(), permissions: Some(limited.clone()) }, + PermissionSourceExport { + subject_id: "unset".into(), + permissions: None, + }, + PermissionSourceExport { + subject_id: "empty".into(), + permissions: Some(empty.clone()), + }, + PermissionSourceExport { + subject_id: "limited".into(), + permissions: Some(limited.clone()), + }, ]); let host = host(false); let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); assert!(!policies.contains_key("unset")); assert_eq!(policies["empty"].change_id, empty.change_id); - assert_eq!(policies["empty"].source.execution, AssignmentSource::default()); - assert_eq!(policies["empty"].source.delegation, AssignmentSource::default()); + assert_eq!( + policies["empty"].source.execution, + AssignmentSource::default() + ); + assert_eq!( + policies["empty"].source.delegation, + AssignmentSource::default() + ); assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited); assert_eq!(diagnostics.len(), 2); - assert!(diagnostics.iter().all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))); + assert!( + diagnostics + .iter() + .all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. })) + ); } #[test] fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() { - for (host, permission) in [(host(false), limited("file.unknown")), (host(true), limited("file.read"))] { + for (host, permission) in [ + (host(false), limited("file.unknown")), + (host(true), limited("file.read")), + ] { let legacy = SubjectPermissions { - change_id: "keep-change".into(), execution: vec![permission.clone()], delegation: vec![], + change_id: "keep-change".into(), + execution: vec![permission.clone()], + delegation: vec![], }; - let snapshot = export(vec![PermissionSourceExport { subject_id: "blocked".into(), permissions: Some(legacy.clone()) }]); - let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); - assert_eq!(diagnostics, vec![MigrationDiagnostic::ExplicitConfigurationRequired { - subject_id: "blocked".into(), permission, + let snapshot = export(vec![PermissionSourceExport { + subject_id: "blocked".into(), + permissions: Some(legacy.clone()), }]); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert_eq!( + diagnostics, + vec![MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: "blocked".into(), + permission, + }] + ); assert_eq!(policies["blocked"].legacy_constraints, Some(legacy)); assert!(policies["blocked"].source.execution.namespaces.is_empty()); assert!(policies["blocked"].source.execution.sets.is_empty()); @@ -384,7 +505,8 @@ mod tests { }; let source = PermissionSource { subjects: vec![policy_config::SubjectAssignmentSource { - subject_id: "subject-\"quoted\\日本語".into(), execution: assignment, + subject_id: "subject-\"quoted\\日本語".into(), + execution: assignment, delegation: AssignmentSource::default(), }], remove_subjects: vec!["removed".into()], @@ -393,33 +515,58 @@ mod tests { assert!(rendered.contains("sets = [\"shared\"]")); assert!(rendered.contains("keys = [\"file.read.text\"]")); assert!(!rendered.contains("legacy_constraints")); - let snapshot = ConfigTreeSnapshot::from_entries([ - ConfigEntry::new(path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, - format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema")).unwrap(), - ]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema"), + ) + .unwrap()]) + .unwrap(); assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source); } #[test] fn draft_preserves_main_comments_other_settings_and_existing_migration_files() { - let main = "{\n // permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; - let occupied = ConfigEntry::new(path("permissions/migrated.dcdl").unwrap(), ConfigContentType::Decodal, "{ keep = true; }\n").unwrap(); + let main = "{\n # permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; + let occupied = ConfigEntry::new( + path("permissions/migrated.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ keep = true; }\n", + ) + .unwrap(); let original = current(main, vec![occupied.clone()]); let source = PermissionSource::default(); let changes = draft_changes(&original, &source).unwrap(); let draft = original.snapshot.apply(&changes).unwrap(); assert_eq!(draft.get(&occupied.path), Some(&occupied)); - assert!(draft.get(&path("permissions/migrated-1.dcdl").unwrap()).is_some()); - let updated_main = &draft.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content; - assert!(updated_main.contains("// permissions in a comment are not a field")); + assert!( + draft + .get(&path("permissions/migrated-1.dcdl").unwrap()) + .is_some() + ); + let updated_main = &draft + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content; + assert!(updated_main.contains("# permissions in a comment are not a field")); assert!(updated_main.contains("brace } and permissions")); assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n")); let before = evaluate(original.snapshot.clone()); let after = evaluate(draft); - assert_eq!(after.projections[0].data_json["other"], before.projections[0].data_json["other"]); + assert_eq!( + after.projections[0].data_json["other"], + before.projections[0].data_json["other"] + ); assert_eq!(policy_config::source(&after).unwrap(), source); - assert_eq!(original.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content, main); + assert_eq!( + original + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content, + main + ); } #[test] @@ -429,11 +576,19 @@ mod tests { "{ permissions.subjects = []; } as WorkspaceConfigSchema", "{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema", ] { - let existing = ConfigEntry::new(path("permissions/existing.dcdl").unwrap(), ConfigContentType::Decodal, "{ subjects = []; }").unwrap(); + let existing = ConfigEntry::new( + path("permissions/existing.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ subjects = []; }", + ) + .unwrap(); let original = current(main, vec![existing]); let before = original.clone(); let error = draft_changes(&original, &PermissionSource::default()).unwrap_err(); - assert!(error.to_string().contains("explicitly defines permissions"), "{error}"); + assert!( + error.to_string().contains("explicitly defines permissions"), + "{error}" + ); assert_eq!(original, before); } } diff --git a/crates/workspace-server/src/configuration_transfer_tests.rs b/crates/workspace-server/src/configuration_transfer_tests.rs new file mode 100644 index 000000000..d5271da48 --- /dev/null +++ b/crates/workspace-server/src/configuration_transfer_tests.rs @@ -0,0 +1,1062 @@ +//! Cross-store component tests connect configuration migration and adoption to a +//! real guarded Subjektiv FeatureDatabase and the production canonical-source +//! bridge, which reads the real Server policy store on every call. +//! These ordinary crate tests use isolated temporary SQLite stores, not HTTP, +//! authenticated Worker execution, or a distributed transaction/crash guarantee. + +use std::path::Path; +use std::sync::Arc; + +use config_source::{ + ConfigContentType, ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle, +}; +use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionHost, + PermissionKey, WorkspaceId, +}; +use feature_storage::{FeatureDatabase, FeatureStorage, ScopedFeatureStorage}; +use rusqlite::Connection; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + WorkerExecutionGrant, +}; +use subjektiv::{SubjectRole, SubjektivError, SubjektivStore}; + +use crate::config_adoption::{ + CanonicalConfigurationSource, ConfigurationAdoptionRequest, ConfigurationAdoptionResult, + ConfigurationMigrationBaseline, ConfigurationPolicyTransfer, catalog_digest, + request_fingerprint, +}; +use crate::config_source::{ + ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider, + WorkspaceConfigState, evaluate_workspace_config_state, +}; +use crate::configuration_migration::{MigrationSeed, preview_migration}; +use crate::policy_config::{AssignmentSource, MigrationDiagnostic, PermissionConfigSchemaProvider}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +const WORKSPACE: &str = "configuration-transfer-workspace"; +const OWNER: &str = "configuration-transfer-owner"; +const EXISTING_MAIN: &str = r#"{ + # Keep this owner's explanation, including the word permissions. + prompts = { common = { language = import "./existing.dcdl"; }; }; +} as WorkspaceConfigSchema +"#; +const EXISTING_DATA: &str = "\"KEEP LANGUAGE\"\n"; +const OCCUPIED_MIGRATION: &str = + "# An unrelated, unreferenced file must not be overwritten.\n{ keep = true; }\n"; + +fn path(value: &str) -> VirtualPath { + VirtualPath::parse(value).unwrap() +} + +fn schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ + PermissionConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} + +fn host() -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:file").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("file").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: ["file.read", "file.read.text"] + .into_iter() + .map(|key| PermissionDefinition { + key: PermissionKey::parse(key).unwrap(), + description: String::new(), + children: vec![], + }) + .collect(), + sets: vec![], + }, + ) + .unwrap(); + host +} + +fn limited(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), + path: "only-this.txt".into(), + recursive: false, + }, + } +} + +struct FeatureFixture { + store: SubjektivStore, + database: FeatureDatabase, + scope: ScopedFeatureStorage, + manager: FeatureStorage, +} + +impl FeatureFixture { + fn open(root: &Path, expected: Option<&feature_storage::DatabaseIdentity>) -> Self { + let manager = FeatureStorage::new(root); + let scope = manager.workspace(WORKSPACE).unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let database = match expected { + Some(identity) => scope.open_existing(®istration, identity).unwrap(), + None => scope + .provision( + ®istration, + "fixture-initial-provision", + "isolated component-test Host provisioning", + ) + .unwrap(), + }; + let store = SubjektivStore::from_database(database.clone()).unwrap(); + Self { + store, + database, + scope, + manager, + } + } + + fn close(self) { + drop(self.store); + drop(self.database); + self.scope.shutdown().unwrap(); + self.manager.shutdown().unwrap(); + } +} + +struct Subjects { + unset: String, + empty: String, + limited: String, + empty_source: SubjectPermissions, + limited_source: SubjectPermissions, + exact_legacy_update: SubjectPermissionsUpdate, + stale_grant: WorkerExecutionGrant, + live_grant: WorkerExecutionGrant, +} + +fn grant(subject: &str, change: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), + source_change_id: change.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "authenticated-root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![limited("file.read.text")], + redelegation: vec![], + } +} + +fn replace( + store: &SubjektivStore, + subject: &str, + id: &str, + execution: Vec, + delegation: Vec, +) -> (SubjectPermissionsUpdate, SubjectPermissions) { + let update = SubjectPermissionsUpdate { + operation_id: id.into(), + expected_change_id: store + .subject_permissions(subject) + .unwrap() + .map(|source| source.change_id), + execution, + delegation, + }; + let source = store.set_subject_permissions(subject, &update).unwrap(); + (update, source) +} + +fn subjects(features: &FeatureFixture) -> Subjects { + let create = || { + features + .store + .create_subject(SubjectRole::new("component fixture").unwrap()) + .unwrap() + .id + }; + let unset = create(); + let empty = create(); + let subject = create(); + // Historical Subjects can have no source. Keep the production schema and + // physical identity; seed only that historical row state through its guard. + features + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset], + )?; + Ok(()) + }) + .unwrap(); + let (_, empty_source) = replace(&features.store, &empty, "explicit-empty", vec![], vec![]); + let (_, old) = replace( + &features.store, + &subject, + "old-limited", + vec![limited("file.read")], + vec![limited("file.read.text")], + ); + let stale_grant = grant(&subject, &old.change_id, "already-stale-child"); + features + .store + .bind_worker_execution_grant(&stale_grant) + .unwrap(); + let (exact_legacy_update, limited_source) = replace( + &features.store, + &subject, + "current-limited", + vec![limited("file.read")], + vec![limited("file.read.text")], + ); + let live_grant = grant(&subject, &limited_source.change_id, "live-child"); + features + .store + .bind_worker_execution_grant(&live_grant) + .unwrap(); + Subjects { + unset, + empty, + limited: subject, + empty_source, + limited_source, + exact_legacy_update, + stale_grant, + live_grant, + } +} + +fn server(root: &Path) -> SqliteWorkspaceStore { + SqliteWorkspaceStore::open_for_test(root.join("server.sqlite")).unwrap() +} + +fn fixture() -> ( + tempfile::TempDir, + SqliteWorkspaceStore, + FeatureFixture, + Subjects, +) { + let root = tempfile::tempdir().unwrap(); + let server = server(root.path()); + server.with_immediate_transaction(|tx| { + tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?1,'Owner','1','1')", [OWNER])?; + tx.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES(?1,?2,'Transfer','active','1','1')", [WORKSPACE, OWNER])?; + Ok(()) + }).unwrap(); + let initial = server + .ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema()) + .unwrap(); + let main = path(MAIN_CONFIG_ENTRYPOINT); + let candidate = server + .evaluate_workspace_config_candidate_with_schema( + WORKSPACE, + &ConfigCommitRequest { + base_digest: initial.snapshot.digest.clone(), + changes: vec![ + ConfigTreeChange::Update { + path: main.clone(), + expected_digest: initial + .snapshot + .get(&main) + .unwrap() + .content_digest + .clone(), + content: EXISTING_MAIN.into(), + }, + ConfigTreeChange::Create { + path: path("existing.dcdl"), + content_type: ConfigContentType::Decodal, + content: EXISTING_DATA.into(), + }, + ConfigTreeChange::Create { + path: path("permissions/migrated.dcdl"), + content_type: ConfigContentType::Decodal, + content: OCCUPIED_MIGRATION.into(), + }, + ], + entrypoints: vec![main], + }, + schema(), + ) + .unwrap(); + server + .commit_evaluated_workspace_config(WORKSPACE, &candidate) + .unwrap(); + let features = FeatureFixture::open(&root.path().join("features"), None); + let subjects = subjects(&features); + (root, server, features, subjects) +} + +fn migration( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, +) -> MigrationSeed { + preview_migration(server, &features.store, WORKSPACE, schema(), host).unwrap() +} + +fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> { + ConfigurationMigrationBaseline { + original: &seed.original, + state: &seed.state, + policies: &seed.policies, + diagnostics: &seed.diagnostics, + export_digest: &seed.snapshot.export_digest, + } +} + +fn migration_request(seed: &MigrationSeed, host: &PermissionHost) -> ConfigurationAdoptionRequest { + ConfigurationAdoptionRequest { + operation_id: "adopt-migration".into(), + expected_head: None, + expected_catalog_digest: catalog_digest(host).unwrap(), + expected_legacy_export: Some(seed.snapshot.export_digest.clone()), + source: ConfigCommitRequest { + base_digest: seed.state.snapshot.digest.clone(), + changes: vec![], + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + } +} + +fn require_subject(store: &SubjektivStore, subject: &str) -> Result<()> { + store + .subject(subject) + .map_err(|error| Error::Store(error.to_string()))? + .ok_or_else(|| Error::SubjektivSubjectNotFound(subject.into()))?; + Ok(()) +} + +fn adopt_migration( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, + seed: &MigrationSeed, + request: &ConfigurationAdoptionRequest, +) -> Result { + features + .store + .with_permission_authority_guard(&seed.snapshot.identity, |authority| { + Ok(server.adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + Some(&baseline(seed)), + |subject| { + authority + .require_subject(subject) + .map_err(|error| Error::Store(error.to_string())) + }, + |fingerprint| { + let receipt = authority + .seal_permission_sources(fingerprint, &seed.snapshot.export_digest) + .map_err(|error| Error::Store(error.to_string()))?; + assert_eq!(receipt.snapshot, seed.snapshot); + Ok(ConfigurationPolicyTransfer { + transfer_identity: receipt.transfer_id, + physical_identity: receipt.snapshot.identity.physical_id, + export_digest: receipt.snapshot.export_digest, + policies: seed.policies.clone(), + }) + }, + )) + }) + .map_err(|error| Error::Store(error.to_string()))? +} + +// Observe persisted records rather than calls. Sorting row renderings also covers +// composite-key/WITHOUT ROWID tables without relying on SQLite traversal order. +fn table_rows(conn: &Connection, table: &str) -> rusqlite::Result> { + let mut statement = conn.prepare(&format!("SELECT * FROM {table}"))?; + let columns = statement.column_count(); + let mut rows = statement + .query_map([], |row| { + let values = (0..columns) + .map(|index| row.get::<_, rusqlite::types::Value>(index)) + .collect::>>()?; + Ok(format!("{values:?}")) + })? + .collect::>>()?; + rows.sort(); + Ok(rows) +} + +fn server_records(server: &SqliteWorkspaceStore) -> Vec<(String, Vec)> { + server.with_conn(|conn| { + let tables = conn.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND (name LIKE 'workspace_config%' OR name LIKE 'content_%') ORDER BY name")? + .query_map([], |row| row.get::<_, String>(0))?.collect::>>()?; + tables.into_iter().map(|table| Ok((table.clone(), table_rows(conn, &table)?))).collect() + }).unwrap() +} + +fn legacy_records(features: &FeatureFixture) -> Vec> { + features + .database + .try_with_connection::<_, SubjektivError>(|conn| { + [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", + ] + .into_iter() + .map(|table| Ok(table_rows(conn, table)?)) + .collect() + }) + .unwrap() +} + +fn feature_schema(features: &FeatureFixture) -> Vec { + features + .database + .try_with_connection::<_, SubjektivError>(|conn| Ok(table_rows(conn, "sqlite_schema")?)) + .unwrap() +} + +fn assert_existing_data_preserved(before: &WorkspaceConfigState, after: &WorkspaceConfigState) { + let before_projection = evaluate_workspace_config_state(before, schema()).unwrap(); + let after_projection = evaluate_workspace_config_state(after, schema()).unwrap(); + for field in ["prompts"] { + assert_eq!( + after_projection.projections[0].data_json[field], + before_projection.projections[0].data_json[field], + "{field}" + ); + } + for file in ["existing.dcdl", "permissions/migrated.dcdl"] { + assert_eq!( + after.snapshot.get(&path(file)), + before.snapshot.get(&path(file)), + "{file}" + ); + } + let main = &after + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT)) + .unwrap() + .content; + assert!(main.contains("# Keep this owner's explanation, including the word permissions.")); + assert!(main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); + assert!( + after + .snapshot + .get(&path("permissions/migrated-1.dcdl")) + .is_some() + ); +} + +#[test] +fn migration_and_adoption_preview_preserve_existing_data_without_writing_either_store() { + let (_root, server, features, subjects) = fixture(); + let host = host(); + let before = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let server_before = server_records(&server); + let legacy_before = legacy_records(&features); + let schema_before = feature_schema(&features); + let seed = migration(&server, &features, &host); + let request = migration_request(&seed, &host); + let preview = server + .preview_configuration_adoption( + WORKSPACE, + OWNER, + &request, + schema(), + &host, + Some(&baseline(&seed)), + |subject| require_subject(&features.store, subject), + ) + .unwrap(); + assert_eq!(preview.state, seed.state); + assert_existing_data_preserved(&before, &preview.state); + assert!(!seed.policies.contains_key(&subjects.unset)); + assert_eq!( + seed.policies[&subjects.empty].change_id, + subjects.empty_source.change_id + ); + assert_eq!( + seed.policies[&subjects.empty].source.execution, + AssignmentSource::default() + ); + assert_eq!( + seed.policies[&subjects.empty].source.delegation, + AssignmentSource::default() + ); + assert_eq!( + seed.policies[&subjects.limited].legacy_read(&host).unwrap(), + subjects.limited_source + ); + assert!( + seed.diagnostics + .iter() + .all(|diagnostic| matches!(diagnostic, MigrationDiagnostic::DomainConstraint { .. })) + ); + assert_eq!(seed.diagnostics.len(), 2); + assert_eq!( + server.load_workspace_config(WORKSPACE).unwrap(), + Some(before) + ); + assert_eq!(server.configuration_head(WORKSPACE, OWNER).unwrap(), None); + assert_eq!( + server + .configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + None + ); + assert_eq!(server_records(&server), server_before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!(feature_schema(&features), schema_before); + assert!( + features + .store + .permission_transfer_receipt(&seed.snapshot.identity) + .unwrap() + .is_none() + ); + assert_eq!( + features + .store + .subject_permissions(&subjects.limited) + .unwrap(), + Some(subjects.limited_source) + ); +} + +#[test] +fn unsupported_legacy_operation_blocks_core_adoption_before_the_real_seal_boundary() { + let (_root, server, features, subjects) = fixture(); + let host = host(); + let unsupported = limited("file.unknown"); + replace( + &features.store, + &subjects.limited, + "unsupported-legacy-source", + vec![unsupported.clone()], + vec![limited("file.read.text")], + ); + let before = server_records(&server); + let legacy_before = legacy_records(&features); + let schema_before = feature_schema(&features); + let seed = migration(&server, &features, &host); + assert!( + seed.diagnostics + .contains(&MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: subjects.limited.clone(), + permission: unsupported, + }) + ); + let request = migration_request(&seed, &host); + let error = adopt_migration(&server, &features, &host, &seed, &request).unwrap_err(); + assert!( + matches!(error, Error::InvalidInput(ref message) if message.contains("require explicit configuration")), + "{error}" + ); + assert_eq!(server_records(&server), before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!(feature_schema(&features), schema_before); + assert!( + features + .store + .permission_transfer_receipt(&seed.snapshot.identity) + .unwrap() + .is_none() + ); + // This is a pre-seal rejection, not a sealed-but-unadopted transfer. + replace( + &features.store, + &subjects.limited, + "writer-after-diagnostic", + vec![], + vec![], + ); +} + +#[test] +fn server_failure_after_seal_rolls_back_and_reopen_resumes_only_the_exact_full_request() { + let (root, db, features, subjects) = fixture(); + let host = host(); + let seed = migration(&db, &features, &host); + let request = migration_request(&seed, &host); + let before = server_records(&db); + let legacy_before = legacy_records(&features); + let identity = features.store.database_identity().unwrap(); + let physical_identity = features.database.identity().clone(); + let old_writer = SubjektivStore::from_database(features.database.clone()).unwrap(); + db.with_conn(|conn| { + conn.execute_batch("CREATE TEMP TRIGGER fail_adoption_receipt BEFORE INSERT ON workspace_configuration_receipts BEGIN SELECT RAISE(ABORT,'injected adoption receipt failure'); END;")?; + Ok(()) + }).unwrap(); + let error = adopt_migration(&db, &features, &host, &seed, &request).unwrap_err(); + assert!( + error + .to_string() + .contains("injected adoption receipt failure"), + "{error}" + ); + assert_eq!( + db.load_workspace_config(WORKSPACE).unwrap(), + Some(seed.original.clone()) + ); + assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + None + ); + assert_eq!(server_records(&db), before); + assert_eq!(legacy_records(&features), legacy_before); + assert!( + db.adopted_subject_policy(WORKSPACE, &subjects.limited) + .is_err() + ); + let fingerprint = request_fingerprint(WORKSPACE, OWNER, &request).unwrap(); + let sealed = features + .store + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!(sealed.transfer_id, fingerprint); + assert_eq!(sealed.snapshot, seed.snapshot); + // This is the same physical gate used by legacy logical configuration writes. + // A Server rollback must not reopen that writer or change its editing base. + let legacy_candidate = db + .evaluate_workspace_config_candidate_with_schema( + WORKSPACE, + &ConfigCommitRequest { + base_digest: seed.original.snapshot.digest.clone(), + changes: vec![ConfigTreeChange::Create { + path: path("late-legacy.txt"), + content_type: ConfigContentType::Text, + content: "must not be stored during pending cutover".into(), + }], + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + schema(), + ) + .unwrap(); + let legacy_write = features + .store + .with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok(db.commit_evaluated_workspace_config(WORKSPACE, &legacy_candidate)) + }); + assert!(matches!( + legacy_write, + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(server_records(&db), before); + assert_eq!( + features + .store + .seal_permission_sources(&identity, &fingerprint, &seed.snapshot.export_digest) + .unwrap(), + sealed + ); + assert!(matches!( + old_writer.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + old_writer.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.limited_source.change_id, + "no-canonical-provider" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + for update in [ + subjects.exact_legacy_update.clone(), + SubjectPermissionsUpdate { + operation_id: "late-old-writer".into(), + expected_change_id: Some(subjects.limited_source.change_id.clone()), + execution: vec![], + delegation: vec![], + }, + ] { + assert!(matches!( + old_writer.set_subject_permissions(&subjects.limited, &update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + old_writer.set_managed_subject_permissions(&subjects.limited, &update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + } + assert!( + features + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", + [&subjects.limited], + )?; + Ok(()) + }) + .is_err() + ); + drop(old_writer); + drop(db); + features.close(); + + let db = server(root.path()); + let features = FeatureFixture::open(&root.path().join("features"), Some(&physical_identity)); + assert_eq!(features.store.database_identity().unwrap(), identity); + let reopened_legacy_write = + features + .store + .with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok(db.commit_evaluated_workspace_config(WORKSPACE, &legacy_candidate)) + }); + assert!(matches!( + reopened_legacy_write, + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(server_records(&db), before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!( + features + .store + .seal_permission_sources(&identity, &fingerprint, &seed.snapshot.export_digest) + .unwrap(), + sealed + ); + assert!(matches!( + features.store.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + features + .store + .set_subject_permissions(&subjects.limited, &subjects.exact_legacy_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + let resumed = migration(&db, &features, &host); + assert_eq!(resumed.original, seed.original); + assert_eq!(resumed.state, seed.state); + assert_eq!(resumed.snapshot, seed.snapshot); + assert_eq!(migration_request(&resumed, &host), request); + let mut different_id = request.clone(); + different_id.operation_id = "not-the-pending-request".into(); + let error = adopt_migration(&db, &features, &host, &resumed, &different_id).unwrap_err(); + assert!( + error.to_string().contains("permission transfer conflicts"), + "{error}" + ); + let mut different_content = request.clone(); + let existing = path("existing.dcdl"); + let entry = resumed.state.snapshot.get(&existing).unwrap(); + different_content + .source + .changes + .push(ConfigTreeChange::Update { + path: existing, + expected_digest: entry.content_digest.clone(), + content: EXISTING_DATA.replace("count = 7", "count = 8"), + }); + let error = adopt_migration(&db, &features, &host, &resumed, &different_content).unwrap_err(); + assert!( + error.to_string().contains("permission transfer conflicts"), + "{error}" + ); + let mut different_export = request.clone(); + different_export.expected_legacy_export = Some("another-export".into()); + assert!(adopt_migration(&db, &features, &host, &resumed, &different_export).is_err()); + assert!(matches!( + features + .store + .seal_permission_sources(&identity, &fingerprint, "another-export"), + Err(SubjektivError::PermissionTransferConflict) + )); + assert_eq!(server_records(&db), before); + let adopted = adopt_migration(&db, &features, &host, &resumed, &request).unwrap(); + assert_eq!(adopted.state, seed.state); + assert_existing_data_preserved(&seed.original, &adopted.state); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(adopted.history_head.clone()) + ); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + Some(adopted.clone()) + ); + assert_eq!( + adopt_migration(&db, &features, &host, &resumed, &request).unwrap(), + adopted + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.unset) + .unwrap(), + None + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.empty) + .unwrap() + .unwrap() + .legacy_read(&host) + .unwrap(), + subjects.empty_source + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.limited) + .unwrap() + .unwrap() + .legacy_read(&host) + .unwrap(), + subjects.limited_source + ); + for grant in [&subjects.stale_grant, &subjects.live_grant] { + assert_eq!( + features + .store + .worker_execution_grant(&grant.runtime_id, &grant.worker_id) + .unwrap(), + Some(grant.clone()) + ); + } + assert_eq!(legacy_records(&features), legacy_before); +} + +fn edit_request( + server: &SqliteWorkspaceStore, + host: &PermissionHost, + id: &str, + changes: Vec, +) -> ConfigurationAdoptionRequest { + let current = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + ConfigurationAdoptionRequest { + operation_id: id.into(), + expected_head: server.configuration_head(WORKSPACE, OWNER).unwrap(), + expected_catalog_digest: catalog_digest(host).unwrap(), + expected_legacy_export: None, + source: ConfigCommitRequest { + base_digest: current.snapshot.digest, + changes, + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + } +} + +fn adopt_edit( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, + request: &ConfigurationAdoptionRequest, +) -> ConfigurationAdoptionResult { + server + .adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + None, + |subject| require_subject(&features.store, subject), + |_| panic!("an ordinary edit must not prepare another legacy transfer"), + ) + .unwrap() +} + +#[test] +fn adopted_source_remains_editable_and_canonical_reads_cannot_widen_or_revive_old_grants() { + let (root, server, features, subjects) = fixture(); + let host = host(); + let seed = migration(&server, &features, &host); + let initial_request = migration_request(&seed, &host); + let initial = adopt_migration(&server, &features, &host, &seed, &initial_request).unwrap(); + let legacy_before = legacy_records(&features); + let identity = features.database.identity().clone(); + drop(server); + features.close(); + let server = self::server(root.path()); + let features = FeatureFixture::open(&root.path().join("features"), Some(&identity)); + assert_eq!( + server.load_workspace_config(WORKSPACE).unwrap(), + Some(initial.state.clone()) + ); + assert_eq!( + server + .configuration_adoption_result(WORKSPACE, OWNER, &initial_request.operation_id) + .unwrap(), + Some(initial.clone()) + ); + assert_eq!(legacy_records(&features), legacy_before); + assert!(matches!( + features.store.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let canonical = features + .store + .clone() + .with_canonical_permission_source(Arc::new(CanonicalConfigurationSource { + store: server.clone(), + host: self::host(), + })); + assert_eq!( + canonical.subject_permissions(&subjects.unset).unwrap(), + None + ); + assert_eq!( + canonical.subject_permissions(&subjects.empty).unwrap(), + Some(subjects.empty_source.clone()) + ); + assert_eq!( + canonical.subject_permissions(&subjects.limited).unwrap(), + Some(subjects.limited_source.clone()) + ); + let child = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "canonical-child", + ); + canonical.bind_worker_execution_grant(&child).unwrap(); + let mut execution_only = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "execution-is-not-delegation", + ); + execution_only.permissions = vec![limited("file.read")]; + assert!(matches!( + canonical.bind_worker_execution_grant(&execution_only), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let mut wider = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "wider-child", + ); + wider.permissions[0].resource = PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), + path: ".".into(), + recursive: true, + }; + assert!(matches!( + canonical.bind_worker_execution_grant(&wider), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.stale_grant.source_change_id, + "stale-source-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let mut wrong_workdir = child.clone(); + wrong_workdir.worker_id = "other-workdir-child".into(); + wrong_workdir.permissions[0].resource = PermissionResource::Workdir { + working_directory_id: "another-workdir".into(), + path: "only-this.txt".into(), + recursive: false, + }; + assert!(matches!( + canonical.bind_worker_execution_grant(&wrong_workdir), + Err(SubjektivError::AuthorizationDenied(_)) + )); + + let generated = path("permissions/migrated-1.dcdl"); + let state = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let entry = state.snapshot.get(&generated).unwrap(); + let revoked_source = format!( + "{{ subjects = [{{ subject_id = {}; execution = {{ revoked = true; }}; delegation = {{ revoked = true; }}; }}]; }}\n", + serde_json::to_string(&subjects.limited).unwrap(), + ); + // A client-chosen operation ID equal to the old change identity must not + // make its retained child grants current again. + let revoke = edit_request( + &server, + &host, + &subjects.limited_source.change_id, + vec![ConfigTreeChange::Update { + path: generated.clone(), + expected_digest: entry.content_digest.clone(), + content: revoked_source, + }], + ); + let revoked = adopt_edit(&server, &features, &host, &revoke); + assert_ne!(revoked.history_head, initial.history_head); + assert_existing_data_preserved(&seed.original, &revoked.state); + let current = canonical + .subject_permissions(&subjects.limited) + .unwrap() + .unwrap(); + assert_ne!(current.change_id, subjects.limited_source.change_id); + assert_ne!(current.change_id, subjects.stale_grant.source_change_id); + assert!(current.execution.is_empty()); + assert!(current.delegation.is_empty()); + let policy = server + .adopted_subject_policy(WORKSPACE, &subjects.limited) + .unwrap() + .unwrap(); + assert_eq!( + policy.legacy_constraints, + Some(subjects.limited_source.clone()) + ); + assert!(policy.source.execution.revoked && policy.source.delegation.revoked); + // Binding replays are history only; neither replay can restore current policy. + canonical.bind_worker_execution_grant(&child).unwrap(); + canonical + .bind_worker_execution_grant(&subjects.live_grant) + .unwrap(); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.limited_source.change_id, + "revoked-old-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + ¤t.change_id, + "revoked-current-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!( + canonical + .worker_execution_grant("runtime", "revoked-current-child") + .unwrap() + .is_none() + ); + assert_eq!( + adopt_migration(&server, &features, &host, &seed, &initial_request).unwrap(), + initial + ); + assert_eq!( + canonical.subject_permissions(&subjects.limited).unwrap(), + Some(current.clone()) + ); + assert_eq!( + server.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(revoked.history_head) + ); + assert_eq!( + canonical.subject_permissions(&subjects.empty).unwrap(), + Some(subjects.empty_source) + ); + assert_eq!( + canonical.subject_permissions(&subjects.unset).unwrap(), + None + ); + assert_eq!(legacy_records(&features), legacy_before); +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 6113fc670..e013be2f0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,9 +6,10 @@ pub mod backend_job; pub mod companion; pub mod config; pub mod config_adoption; -mod configuration_migration; -pub mod policy_config; pub mod config_source; +mod configuration_migration; +#[cfg(test)] +mod configuration_transfer_tests; #[cfg(test)] mod content_history_tests; pub mod db_executor; @@ -27,6 +28,7 @@ pub mod plugin_plan; pub mod plugin_receipt; pub mod plugin_service; pub mod plugin_settings; +pub mod policy_config; pub mod profile_settings; pub mod prompt_settings; pub mod records; diff --git a/crates/workspace-server/src/policy_config.rs b/crates/workspace-server/src/policy_config.rs index 165c3eef7..b57f53ea9 100644 --- a/crates/workspace-server/src/policy_config.rs +++ b/crates/workspace-server/src/policy_config.rs @@ -3,6 +3,38 @@ //! An omitted Subject is unchanged; removal is explicit. Assignments contain only //! references to Feature-owned Sets and Namespaces. Legacy resource restrictions //! remain separate domain constraints, never payloads on permission keys. +//! +//! Assigning a Subject replaces both execution and delegation assignments for +//! that Subject. An omitted lane is empty, not its old value. `{}` is explicit +//! empty; `revoked = true` denies that lane without altering the other lane. +//! `remove_subjects` removes policy, not the Subject, and never restores a default. +//! Omitting `permissions`, or omitting an individual Subject, preserves policy. +//! Consequently deleting an assignment from source alone is not revocation. +//! +//! For example, when the catalog declares `filesystem.read` and `subject-a` exists: +//! +//! ```text +//! { +//! permissions = { +//! subjects = [{ +//! subject_id = "subject-a"; +//! execution = { +//! namespaces = [{ namespace = "filesystem"; keys = ["filesystem.read"]; }]; +//! }; +//! delegation = {}; +//! }]; +//! remove_subjects = []; +//! }; +//! } as WorkspaceConfigSchema +//! ``` +//! +//! `sets` and `exclude_sets` reference Feature-owned shared definitions; +//! `namespaces` and `exclude_namespaces` reference exact keys in their declared +//! Namespace. This source does not redeclare Sets, infer children from dot +//! prefixes, attach payloads/arguments to keys or issue provider capabilities. +//! Multi-segment keys remain exact. Only catalog-declared children expand them. +//! Unknown references are rejected even in revoked assignments, so reactivation +//! cannot conceal invalid configuration. use std::collections::{BTreeMap, BTreeSet}; @@ -68,8 +100,16 @@ pub struct AssignmentSource { impl AssignmentSource { pub fn assignment(&self) -> PermissionAssignment { let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| { - sets.iter().cloned().map(PermissionReference::Set) - .chain(namespaces.iter().cloned().map(PermissionReference::Namespace)).collect() + sets.iter() + .cloned() + .map(PermissionReference::Set) + .chain( + namespaces + .iter() + .cloned() + .map(PermissionReference::Namespace), + ) + .collect() }; PermissionAssignment { grants: references(&self.sets, &self.namespaces), @@ -112,16 +152,29 @@ pub struct AdoptedSubjectPolicy { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)] pub enum MigrationDiagnostic { - Converted { subject_id: String, operation: String }, - DomainConstraint { subject_id: String, permission: ScopedPermission }, - ExplicitConfigurationRequired { subject_id: String, permission: ScopedPermission }, + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: ScopedPermission, + }, } pub fn source(evaluation: &EvaluationResult) -> Result { - let value = evaluation.projections.first() + let value = evaluation + .projections + .first() .and_then(|p| p.data_json.get("permissions")) - .cloned().unwrap_or_else(|| serde_json::json!({})); - serde_json::from_value(value).map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + serde_json::from_value(value) + .map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) } fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> { @@ -130,10 +183,18 @@ fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Resu let mut assignment = source.assignment(); assignment.revoked = false; let resolution = host.resolve(Some(&assignment)); - if resolution.projection().diagnostics.iter().any(|d| matches!(d, - PermissionDiagnostic::UnknownKey(_) | PermissionDiagnostic::UnknownNamespace(_) - | PermissionDiagnostic::UnknownSet(_))) { - return Err(Error::InvalidInput(format!("invalid permission references: {:?}", resolution.projection().diagnostics))); + if resolution.projection().diagnostics.iter().any(|d| { + matches!( + d, + PermissionDiagnostic::UnknownKey(_) + | PermissionDiagnostic::UnknownNamespace(_) + | PermissionDiagnostic::UnknownSet(_) + ) + }) { + return Err(Error::InvalidInput(format!( + "invalid permission references: {:?}", + resolution.projection().diagnostics + ))); } Ok(()) } @@ -158,7 +219,9 @@ pub fn validate_source( server_api::authorization::validate_authorization_identifier(subject) .map_err(Error::InvalidInput)?; if !subjects.insert(subject) { - return Err(Error::InvalidInput("Subject both assigned and removed, or removed twice".into())); + return Err(Error::InvalidInput( + "Subject both assigned and removed, or removed twice".into(), + )); } require_subject(subject)?; } @@ -172,19 +235,26 @@ pub fn apply( source: &PermissionSource, change_id: &str, ) { - for subject in &source.remove_subjects { policies.remove(subject); } + for subject in &source.remove_subjects { + policies.remove(subject); + } for item in &source.subjects { match policies.get_mut(&item.subject_id) { - Some(previous) if previous.source == *item => {}, + Some(previous) if previous.source == *item => {} Some(previous) => { previous.source = item.clone(); previous.change_id = change_id.to_owned(); - }, + } None => { - policies.insert(item.subject_id.clone(), AdoptedSubjectPolicy { - source: item.clone(), change_id: change_id.to_owned(), legacy_constraints: None, - }); - }, + policies.insert( + item.subject_id.clone(), + AdoptedSubjectPolicy { + source: item.clone(), + change_id: change_id.to_owned(), + legacy_constraints: None, + }, + ); + } } } } @@ -201,36 +271,66 @@ pub fn migrate_subject( let mut lane = |permissions: &[ScopedPermission]| -> Result { let mut by_namespace: BTreeMap> = BTreeMap::new(); for permission in permissions { - let operation = server_api::authorization::canonical_worker_operation(&permission.operation).unwrap_or(&permission.operation); - let key = PermissionKey::parse(operation.to_owned()).map_err(Error::InvalidInput)?; - let assignment = PermissionAssignment { grants: vec![PermissionReference::Namespace(NamespacePermissions { - namespace: key.namespace(), keys: vec![key.clone()], - })], exclusions: vec![], revoked: false }; + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); + let Ok(key) = PermissionKey::parse(operation.to_owned()) else { + diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: subject_id.into(), + permission: permission.clone(), + }); + continue; + }; + let assignment = PermissionAssignment { + grants: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: key.namespace(), + keys: vec![key.clone()], + })], + exclusions: vec![], + revoked: false, + }; let resolution = host.resolve(Some(&assignment)); let exact = resolution.projection().diagnostics.is_empty() && resolution.projection().allowed.len() == 1 && resolution.projection().allowed.contains_key(&key); if !exact { diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { - subject_id: subject_id.into(), permission: permission.clone(), + subject_id: subject_id.into(), + permission: permission.clone(), }); continue; } by_namespace.entry(key.namespace()).or_default().insert(key); diagnostics.push(MigrationDiagnostic::DomainConstraint { - subject_id: subject_id.into(), permission: permission.clone(), + subject_id: subject_id.into(), + permission: permission.clone(), }); } - Ok(AssignmentSource { namespaces: by_namespace.into_iter().map(|(namespace, keys)| NamespacePermissions { - namespace, keys: keys.into_iter().collect(), - }).collect(), ..Default::default() }) + Ok(AssignmentSource { + namespaces: by_namespace + .into_iter() + .map(|(namespace, keys)| NamespacePermissions { + namespace, + keys: keys.into_iter().collect(), + }) + .collect(), + ..Default::default() + }) }; let execution = lane(&legacy.execution)?; let delegation = lane(&legacy.delegation)?; - Ok((AdoptedSubjectPolicy { - source: SubjectAssignmentSource { subject_id: subject_id.into(), execution, delegation }, - change_id: legacy.change_id.clone(), legacy_constraints: Some(legacy.clone()), - }, diagnostics)) + Ok(( + AdoptedSubjectPolicy { + source: SubjectAssignmentSource { + subject_id: subject_id.into(), + execution, + delegation, + }, + change_id: legacy.change_id.clone(), + legacy_constraints: Some(legacy.clone()), + }, + diagnostics, + )) } impl AdoptedSubjectPolicy { @@ -241,12 +341,15 @@ impl AdoptedSubjectPolicy { pub fn legacy_read(&self, host: &PermissionHost) -> Result { let execution = host.resolve(Some(&self.source.execution.assignment())); let delegation = host.resolve(Some(&self.source.delegation.assignment())); - let filter = |allowed: &feature_permissions::PermissionResolution, constraints: &[ScopedPermission]| -> Result> { + let filter = |allowed: &feature_permissions::PermissionResolution, + constraints: &[ScopedPermission]| + -> Result> { let mut represented = BTreeSet::new(); let mut output = vec![]; for permission in constraints { - let operation = server_api::authorization::canonical_worker_operation(&permission.operation) - .unwrap_or(&permission.operation); + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); if let Ok(key) = PermissionKey::parse(operation.to_owned()) { if allowed.allowed.contains_key(&key) { represented.insert(key); @@ -259,12 +362,16 @@ impl AdoptedSubjectPolicy { } Ok(output) }; - let empty = SubjectPermissions { change_id:self.change_id.clone(),execution:vec![],delegation:vec![] }; + let empty = SubjectPermissions { + change_id: self.change_id.clone(), + execution: vec![], + delegation: vec![], + }; let retained = self.legacy_constraints.as_ref().unwrap_or(&empty); Ok(SubjectPermissions { - change_id:self.change_id.clone(), - execution:filter(execution.projection(),&retained.execution)?, - delegation:filter(delegation.projection(),&retained.delegation)?, + change_id: self.change_id.clone(), + execution: filter(execution.projection(), &retained.execution)?, + delegation: filter(delegation.projection(), &retained.delegation)?, }) } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d79a839ff..991472c36 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -60,6 +60,8 @@ mod plugin_host; #[path = "server_plugin_runtime.rs"] mod plugin_runtime; pub use plugin_delivery::InProcessPluginArtifactSource; +#[path = "server_configuration_adoption.rs"] +mod configuration_adoption; #[path = "server_workspace_config.rs"] mod workspace_config; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool { }; resource == "config/source-tree" || resource.starts_with("config/source-tree/") + || resource == "config/adoption" + || resource.starts_with("config/adoption/") || resource == "settings/profiles" || resource.starts_with("settings/profiles/") } @@ -4106,7 +4110,8 @@ fn workspace_server_router(api: WorkspaceServerApi) -> Router { .merge(generated_auth_contract_router(contract_service.clone())) .merge(generated_workspace_catalog_contract_router( contract_service, - )); + )) + .merge(configuration_adoption::routes(api.clone())); catalog.layer(axum::middleware::from_fn_with_state( api, enforce_server_cookie_mutation_origin, @@ -4636,6 +4641,9 @@ impl WorkspaceApi { crate::profile_settings::ProfileConfigSchemaProvider, )) .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) + .with_provider(Arc::new( + crate::policy_config::PermissionConfigSchemaProvider, + )) .with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider)) @@ -16486,22 +16494,13 @@ async fn scoped_get_workspace_config_entry( } async fn scoped_commit_workspace_config_tree( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, + State(_api): State, + AxumPath(_path): AxumPath, + Json(_request): Json, ) -> ApiResult<(StatusCode, Json)> { - api.host_executors - .host()? - .run(move || { - let request = config_commit_request_from_api(request)?; - validate_workspace_scope(&api, &path.workspace_id)?; - let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?; - Ok(( - StatusCode::CREATED, - Json(workspace_config_state_to_api(state)), - )) - }) - .await + Err(Error::WorkspaceConfigConflict( + "source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(), + ).into()) } /// Shared canonical commit and activation boundary for UI and logical config operations. @@ -24198,11 +24197,27 @@ where executor.run(move || runtime.block_on(future)).await } -fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { +fn open_legacy_subjektiv_store(api: &WorkspaceApi) -> ApiResult { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|error| ApiError::from(Error::Store(error.to_string()))) } +fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { + let host = worker::feature::permissions::builtin_permission_host( + feature_permissions::WorkspaceId::parse(api.config.workspace_id.clone()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + ) + .map_err(|error| Error::Config(error.to_string()))?; + Ok( + open_legacy_subjektiv_store(api)?.with_canonical_permission_source(Arc::new( + crate::config_adoption::CanonicalConfigurationSource { + store: api.config_store.as_ref().clone(), + host, + }, + )), + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SubjektivWorkerAuthority { Subject, @@ -40388,6 +40403,7 @@ mod tests { mod workspace_admission_tests; mod workspace_protocol_backpressure_tests; include!("server_workspace_config_tests.rs"); + include!("server_configuration_adoption_tests.rs"); use super::*; use axum::body::{Body, to_bytes}; use axum::http::Request; diff --git a/crates/workspace-server/src/server_configuration_adoption.rs b/crates/workspace-server/src/server_configuration_adoption.rs new file mode 100644 index 000000000..b64baf2d7 --- /dev/null +++ b/crates/workspace-server/src/server_configuration_adoption.rs @@ -0,0 +1,523 @@ +//! Owner-only HTTP boundary for source adoption and the one-way legacy cutover. +//! +//! Every preview and adoption acquires the native Feature authority guard before +//! entering the Server transaction. Subject references are checked against current +//! metadata/deletion fences under its IMMEDIATE transaction, not a preloaded set. +//! Preview and canonical saves never seal: their nonmutating Feature transaction +//! remains held through the Server operation and rolls back when the guard drops. +//! +//! Only migration adoption seals. Seal commits Feature evidence, not Server +//! adoption; the outer connection/lifecycle guard remains held until Server commit +//! or rollback, and no Feature access follows the seal. Legacy logical writers +//! must use that same domain guard and require_unsealed. IMMEDIATE serializes gate +//! users across connections/processes; after seal releases the SQLite write lock, +//! the durable seal rejects legacy writers. This is not a distributed commit: +//! Server failure can leave a pending transfer requiring the exact original retry. +//! Runtime operations must still check current policy and Subject lifecycle. +//! Callbacks must not reenter the Feature store or its canonical source provider. + +use super::*; + +use crate::config_adoption::{ + ConfigurationAdoptionRequest as AdoptionRequest, ConfigurationAdoptionResult as AdoptionResult, + ConfigurationCurrent, ConfigurationMigrationBaseline, ConfigurationPolicyTransfer, + request_fingerprint, +}; +use crate::configuration_migration::{MigrationSeed, preview_migration}; +use crate::policy_config::{AdoptedSubjectPolicy, MigrationDiagnostic}; +use crate::subjektiv::{ + DatabaseIdentity, PermissionTransferReceipt, SubjektivError, SubjektivStore, +}; +use feature_permissions::PermissionHost; +use server_api::{ + ConfigurationAdoptionApi, ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest, + ConfigurationMigrationDiagnostic, ConfigurationPreviewResponse, ConfigurationReconciliation, + ConfigurationSourceResponse, ConfigurationSubjectPolicy, RepositoryApiError, + RuntimePolicyApplication, ServerRequestContext, +}; + +/// Mount on the Server router, under its cookie-mutation-origin middleware. +/// These routes own admission because they bypass Workspace fallback dispatch. +pub(super) fn routes(service: WorkspaceServerApi) -> Router { + let context_service = ServerApiContractService::Server(service.clone()); + let service = Arc::new(service); + Router::new() + .merge(server_api::configuration_adoption_api_axum::configuration_source(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_preview(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_adopt(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_reconcile(service)) + .layer(middleware::from_fn_with_state( + context_service, + attach_server_request_context, + )) +} + +async fn owner_operation( + service: &WorkspaceServerApi, + context: ServerRequestContext, + workspace: String, + operation: impl FnOnce(WorkspaceApi, String) -> Result + Send + 'static, +) -> std::result::Result { + // These owner-only routes are mounted above Workspace fallback dispatch. + // A Runtime proof must not be ignored in favor of a shared human credential. + if context.transport_headers.iter().any(|(name, _)| { + name.eq_ignore_ascii_case(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + }) { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Runtime source proofs cannot authorize owner configuration management".into(), + )) + .into_repository_api_error()); + } + let actor = subject_authorization::administration_actor(&context) + .map_err(ApiError::into_repository_api_error)? + .clone(); + let result: ApiResult = async { + let gate = service.admission_for_workspace(&workspace).await; + let lease = gate.admit(false).ok_or_else(|| { + Error::WorkspaceConfigConflict("Workspace deletion is draining requests".into()) + })?; + crate::db_executor::with_operation_lifetime(Arc::new(lease), async { + // Authenticate against catalog ownership before resolving an API: + // API construction itself can load configuration and Feature stores. + let record = service + .store + .get_workspace(&workspace) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + if record.owner_account_id != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "configuration management requires the Workspace owner".into(), + ) + .into()); + } + if record.state != "active" { + return Err( + Error::WorkspaceConfigConflict("Workspace is not active".into()).into(), + ); + } + let api = service + .api_for_workspace(&workspace) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + require_workspace_owner(&api, &workspace, &actor, "Configuration adoption").await?; + // The admitted lifetime survives HTTP cancellation. The synchronous + // DB job owns native Feature guards through Server commit/rollback. + service + .store + .db_executor() + .run(move || operation(api, actor.account_id)) + .await + .map_err(ApiError::from) + }) + .await + } + .await; + result.map_err(ApiError::into_repository_api_error) +} + +fn conflict(message: &str) -> Error { + Error::WorkspaceConfigConflict(message.into()) +} + +fn request_from_api(request: ConfigurationAdoptionRequest) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id) + .map_err(Error::InvalidInput)?; + Ok(AdoptionRequest { + operation_id: request.operation_id, + expected_head: request.expected_head, + expected_catalog_digest: request.expected_catalog_digest, + expected_legacy_export: request.expected_legacy_export, + source: config_commit_request_from_api(request.source)?, + }) +} + +fn host(workspace: &str) -> Result { + let workspace = + feature_permissions::WorkspaceId::parse(workspace).map_err(Error::InvalidInput)?; + worker::feature::permissions::builtin_permission_host(workspace) + .map_err(|error| Error::Config(error.to_string())) +} + +fn raw_legacy(api: &WorkspaceApi) -> Result { + // Intentionally do not call open_subjektiv_store: the normal helper connects + // the canonical provider. Transfer preparation must retain the raw guard. + open_legacy_subjektiv_store(api).map_err(|error| error.error) +} + +fn transfer_error(error: SubjektivError) -> Error { + match error { + SubjektivError::PermissionTransferConflict | SubjektivError::PermissionAuthoritySealed => { + conflict("legacy permission transfer changed or is sealed for another request") + } + error => subjektiv_store_error(error), + } +} + +fn source_snapshot(api: &WorkspaceApi, account: &str) -> Result { + api.config_store + .configuration_current(&api.config.workspace_id, account) +} + +fn saved_result( + api: &WorkspaceApi, + account: &str, + request: &AdoptionRequest, +) -> Result> { + // Core authenticates the current owner and compares the complete request. + api.config_store + .configuration_request_result(&api.config.workspace_id, account, request) +} + +fn pending_transfer( + legacy: &SubjektivStore, + workspace: &str, + account: &str, + request: &AdoptionRequest, +) -> Result> { + let identity = legacy.database_identity().map_err(transfer_error)?; + if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID { + return Err(Error::WorkspaceIdMismatch); + } + // Read-only guarded lookup verifies native identity; lookup must never seal. + let receipt = legacy + .permission_transfer_receipt(&identity) + .map_err(transfer_error)?; + if let Some(receipt) = &receipt { + if receipt.snapshot.identity != identity + || receipt.transfer_id != request_fingerprint(workspace, account, request)? + || request.expected_legacy_export.as_deref() + != Some(receipt.snapshot.export_digest.as_str()) + { + return Err(conflict( + "sealed transfer belongs to another adoption request", + )); + } + } + Ok(receipt) +} + +fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> { + ConfigurationMigrationBaseline { + original: &seed.original, + state: &seed.state, + policies: &seed.policies, + diagnostics: &seed.diagnostics, + export_digest: &seed.snapshot.export_digest, + } +} + +struct Preparation { + host: PermissionHost, + schema: ::config_source::WorkspaceConfigSchemaBundle, + legacy: SubjektivStore, + identity: DatabaseIdentity, + seed: Option, +} + +fn prepare(api: &WorkspaceApi, account: &str, request: &AdoptionRequest) -> Result { + let current = source_snapshot(api, account)?; + if current.canonical && request.expected_legacy_export.is_some() { + return Err(conflict( + "legacy transfer is already adopted; reload current source", + )); + } + let host = host(&api.config.workspace_id)?; + let schema = api.config_schema_registry.compose()?; + let legacy = raw_legacy(api)?; + let sealed = if current.canonical { + None + } else { + pending_transfer(&legacy, &api.config.workspace_id, account, request)? + }; + let seed = if current.canonical { + None + } else { + let seed = preview_migration( + &api.config_store, + &legacy, + &api.config.workspace_id, + schema.clone(), + &host, + )?; + if sealed + .as_ref() + .is_some_and(|receipt| receipt.snapshot != seed.snapshot) + { + return Err(conflict( + "sealed transfer differs from the evaluated migration snapshot", + )); + } + Some(seed) + }; + // Observe identity outside the guarded Server operation. Inside the native + // gate, use only authority methods; reopening this store would reenter it. + let identity = match &seed { + Some(seed) => seed.snapshot.identity.clone(), + None => legacy.database_identity().map_err(transfer_error)?, + }; + Ok(Preparation { + host, + schema, + legacy, + identity, + seed, + }) +} + +fn policy_to_api(policy: AdoptedSubjectPolicy) -> ConfigurationSubjectPolicy { + ConfigurationSubjectPolicy { + subject_id: policy.source.subject_id, + change_id: policy.change_id, + execution: policy.source.execution.assignment(), + delegation: policy.source.delegation.assignment(), + legacy_constraints: policy.legacy_constraints, + } +} + +fn diagnostic_to_api(diagnostic: MigrationDiagnostic) -> ConfigurationMigrationDiagnostic { + match diagnostic { + MigrationDiagnostic::Converted { + subject_id, + operation, + } => ConfigurationMigrationDiagnostic::Converted { + subject_id, + operation, + }, + MigrationDiagnostic::DomainConstraint { + subject_id, + permission, + } => ConfigurationMigrationDiagnostic::DomainConstraint { + subject_id, + permission, + }, + MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id, + permission, + } => ConfigurationMigrationDiagnostic::ExplicitConfigurationRequired { + subject_id, + permission, + }, + } +} + +fn receipt_to_api(result: AdoptionResult) -> ConfigurationAdoptionReceipt { + ConfigurationAdoptionReceipt { + workspace_id: result.workspace_id, + operation_id: result.operation_id, + history_head: result.history_head, + state: workspace_config_state_to_api(result.state), + source_stored: result.source_stored, + policy_committed: result.policy_committed, + runtime_application: match result.runtime_application { + crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck => { + RuntimePolicyApplication::NextCurrentPolicyCheck + } + }, + } +} + +impl ConfigurationAdoptionApi for WorkspaceServerApi { + async fn configuration_source( + &self, + context: ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, |api, account| { + let current = source_snapshot(&api, &account)?; + let host = host(&api.config.workspace_id)?; + let catalog_digest = crate::config_adoption::catalog_digest(&host)?; + let (state, expected_legacy_export, policies, diagnostics) = if current.canonical { + (current.state, None, current.policies, vec![]) + } else { + let legacy = raw_legacy(&api)?; + let seed = preview_migration( + &api.config_store, + &legacy, + &api.config.workspace_id, + api.config_schema_registry.compose()?, + &host, + )?; + let observed = source_snapshot(&api, &account)?; + if observed.canonical + || observed.history_head != current.history_head + || observed.state != seed.original + { + return Err(conflict( + "configuration changed during migration preview; reload source", + )); + } + ( + seed.state, + Some(seed.snapshot.export_digest), + seed.policies, + seed.diagnostics, + ) + }; + Ok(ConfigurationSourceResponse { + state: workspace_config_state_to_api(state), + history_head: current.history_head, + source_stored: current.canonical, + catalog: host.catalog(), + catalog_digest, + expected_legacy_export, + policies: policies.into_values().map(policy_to_api).collect(), + migration_diagnostics: diagnostics.into_iter().map(diagnostic_to_api).collect(), + }) + }) + .await + } + + async fn configuration_preview( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + // Detect operation-ID reuse even though preview itself never persists. + saved_result(&api, &account, &request)?; + let Preparation { + host, + schema, + legacy, + identity, + seed, + } = prepare(&api, &account, &request)?; + let baseline = seed.as_ref().map(baseline); + // Preview never seals. The native read-only IMMEDIATE transaction + // fences metadata updates through core validation, then rolls back. + let preview = legacy + .with_permission_authority_guard(&identity, |authority| { + Ok(api.config_store.preview_configuration_adoption( + &api.config.workspace_id, + &account, + &request, + schema, + &host, + baseline.as_ref(), + |subject| authority.require_subject(subject).map_err(transfer_error), + )) + }) + .map_err(transfer_error)??; + Ok(ConfigurationPreviewResponse { + expected_head: preview.expected_head, + state: workspace_config_state_to_api(preview.state), + changed_paths: preview.changed_paths, + changed_subjects: preview.changed_subjects, + }) + }) + .await + } + + async fn configuration_adopt( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(receipt_to_api(result)); + } + let Preparation { + host, + schema, + legacy, + identity, + seed, + } = prepare(&api, &account, &request)?; + let baseline = seed.as_ref().map(baseline); + // Feature connection + IMMEDIATE transaction always precede Server. + // Never call store-level Feature APIs from inside this closure. + let result = legacy + .with_permission_authority_guard(&identity, |authority| { + // Keep Server failures as the inner Result. Only native gate + // acquisition/finalization failures belong to the outer lane. + Ok(api.config_store.adopt_configuration( + &api.config.workspace_id, + &account, + &request, + schema, + &host, + baseline.as_ref(), + |subject| authority.require_subject(subject).map_err(transfer_error), + |fingerprint| { + // Canonical saves must not seal. Their Feature transaction + // stays nonmutating until guard-drop rollback after core. + let seed = seed.as_ref().ok_or_else(|| { + conflict("canonical adoption must not prepare a legacy transfer") + })?; + // Core completes every reference check before this final + // Feature access. One method verifies a retained seal or + // creates the first one; the rest is in-memory work only. + let receipt = authority + .seal_permission_sources(fingerprint, &seed.snapshot.export_digest) + .map_err(transfer_error)?; + if receipt.transfer_id != fingerprint + || receipt.snapshot != seed.snapshot + { + return Err(conflict( + "sealed transfer differs from the evaluated migration snapshot", + )); + } + Ok(ConfigurationPolicyTransfer { + transfer_identity: receipt.transfer_id, + physical_identity: receipt.snapshot.identity.physical_id, + export_digest: receipt.snapshot.export_digest, + policies: seed.policies.clone(), + }) + }, + )) + }) + .map_err(transfer_error)??; + Ok(receipt_to_api(result)) + }) + .await + } + + async fn configuration_reconcile( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }); + } + // Canonical operations do not use the old transfer. A missing normal + // save receipt is not evidence about the unrelated initial cutover. + if source_snapshot(&api, &account)?.canonical { + return Ok(match saved_result(&api, &account, &request)? { + Some(result) => ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }, + None => ConfigurationReconciliation::NotObserved, + }); + } + // No draft generation, validation against today's head/catalog, seal, + // or adoption here: this is observation of the exact original input. + let legacy = raw_legacy(&api)?; + let pending = pending_transfer(&legacy, &api.config.workspace_id, &account, &request)?; + // Adoption may have committed between the first lookup and Feature read. + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }); + } + Ok(match pending { + Some(receipt) => ConfigurationReconciliation::PendingTransfer { + transfer_identity: receipt.transfer_id, + export_digest: receipt.snapshot.export_digest, + }, + None => ConfigurationReconciliation::NotObserved, + }) + }) + .await + } +} diff --git a/crates/workspace-server/src/server_configuration_adoption_tests.rs b/crates/workspace-server/src/server_configuration_adoption_tests.rs new file mode 100644 index 000000000..12ab1c5dc --- /dev/null +++ b/crates/workspace-server/src/server_configuration_adoption_tests.rs @@ -0,0 +1,945 @@ +// Included inside server::tests to reuse its catalog, identity and storage fixtures. +mod configuration_adoption_http_integration { + //! Ordinary Server library integration tests: real catalog router (including + //! cookie-origin middleware), owner/session lookup, builtin permission catalog, + //! migration, guarded Feature storage and Server SQLite adoption. Only unused + //! Runtime execution is the existing deterministic fixture. Router oneshot is + //! the HTTP boundary; these are not TCP-loss, OS-crash or store-fault tests. + //! The separate configuration_transfer/config_adoption suites own store faults. + use super::*; + use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + }; + use server_api::{ + ConfigCommitRequest as SourceRequest, ConfigTreeChange as Change, + ConfigurationAdoptionReceipt as Receipt, ConfigurationAdoptionRequest as Adoption, + ConfigurationPreviewResponse as Preview, ConfigurationReconciliation as Reconciliation, + ConfigurationSourceResponse as Source, RuntimePolicyApplication, + }; + + const OWNER_SUFFIX: &str = "adoption-http-owner"; + const COOKIE_TOKEN: &str = "adoption-http-browser-session"; + const MARKER: &str = "notes/adoption-http.txt"; + + struct Fixture { + api: WorkspaceApi, + app: Router, + owner: String, + foreign: String, + owner_account: String, + cookie: String, + origin: String, + subject: String, + legacy: crate::subjektiv::SubjektivStore, + legacy_update: SubjectPermissionsUpdate, + legacy_policy: SubjectPermissions, + runtime_identity: RuntimeIdentityMaterial, + _dir: tempfile::TempDir, + } + + impl Fixture { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let owner_account = api + .store + .get_workspace(TEST_WORKSPACE_ID) + .await + .unwrap() + .unwrap() + .owner_account_id; + let owner = seed_test_workspace_owner_token(api.store.as_ref(), OWNER_SUFFIX).await; + let foreign = seed_test_api_token(api.store.as_ref(), "adoption-http-nonowner"); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash(COOKIE_TOKEN), + session_id: "adoption-http-session-id".into(), + user_id: format!("user-{OWNER_SUFFIX}"), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &api.config.auth; + let origin = origin.clone(); + let cookie = format!("{cookie_name}={COOKIE_TOKEN}"); + + // Open the real raw Feature store before adoption, retaining a handle + // to verify that cutover fences already-open legacy writers too. + let legacy = open_legacy_subjektiv_store(&api).unwrap(); + let subject = legacy + .create_subject(crate::subjektiv::SubjectRole::new("adoption-http").unwrap()) + .unwrap() + .id; + let legacy_update = SubjectPermissionsUpdate { + operation_id: "adoption-http-existing-policy".into(), + expected_change_id: legacy + .subject_permissions(&subject) + .unwrap() + .map(|policy| policy.change_id), + execution: vec![workspace_permission("ticket.query")], + delegation: vec![workspace_permission("ticket.create")], + }; + let legacy_policy = legacy + .set_subject_permissions(&subject, &legacy_update) + .unwrap(); + + let runtime_identity = + RuntimeIdentityMaterial::generate("adoption-http-runtime").unwrap(); + configure_runtime_request_auth(&mut api, &runtime_identity, "adoption-http-runtime"); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + // Reuse the fixture API, not a second Runtime with the same store lease. + // Catalog ownership and credential resolution remain production code. + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + Self { + api, + app, + owner, + foreign, + owner_account, + cookie, + origin, + subject, + legacy, + legacy_update, + legacy_policy, + runtime_identity, + _dir: dir, + } + } + + fn path(suffix: &str) -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption{suffix}") + } + + fn bearer( + &self, + token: &str, + method: &str, + suffix: &str, + body: Option, + ) -> Request { + Request::builder() + .method(method) + .uri(Self::path(suffix)) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() + } + + fn browser( + &self, + method: &str, + suffix: &str, + body: Option, + origin: Option<&str>, + ) -> Request { + let mut request = Request::builder() + .method(method) + .uri(Self::path(suffix)) + .header(axum::http::header::COOKIE, &self.cookie) + .header(CONTENT_TYPE, "application/json"); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + request + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() + } + + async fn call(&self, request: Request) -> (StatusCode, Value) { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let body = serde_json::from_slice(&bytes).unwrap_or_else(|_| { + panic!( + "expected JSON response ({status}): {}", + String::from_utf8_lossy(&bytes) + ) + }); + (status, body) + }) + .await + .expect("bounded adoption HTTP request") + } + + async fn owner_call( + &self, + suffix: &str, + request: &Adoption, + expected: StatusCode, + ) -> Value { + let (status, body) = self + .call(self.bearer( + &self.owner, + "POST", + suffix, + Some(serde_json::to_value(request).unwrap()), + )) + .await; + assert_eq!(status, expected, "{suffix}: {body}"); + body + } + + async fn source(&self) -> Source { + let (status, body) = self.call(self.bearer(&self.owner, "GET", "", None)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + serde_json::from_value(body).unwrap() + } + + async fn commit(&self, request: &Adoption) -> Receipt { + serde_json::from_value(self.owner_call("/commit", request, StatusCode::OK).await) + .unwrap() + } + + async fn adopt_draft(&self, id: &str) -> Receipt { + let draft = self.source().await; + self.commit(&request(&draft, id, vec![])).await + } + + // Re-read durable authorities, not cached HTTP responses. The raw export + // API is guarded by FeatureStorage and checks its physical identity. + fn durable(&self) -> Value { + let current = self + .api + .config_store + .configuration_current(TEST_WORKSPACE_ID, &self.owner_account) + .unwrap(); + let identity = self.legacy.database_identity().unwrap(); + let receipts: Vec<(String, String, String)> = self.api.config_store.with_conn(|conn| { + let mut query = conn.prepare( + "SELECT operation_id,request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 ORDER BY operation_id", + )?; + let rows = query.query_map([TEST_WORKSPACE_ID], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + Ok(rows.collect::, _>>()?) + }).unwrap(); + json!({ + "state": current.state, + "history_head": current.history_head, + "canonical": current.canonical, + "policies": current.policies, + "receipts": receipts, + "legacy_export": self.legacy.preview_permission_transfer(&identity).unwrap(), + "legacy_receipt": self.legacy.permission_transfer_receipt(&identity).unwrap(), + }) + } + + fn assert_legacy_writable(&self) { + // Exact replay is accepted by an unsealed writer, but rejected after + // cutover even on an already-open handle. It does not create a change. + assert_eq!( + self.legacy + .set_subject_permissions(&self.subject, &self.legacy_update) + .unwrap(), + self.legacy_policy + ); + } + + fn assert_legacy_sealed(&self) { + assert!(matches!( + self.legacy + .set_subject_permissions(&self.subject, &self.legacy_update), + Err(crate::subjektiv::SubjektivError::PermissionAuthoritySealed), + )); + } + } + + fn core_request(request: &Adoption) -> crate::config_adoption::ConfigurationAdoptionRequest { + crate::config_adoption::ConfigurationAdoptionRequest { + operation_id: request.operation_id.clone(), + expected_head: request.expected_head.clone(), + expected_catalog_digest: request.expected_catalog_digest.clone(), + expected_legacy_export: request.expected_legacy_export.clone(), + source: config_commit_request_from_api(request.source.clone()).unwrap(), + } + } + + fn assignment(operation: &str) -> feature_permissions::PermissionAssignment { + feature_permissions::PermissionAssignment { + grants: vec![feature_permissions::PermissionReference::Namespace( + feature_permissions::NamespacePermissions { + namespace: feature_permissions::NamespaceId::parse("ticket").unwrap(), + keys: vec![feature_permissions::PermissionKey::parse(operation).unwrap()], + }, + )], + exclusions: vec![], + revoked: false, + } + } + + fn workspace_permission(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workspace { + workspace_id: TEST_WORKSPACE_ID.into(), + }, + } + } + + fn request(source: &Source, id: &str, changes: Vec) -> Adoption { + Adoption { + operation_id: id.into(), + expected_head: source.history_head.clone(), + expected_catalog_digest: source.catalog_digest.clone(), + expected_legacy_export: source.expected_legacy_export.clone(), + source: SourceRequest { + base_digest: source.state.snapshot.digest.clone(), + changes, + entrypoints: source.state.contract.entrypoints.clone(), + }, + } + } + + fn update(source: &Source, path: &str, content: &str) -> Change { + Change::Update { + path: path.into(), + expected_digest: source.state.snapshot.entries[path].content_digest.clone(), + content: content.into(), + } + } + + fn create_marker(content: &str) -> Change { + Change::Create { + path: MARKER.into(), + content_type: server_api::ConfigContentType::Text, + content: content.into(), + } + } + + fn permission_path(source: &Source) -> String { + source + .state + .snapshot + .entries + .keys() + .find(|path| path.starts_with("permissions/migrated") && path.ends_with(".dcdl")) + .expect("migration generated editable permission source") + .clone() + } + + fn permission_source(subject: &str, execution: &str, delegation: &str) -> String { + format!( + r#"{{ + subjects = [{{ + subject_id = {subject:?}; + execution = {{ namespaces = [{{ namespace = "ticket"; keys = [{execution:?}]; }}]; }}; + delegation = {{ namespaces = [{{ namespace = "ticket"; keys = [{delegation:?}]; }}]; }}; + }}]; + remove_subjects = []; +}} +"# + ) + } + + #[tokio::test] + async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() { + let fixture = Fixture::new().await; + let before = fixture.durable(); + let draft = fixture.source().await; + assert!(!draft.source_stored); + assert_eq!(draft.history_head, None); + assert!(draft.expected_legacy_export.is_some()); + assert_ne!( + draft.state.snapshot.digest, + before["state"]["snapshot"]["digest"] + ); + assert_eq!(draft.catalog.workspace.as_str(), TEST_WORKSPACE_ID); + assert!(draft.catalog.namespaces.iter().any(|namespace| { + namespace + .declaration + .permissions + .iter() + .any(|permission| permission.key.as_str() == "ticket.query") + })); + assert_eq!(draft.policies.len(), 1); + assert_eq!(draft.policies[0].change_id, fixture.legacy_policy.change_id); + assert_eq!( + fixture.durable(), + before, + "GET is a read, not a save or seal" + ); + fixture.assert_legacy_writable(); + + let path = permission_path(&draft); + let edited = format!( + "{}\n# Owner edited the migration draft before first adoption.\n", + draft.state.snapshot.entries[&path].content + ); + let adoption = request( + &draft, + "edited-migration-draft", + vec![update(&draft, &path, &edited)], + ); + let preview: Preview = serde_json::from_value( + fixture + .owner_call("/preview", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(preview.expected_head, draft.history_head); + assert!(preview.changed_paths.contains(&path)); + assert!( + preview.state.snapshot.entries[&path] + .content + .contains("# Owner edited the migration draft before first adoption.") + ); + assert_eq!( + fixture.durable(), + before, + "preview must not seal or persist the draft" + ); + fixture.assert_legacy_writable(); + + // Exercise the real Browser session and configured Origin on commit. + let (status, wire) = fixture + .call(fixture.browser( + "POST", + "/commit", + Some(serde_json::to_value(&adoption).unwrap()), + Some(&fixture.origin), + )) + .await; + assert_eq!(status, StatusCode::OK, "{wire}"); + assert_eq!(wire["runtime_application"], "next_current_policy_check"); + let receipt: Receipt = serde_json::from_value(wire).unwrap(); + assert_eq!( + receipt.runtime_application, + RuntimePolicyApplication::NextCurrentPolicyCheck + ); + assert!(receipt.source_stored && receipt.policy_committed); + assert_eq!(receipt.workspace_id, TEST_WORKSPACE_ID); + assert_eq!(receipt.operation_id, adoption.operation_id); + assert_eq!(receipt.state, preview.state); + let stored = fixture.source().await; + assert!(stored.source_stored); + assert_eq!(stored.expected_legacy_export, None); + assert_eq!(stored.state, receipt.state); + assert_eq!( + stored.history_head.as_deref(), + Some(receipt.history_head.as_str()) + ); + assert_eq!( + stored.policies, draft.policies, + "source-only edit preserves policy and change IDs" + ); + let current = fixture + .api + .config_store + .configuration_current(TEST_WORKSPACE_ID, &fixture.owner_account) + .unwrap(); + assert!(current.canonical); + assert_eq!(workspace_config_state_to_api(current.state), receipt.state); + assert_eq!( + open_subjektiv_store(&fixture.api) + .unwrap() + .subject_permissions(&fixture.subject) + .unwrap(), + Some(fixture.legacy_policy.clone()) + ); + let identity = fixture.legacy.database_identity().unwrap(); + let transfer = fixture + .legacy + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!( + Some(transfer.snapshot.export_digest), + draft.expected_legacy_export + ); + assert_eq!(transfer.snapshot.identity, identity); + let saved = fixture + .api + .config_store + .configuration_request_result( + TEST_WORKSPACE_ID, + &fixture.owner_account, + &core_request(&adoption), + ) + .unwrap() + .unwrap(); + assert_eq!(saved.history_head, receipt.history_head); + assert_eq!(workspace_config_state_to_api(saved.state), receipt.state); + assert_eq!( + saved.runtime_application, + crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck + ); + assert_eq!( + transfer.transfer_id, + crate::config_adoption::request_fingerprint( + TEST_WORKSPACE_ID, + &fixture.owner_account, + &core_request(&adoption), + ) + .unwrap() + ); + fixture.assert_legacy_sealed(); + + // Then edit actual builtin operation assignments through the same routes. + let changed = permission_source(&fixture.subject, "ticket.create", "ticket.query"); + let policy_edit = request( + &stored, + "owner-policy-edit", + vec![update(&stored, &path, &changed)], + ); + let preview: Preview = serde_json::from_value( + fixture + .owner_call("/preview", &policy_edit, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(preview.changed_subjects, vec![fixture.subject.clone()]); + let next = fixture.commit(&policy_edit).await; + assert_eq!(next.state, preview.state); + assert_ne!(next.history_head, receipt.history_head); + let stored = fixture.source().await; + assert_eq!(stored.state, next.state); + let policy = stored + .policies + .iter() + .find(|policy| policy.subject_id == fixture.subject) + .unwrap(); + assert_eq!(policy.execution, assignment("ticket.create")); + assert_eq!(policy.delegation, assignment("ticket.query")); + assert_ne!(policy.change_id, fixture.legacy_policy.change_id); + let adopted = fixture + .api + .config_store + .adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject) + .unwrap() + .unwrap(); + assert_eq!(adopted.source.execution.assignment(), policy.execution); + assert_eq!(adopted.source.delegation.assignment(), policy.delegation); + assert_eq!(adopted.change_id, policy.change_id); + // New payload-free keys cannot fabricate broader legacy resource scopes. + assert_eq!( + policy.legacy_constraints, + Some(fixture.legacy_policy.clone()) + ); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn competing_http_requests_reject_stale_head_even_after_content_returns_to_a() { + let fixture = Fixture::new().await; + fixture.adopt_draft("aba-migrate").await; + let initial = fixture.source().await; + fixture + .commit(&request(&initial, "aba-a", vec![create_marker("A")])) + .await; + let a = fixture.source().await; + // Two independent requests are built from the same observed head. + let stale = request(&a, "aba-losing-editor", vec![update(&a, MARKER, "loser")]); + let b = request(&a, "aba-b", vec![update(&a, MARKER, "B")]); + fixture.commit(&b).await; + let before = fixture.durable(); + fixture + .owner_call("/commit", &stale, StatusCode::CONFLICT) + .await; + assert_eq!(fixture.durable(), before); + let b = fixture.source().await; + fixture + .commit(&request(&b, "aba-return-a", vec![update(&b, MARKER, "A")])) + .await; + let returned = fixture.source().await; + assert_eq!( + returned.state.snapshot, a.state.snapshot, + "the content really returned to A" + ); + assert_ne!( + returned.history_head, a.history_head, + "editing history did not return to A" + ); + let before = fixture.durable(); + fixture + .owner_call("/preview", &stale, StatusCode::CONFLICT) + .await; + fixture + .owner_call("/commit", &stale, StatusCode::CONFLICT) + .await; + assert_eq!( + fixture.durable(), + before, + "ABA rejection leaves source, policy and receipts intact" + ); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &stale, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(reconciled, Reconciliation::NotObserved); + assert_eq!(fixture.durable(), before); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn full_request_reconcile_recovers_received_commit_result_without_reapplying_old_policy() + { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let adoption = request(&draft, "response-lost-after-commit", vec![]); + let before = fixture.durable(); + let missing: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(missing, Reconciliation::NotObserved); + assert_eq!(fixture.durable(), before); + fixture.assert_legacy_writable(); + + // Receive a committed HTTP result, then model the consumer losing it. + // Reconciliation uses the retained original request, not result-derived input. + let received_result = fixture + .owner_call("/commit", &adoption, StatusCode::OK) + .await; + let after_commit = fixture.durable(); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + let expected: Receipt = serde_json::from_value(received_result).unwrap(); + assert_eq!( + reconciled, + Reconciliation::Adopted { + receipt: expected.clone() + } + ); + assert_eq!(fixture.commit(&adoption).await, expected); + assert_eq!( + fixture.durable(), + after_commit, + "reconcile and exact retry are not new adoptions" + ); + + let current = fixture.source().await; + let path = permission_path(¤t); + // Explicit removal makes old-receipt resurrection observable. + let removal = format!( + "{{ subjects = []; remove_subjects = [{:?}]; }}\n", + fixture.subject + ); + fixture + .commit(&request( + ¤t, + "later-policy-removal", + vec![update(¤t, &path, &removal)], + )) + .await; + assert!( + fixture + .api + .config_store + .adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject) + .unwrap() + .is_none() + ); + let latest = fixture.durable(); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!( + reconciled, + Reconciliation::Adopted { + receipt: expected.clone() + } + ); + assert_eq!(fixture.commit(&adoption).await, expected); + assert_eq!( + fixture.durable(), + latest, + "historical receipt must not resurrect removed policy or head" + ); + assert!( + open_subjektiv_store(&fixture.api) + .unwrap() + .subject_permissions(&fixture.subject) + .unwrap() + .is_none() + ); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn reconcile_rejects_same_operation_id_with_any_changed_request_fingerprint() { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let adoption = request(&draft, "fingerprint-migration", vec![]); + let receipt = fixture.commit(&adoption).await; + let before = fixture.durable(); + let mut variants = Vec::new(); + let mut changed = adoption.clone(); + changed.expected_head = Some(receipt.history_head.clone()); + variants.push(("head", changed)); + let mut changed = adoption.clone(); + changed.expected_catalog_digest.push_str("-different"); + variants.push(("catalog", changed)); + let mut changed = adoption.clone(); + changed.expected_legacy_export = None; + variants.push(("legacy export", changed)); + let mut changed = adoption.clone(); + changed.source.base_digest.push_str("-different"); + variants.push(("base content", changed)); + let mut changed = adoption.clone(); + changed + .source + .changes + .push(create_marker("not the same request")); + variants.push(("changes", changed)); + let mut changed = adoption.clone(); + changed.source.entrypoints.push("other.dcdl".into()); + variants.push(("entrypoints", changed)); + for (field, changed) in variants { + fixture + .owner_call("/reconcile", &changed, StatusCode::CONFLICT) + .await; + fixture + .owner_call("/commit", &changed, StatusCode::CONFLICT) + .await; + assert_eq!( + fixture.durable(), + before, + "changed {field} must not adopt or modify a receipt" + ); + } + let exact: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(exact, Reconciliation::Adopted { receipt }); + assert_eq!(fixture.durable(), before); + } + + #[tokio::test] + async fn http_owner_and_cookie_origin_denials_leave_all_authorities_and_legacy_writer_intact() { + let fixture = Fixture::new().await; + for canonical in [false, true] { + let source = fixture.source().await; + assert_eq!(source.source_stored, canonical); + let change = if canonical { + update(&source, MARKER, "second authorized value") + } else { + create_marker("first authorized value") + }; + let adoption = request( + &source, + &format!("denied-cutover-{canonical}"), + vec![change], + ); + let wire = serde_json::to_value(&adoption).unwrap(); + let before = fixture.durable(); + for (method, suffix) in [ + ("GET", ""), + ("POST", "/preview"), + ("POST", "/commit"), + ("POST", "/reconcile"), + ] { + let body = (method == "POST").then(|| wire.clone()); + let anonymous = Request::builder() + .method(method) + .uri(Fixture::path(suffix)) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.as_ref().map(Value::to_string).unwrap_or_default(), + )) + .unwrap(); + let runtime = runtime_source_request( + &fixture.runtime_identity, + None, + method, + &Fixture::path(suffix), + body.as_ref() + .map(|value| serde_json::to_vec(value).unwrap()) + .unwrap_or_default(), + ); + let mut runtime_with_owner = runtime_source_request( + &fixture.runtime_identity, + None, + method, + &Fixture::path(suffix), + body.as_ref() + .map(|value| serde_json::to_vec(value).unwrap()) + .unwrap_or_default(), + ); + runtime_with_owner.headers_mut().insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", fixture.owner).parse().unwrap(), + ); + for (principal, request) in [ + ( + "nonowner", + fixture.bearer(&fixture.foreign, method, suffix, body.clone()), + ), + ("unauthenticated", anonymous), + ("runtime proof", runtime), + // A valid owner credential must not hide the Runtime source. + ("runtime proof plus owner bearer", runtime_with_owner), + ] { + let (status, response) = fixture.call(request).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{principal} {method} {suffix}: {response}" + ); + assert_eq!(fixture.durable(), before, "{principal} {suffix}"); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + if method == "POST" { + for origin in [None, Some("https://attacker.invalid")] { + let (status, response) = fixture + .call(fixture.browser(method, suffix, body.clone(), origin)) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "cookie {origin:?} {suffix}: {response}" + ); + assert_eq!(fixture.durable(), before); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + } + } + // A Browser GET does not require a mutation Origin, and the same + // valid adoption remains possible after every denied request. + let (status, response) = fixture.call(fixture.browser("GET", "", None, None)).await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!(serde_json::from_value::(response).unwrap(), source); + fixture.commit(&adoption).await; + fixture.assert_legacy_sealed(); + } + } + + #[tokio::test] + async fn invalid_http_source_and_old_source_tree_writer_cannot_partially_adopt() { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let path = permission_path(&draft); + let before = fixture.durable(); + let invalid = [ + ("syntax", "{ subjects = [".to_owned()), + ( + "unknown-key", + permission_source( + &fixture.subject, + "ticket.not_a_builtin_operation", + "ticket.query", + ), + ), + ( + "missing-subject", + permission_source( + "nonexistent-adoption-subject", + "ticket.create", + "ticket.query", + ), + ), + ( + "unknown-set", + format!( + "{{ subjects = [{{ subject_id = {:?}; execution = {{ sets = [\"unknown-adoption-set\"]; }}; delegation = {{}}; }}]; remove_subjects = []; }}", + fixture.subject + ), + ), + ]; + for (name, content) in invalid { + let adoption = request( + &draft, + &format!("invalid-{name}"), + vec![update(&draft, &path, &content)], + ); + for suffix in ["/preview", "/commit"] { + let (status, response) = fixture + .call(fixture.bearer( + &fixture.owner, + "POST", + suffix, + Some(serde_json::to_value(&adoption).unwrap()), + )) + .await; + assert!( + status.is_client_error(), + "{name} {suffix}: {status} {response}" + ); + assert_eq!( + fixture.durable(), + before, + "invalid {name} leaves no source, policy, head, receipt or seal" + ); + fixture.assert_legacy_writable(); + } + } + for canonical in [false, true] { + if canonical { + fixture.adopt_draft("old-writer-cutover").await; + } + let current = fixture.source().await; + let old = SourceRequest { + base_digest: current.state.snapshot.digest, + changes: vec![create_marker("old writer must not persist")], + entrypoints: current.state.contract.entrypoints, + }; + let before = fixture.durable(); + let response = Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit" + )) + .header("authorization", format!("Bearer {}", fixture.owner)) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&old).unwrap())) + .unwrap(); + let (status, response) = fixture.call(response).await; + assert_eq!( + status, + StatusCode::CONFLICT, + "old writer canonical={canonical}: {response}" + ); + assert_eq!(fixture.durable(), before); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + } +} diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 1247e2961..3c54da854 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -619,22 +619,28 @@ pub(super) async fn commit( { return Err(fail(409, "authority_changed")); } - // Preserve the shared helper's ordering and atomic CAS, but never - // run its remote Runtime publication on the sole DB worker. - project_repository_access_candidate( - &*persistence_api.store, - &persistence_api.repository_secrets, - &persistence_api.config.workspace_id, - &candidate, - ) - .map_err(|_| unknown())?; - let state = persistence_api - .config_store - .commit_evaluated_workspace_config( - &persistence_api.config.workspace_id, - &candidate, - ) - .map_err(|_| unknown())?; + // Legacy logical writes share the physical authority gate with + // initial cutover, in Feature -> Server order. A durable seal + // fences this writer even when the Server adoption rolled back. + let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?; + let identity = legacy.database_identity().map_err(|_| unknown())?; + let state = legacy.with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok((|| { + project_repository_access_candidate( + &*persistence_api.store, + &persistence_api.repository_secrets, + &persistence_api.config.workspace_id, + &candidate, + ).map_err(|_| unknown())?; + persistence_api.config_store.commit_evaluated_workspace_config( + &persistence_api.config.workspace_id, &candidate, + ).map_err(pre_error) + })()) + }).map_err(|error| match error { + subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"), + _ => unknown(), + })??; let publication = persistence_api .prompt_projection_cache .resolve(&persistence_api.config.workspace_id, &state) diff --git a/crates/workspace-server/src/storage_format_tests.rs b/crates/workspace-server/src/storage_format_tests.rs index d523da6a6..3bc3c034b 100644 --- a/crates/workspace-server/src/storage_format_tests.rs +++ b/crates/workspace-server/src/storage_format_tests.rs @@ -112,7 +112,10 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) { let plan = published_plan(conn, current).unwrap().unwrap(); assert_eq!(plan.migrations.len(), 1); assert_eq!(plan.migrations[0].version, 122); - assert_eq!(plan.migrations[0].name, crate::config_adoption::MIGRATION_NAME); + assert_eq!( + plan.migrations[0].name, + crate::config_adoption::MIGRATION_NAME + ); assert_eq!(plan.target_schema_version, 122); } assert_eq!(rows, saved_rows(conn)); @@ -200,12 +203,26 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122); for (table, rows) in &expected { if table != "__yoi_schema_migrations" { - assert_eq!(saved_rows(&conn).into_iter().find(|(name, _)| name == table).unwrap().1, *rows, "{table}, from {current}"); + assert_eq!( + saved_rows(&conn) + .into_iter() + .find(|(name, _)| name == table) + .unwrap() + .1, + *rows, + "{table}, from {current}" + ); } } let mut history = super::super::workspace_schema_migration_history(&conn).unwrap(); - assert_eq!(history.pop(), Some((122, crate::config_adoption::MIGRATION_NAME.into()))); - assert_eq!(history, super::super::workspace_schema_migration_history(&source).unwrap()); + assert_eq!( + history.pop(), + Some((122, crate::config_adoption::MIGRATION_NAME.into())) + ); + assert_eq!( + history, + super::super::workspace_schema_migration_history(&source).unwrap() + ); verify_history(&conn, 122).unwrap(); } } diff --git a/crates/workspace-server/src/storage_formats.rs b/crates/workspace-server/src/storage_formats.rs index 2a797c995..aafe26414 100644 --- a/crates/workspace-server/src/storage_formats.rs +++ b/crates/workspace-server/src/storage_formats.rs @@ -87,8 +87,13 @@ fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String) return Err(invalid("unrecognized configuration adoption receipt")); } let prefix = &rows[..rows.len() - 1]; - let prior = prefix.last().map(|row| row.0).ok_or_else(|| invalid("missing configuration predecessor"))?; - if !(113..=121).contains(&prior) { return Err(invalid("invalid configuration predecessor")); } + let prior = prefix + .last() + .map(|row| row.0) + .ok_or_else(|| invalid("missing configuration predecessor"))?; + if !(113..=121).contains(&prior) { + return Err(invalid("invalid configuration predecessor")); + } return verify_history_records(conn, prior, prefix); } let Some((baseline, name)) = rows.first() else { @@ -217,7 +222,9 @@ pub(super) fn published_plan( version: 122, name: crate::config_adoption::MIGRATION_NAME.to_owned(), }] - } else { vec![] }, + } else { + vec![] + }, })) } @@ -227,9 +234,13 @@ fn verify_configuration_shape(conn: &Connection) -> Result<()> { expected_db.execute_batch(crate::config_adoption::SCHEMA)?; let expected = snapshot(&expected_db)?; let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect(); - let actual: Shape = snapshot(conn)?.into_iter() - .filter(|(_, object)| tables.contains(&object.1)).collect(); - if actual != expected { return Err(invalid("configuration adoption receipt/shape mismatch")); } + let actual: Shape = snapshot(conn)? + .into_iter() + .filter(|(_, object)| tables.contains(&object.1)) + .collect(); + if actual != expected { + return Err(invalid("configuration adoption receipt/shape mismatch")); + } Ok(()) } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 83ea0f1a2..452e7e012 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -16077,13 +16077,18 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> { fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> { let current = current_schema_version(conn)?; if !(113..=121).contains(¤t) { - return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into())); + return Err(Error::Store( + "Configuration adoption migration requires a recognized schema 113..121".into(), + )); } storage_formats::verify_history(conn, current)?; let tx = conn.unchecked_transaction()?; tx.execute_batch(content_history_store::SCHEMA)?; tx.execute_batch(crate::config_adoption::SCHEMA)?; - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", [crate::config_adoption::MIGRATION_NAME])?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", + [crate::config_adoption::MIGRATION_NAME], + )?; tx.commit()?; Ok(()) } @@ -18904,12 +18909,15 @@ mod tests { #[test] fn current_schema_accepts_every_retained_canonical_provenance() { - for baseline_version in (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) { + for baseline_version in + (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) + { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); merge_request::migrate(&conn).unwrap(); - conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;") + .unwrap(); create_latest_workspace_schema(&conn).unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", diff --git a/openapi/configuration-adoption-api.json b/openapi/configuration-adoption-api.json new file mode 100644 index 000000000..4b6064581 --- /dev/null +++ b/openapi/configuration-adoption-api.json @@ -0,0 +1,1776 @@ +{ + "components": { + "schemas": { + "ConfigCommitRequest": { + "additionalProperties": false, + "description": "Compare-and-swap request for applying a complete set of source-tree changes.", + "properties": { + "base_digest": { + "type": "string" + }, + "changes": { + "items": { + "$ref": "#/components/schemas/ConfigTreeChange" + }, + "type": "array" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "base_digest", + "changes", + "entrypoints" + ], + "type": "object" + }, + "ConfigContentType": { + "description": "Source content kinds accepted by the Workspace configuration tree.", + "enum": [ + "decodal", + "text" + ], + "type": "string" + }, + "ConfigEntry": { + "additionalProperties": false, + "description": "One source entry in the Workspace configuration tree.", + "properties": { + "content": { + "type": "string" + }, + "content_digest": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "content_type", + "content", + "content_digest" + ], + "type": "object" + }, + "ConfigProjectionValidator": { + "oneOf": [ + { + "properties": { + "key_aliases": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "const": "static_template_catalog", + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "kind", + "namespace" + ], + "type": "object" + } + ] + }, + "ConfigSchemaContribution": { + "additionalProperties": false, + "properties": { + "authoring_source": { + "description": "Completion-only shape for partial values; never materialized as defaults.", + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": "string" + }, + "projection_validator": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConfigProjectionValidator" + }, + { + "type": "null" + } + ] + }, + "provider_id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "provider_id", + "namespace", + "version", + "source", + "source_digest" + ], + "type": "object" + }, + "ConfigTreeChange": { + "description": "Base-relative change applied by a Workspace configuration commit.", + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "kind": { + "const": "create", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "content_type", + "content" + ], + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "update", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest", + "content" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "from": { + "type": "string" + }, + "kind": { + "const": "rename", + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "kind", + "from", + "to", + "expected_digest" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest" + ], + "type": "object" + } + ] + }, + "ConfigTreeSnapshot": { + "additionalProperties": false, + "description": "Immutable Workspace configuration tree snapshot.", + "properties": { + "digest": { + "type": "string" + }, + "entries": { + "additionalProperties": { + "$ref": "#/components/schemas/ConfigEntry" + }, + "type": "object" + } + }, + "required": [ + "digest", + "entries" + ], + "type": "object" + }, + "ConfigurationAdoptionReceipt": { + "additionalProperties": false, + "properties": { + "history_head": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "policy_committed": { + "type": "boolean" + }, + "runtime_application": { + "$ref": "#/components/schemas/RuntimePolicyApplication" + }, + "source_stored": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "operation_id", + "history_head", + "state", + "source_stored", + "policy_committed", + "runtime_application" + ], + "type": "object" + }, + "ConfigurationAdoptionRequest": { + "additionalProperties": false, + "properties": { + "expected_catalog_digest": { + "type": "string" + }, + "expected_head": { + "type": [ + "string", + "null" + ] + }, + "expected_legacy_export": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/ConfigCommitRequest" + } + }, + "required": [ + "operation_id", + "expected_catalog_digest", + "source" + ], + "type": "object" + }, + "ConfigurationMigrationDiagnostic": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "converted", + "type": "string" + }, + "operation": { + "type": "string" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "operation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "domain_constraint", + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "permission" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "explicit_configuration_required", + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "permission" + ], + "type": "object" + } + ] + }, + "ConfigurationPreviewResponse": { + "additionalProperties": false, + "properties": { + "changed_paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "changed_subjects": { + "items": { + "type": "string" + }, + "type": "array" + }, + "expected_head": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + } + }, + "required": [ + "state", + "changed_paths", + "changed_subjects" + ], + "type": "object" + }, + "ConfigurationReconciliation": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "receipt": { + "$ref": "#/components/schemas/ConfigurationAdoptionReceipt" + }, + "status": { + "const": "adopted", + "type": "string" + } + }, + "required": [ + "status", + "receipt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "export_digest": { + "type": "string" + }, + "status": { + "const": "pending_transfer", + "type": "string" + }, + "transfer_identity": { + "type": "string" + } + }, + "required": [ + "status", + "transfer_identity", + "export_digest" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "No matching evidence was observed. This is not permission to change the ID.", + "properties": { + "status": { + "const": "not_observed", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + ] + }, + "ConfigurationSourceResponse": { + "additionalProperties": false, + "properties": { + "catalog": { + "$ref": "#/components/schemas/PermissionCatalog" + }, + "catalog_digest": { + "type": "string" + }, + "expected_legacy_export": { + "type": [ + "string", + "null" + ] + }, + "history_head": { + "type": [ + "string", + "null" + ] + }, + "migration_diagnostics": { + "items": { + "$ref": "#/components/schemas/ConfigurationMigrationDiagnostic" + }, + "type": "array" + }, + "policies": { + "items": { + "$ref": "#/components/schemas/ConfigurationSubjectPolicy" + }, + "type": "array" + }, + "source_stored": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse", + "description": "Before the first adoption this is an editable, evaluated migration draft.\nsource_stored distinguishes it from the unchanged stored source." + } + }, + "required": [ + "state", + "source_stored", + "catalog", + "catalog_digest", + "policies", + "migration_diagnostics" + ], + "type": "object" + }, + "ConfigurationSubjectPolicy": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "$ref": "#/components/schemas/PermissionAssignment" + }, + "execution": { + "$ref": "#/components/schemas/PermissionAssignment" + }, + "legacy_constraints": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectPermissions" + }, + { + "type": "null" + } + ], + "description": "Exact retained domain restrictions, not editable payloads on permission keys." + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "subject_id", + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "NamespaceCatalog": { + "additionalProperties": false, + "properties": { + "declaration": { + "$ref": "#/components/schemas/NamespaceDeclaration" + }, + "enabled": { + "type": "boolean" + }, + "feature": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "feature", + "enabled", + "declaration" + ], + "type": "object" + }, + "NamespaceDeclaration": { + "additionalProperties": false, + "description": "This input deliberately has no owner field. The Host supplies the registered\nidentity/namespace handle independently of guest-controlled declaration data.", + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/PermissionDefinition" + }, + "type": "array" + }, + "sets": { + "items": { + "$ref": "#/components/schemas/SetContribution" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "sets" + ], + "type": "object" + }, + "NamespacePermissions": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "keys" + ], + "type": "object" + }, + "PermissionAssignment": { + "additionalProperties": false, + "description": "`None` at the consuming policy boundary means unset. An empty assignment is\nexplicit deny-all; this crate never chooses or reinstates a default.", + "properties": { + "exclusions": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "grants": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "grants", + "exclusions", + "revoked" + ], + "type": "object" + }, + "PermissionCatalog": { + "additionalProperties": false, + "properties": { + "namespaces": { + "items": { + "$ref": "#/components/schemas/NamespaceCatalog" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "namespaces" + ], + "type": "object" + }, + "PermissionDefinition": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "key", + "description", + "children" + ], + "type": "object" + }, + "PermissionReference": { + "description": "The discriminant, not an identifier's spelling, selects a reference kind.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "namespace", + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/NamespacePermissions" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionResource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "installation_id": { + "type": "string" + }, + "kind": { + "const": "plugin_installation", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "installation_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "string" + }, + "kind": { + "const": "job", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "job_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ticket", + "type": "string" + }, + "ticket_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "ticket_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "objective", + "type": "string" + }, + "objective_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "objective_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "merge_request", + "type": "string" + }, + "merge_request_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "merge_request_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "subject", + "type": "string" + }, + "subject_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "subject_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "drive_entry", + "type": "string" + }, + "node_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "node_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "drive_request", + "type": "string" + }, + "request_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "request_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "repository", + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "repository_key" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "kind": { + "const": "workdir_attachment", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "connection_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace_config", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "network", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "kind", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "sub_worker", + "type": "string" + }, + "name": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workdir", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Authorizes operation use, not a target Worker or a Workspace collection.\nThe Worker Feature must still enforce its directional target control grant.", + "properties": { + "kind": { + "const": "worker_operation", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + } + ] + }, + "RepositoryApiError": { + "additionalProperties": false, + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupFailureAttachment" + }, + { + "type": "null" + } + ], + "description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience." + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "RuntimePolicyApplication": { + "enum": [ + "next_current_policy_check" + ], + "type": "string" + }, + "ScopedPermission": { + "additionalProperties": false, + "description": "Stored scopes retain their original resources and exact operation names.\n\nA legacy Worker read adapter may use [`canonical_worker_operation`] to project\noperation use, but must retain the original target separately for the Worker\nFeature's control-grant check, including direction, revocation and delegation\nlimits. Replacing an old Worker resource with WorkerOperation and discarding\nthat target would broaden authority. Neither validation nor `covers` performs\nthat conversion.", + "properties": { + "operation": { + "type": "string" + }, + "resource": { + "$ref": "#/components/schemas/PermissionResource" + } + }, + "required": [ + "operation", + "resource" + ], + "type": "object" + }, + "SetContribution": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "set": { + "type": "string" + } + }, + "required": [ + "set", + "children" + ], + "type": "object" + }, + "StartupFailureAttachment": { + "description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.", + "type": "string" + }, + "SubjectPermissions": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "ToolchainContract": { + "additionalProperties": false, + "properties": { + "contract_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "decodal_version": { + "type": "string" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "import_policy_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "schema_bundle": { + "$ref": "#/components/schemas/WorkspaceConfigSchemaBundle" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "contract_version", + "decodal_version", + "schema_version", + "entrypoints", + "import_policy_version", + "schema_bundle", + "fingerprint" + ], + "type": "object" + }, + "WorkspaceConfigSchemaBundle": { + "additionalProperties": false, + "properties": { + "contributions": { + "items": { + "$ref": "#/components/schemas/ConfigSchemaContribution" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "contributions", + "source", + "fingerprint" + ], + "type": "object" + }, + "WorkspaceConfigTreeResponse": { + "additionalProperties": false, + "description": "Workspace configuration tree plus the exact evaluation contract that produced it.", + "properties": { + "contract": { + "$ref": "#/components/schemas/ToolchainContract" + }, + "projection_digest": { + "type": "string" + }, + "snapshot": { + "$ref": "#/components/schemas/ConfigTreeSnapshot" + } + }, + "required": [ + "snapshot", + "contract", + "projection_digest" + ], + "type": "object" + }, + "string": { + "type": "string" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Workspace configuration adoption", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:69090fac404d0e719a6d399a809e4e94d640b85bd646337785b63a5c9b30180a" + }, + "openapi": "3.1.0", + "paths": { + "/api/w/{workspace_id}/config/adoption": { + "get": { + "operationId": "configuration_source", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationSourceResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/commit": { + "post": { + "operationId": "configuration_adopt", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/preview": { + "post": { + "operationId": "configuration_preview", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationPreviewResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/reconcile": { + "post": { + "operationId": "configuration_reconcile", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationReconciliation" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + } + } +} diff --git a/openapi/feature-permissions-api.json b/openapi/feature-permissions-api.json index 64af1c82f..c17704c2d 100644 --- a/openapi/feature-permissions-api.json +++ b/openapi/feature-permissions-api.json @@ -420,6 +420,17 @@ "error": { "type": "string" }, + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupFailureAttachment" + }, + { + "type": "null" + } + ], + "description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience." + }, "message": { "type": "string" } @@ -449,6 +460,10 @@ ], "type": "object" }, + "StartupFailureAttachment": { + "description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.", + "type": "string" + }, "ValidatePermissionsRequest": { "additionalProperties": false, "description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", @@ -481,7 +496,7 @@ "info": { "title": "Feature permission declaration contract", "version": "0.1.0", - "x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" + "x-yoi-source-digest": "sha256:9a4f2ec1ab277c73bf8c69b2adef050dbae26cff5430ff2bd1201371feef5a38" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index caa1e9467..c9588effa 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts src/lib/workspace/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/generated/configuration-adoption.generated.ts b/web/workspace/src/lib/generated/configuration-adoption.generated.ts new file mode 100644 index 000000000..762011f98 --- /dev/null +++ b/web/workspace/src/lib/generated/configuration-adoption.generated.ts @@ -0,0 +1,30 @@ +// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`. +// Do not edit manually. + +import type { ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions } from "./legacy-server-api.ts"; +import type { PermissionAssignment, PermissionCatalog } from "./feature-permissions.generated.ts"; + +export type ConfigurationAdoptionRequest = { operation_id: string, expected_head: string | null, expected_catalog_digest: string, expected_legacy_export: string | null, source: ConfigCommitRequest, }; + +export type RuntimePolicyApplication = "next_current_policy_check"; + +export type ConfigurationAdoptionReceipt = { workspace_id: string, operation_id: string, history_head: string, state: WorkspaceConfigTreeResponse, source_stored: boolean, policy_committed: boolean, runtime_application: RuntimePolicyApplication, }; + +export type ConfigurationSubjectPolicy = { subject_id: string, change_id: string, execution: PermissionAssignment, delegation: PermissionAssignment, +/** + * Exact retained domain restrictions, not editable payloads on permission keys. + */ +legacy_constraints: SubjectPermissions | null, }; + +export type ConfigurationMigrationDiagnostic = { "kind": "converted", subject_id: string, operation: string, } | { "kind": "domain_constraint", subject_id: string, permission: ScopedPermission, } | { "kind": "explicit_configuration_required", subject_id: string, permission: ScopedPermission, }; + +export type ConfigurationSourceResponse = { +/** + * Before the first adoption this is an editable, evaluated migration draft. + * source_stored distinguishes it from the unchanged stored source. + */ +state: WorkspaceConfigTreeResponse, history_head: string | null, source_stored: boolean, catalog: PermissionCatalog, catalog_digest: string, expected_legacy_export: string | null, policies: Array, migration_diagnostics: Array, }; + +export type ConfigurationPreviewResponse = { expected_head: string | null, state: WorkspaceConfigTreeResponse, changed_paths: Array, changed_subjects: Array, }; + +export type ConfigurationReconciliation = { "status": "adopted", receipt: ConfigurationAdoptionReceipt, } | { "status": "pending_transfer", transfer_identity: string, export_digest: string, } | { "status": "not_observed" }; diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts index fd5bb856f..30cb349b0 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; +import type { WorkspaceConfigTreeResponse } from "./types.ts"; const toolchain = vi.hoisted(() => ({ construct: vi.fn(), @@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({ })); const schemaSource = "schema body\n".repeat(6000); -const tree = { +const tree: WorkspaceConfigTreeResponse = { snapshot: { digest: "sha256:tree", entries: { @@ -64,7 +65,27 @@ const tree = { projection_digest: "sha256:projection", }; const fetcher = vi.fn(); -const response = () => new Response(JSON.stringify(tree)); +const sourceResponse = (state = tree) => ({ + state, + history_head: "head:observed", + source_stored: true, + catalog: { workspace: "w", namespaces: [] }, + catalog_digest: "sha256:catalog", + expected_legacy_export: "sha256:legacy", + policies: [], + migration_diagnostics: [], +}); +const response = () => new Response(JSON.stringify(sourceResponse())); +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body)); +const previewResponse = (state = tree, expected_head: string | null = "head:observed") => jsonResponse({ + expected_head, state, changed_paths: ["module.dcdl"], changed_subjects: [], +}); +function receipt(operation_id: string, state = tree, history_head = "head:adopted") { + return { + workspace_id: "w", operation_id, history_head, state, + source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check", + }; +} beforeEach(() => { vi.resetAllMocks(); @@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation finishSetup(); await expectReady(view); expect(fetcher).toHaveBeenCalledWith( - "/api/w/w%2Fone/config/source-tree", + "/api/w/w%2Fone/config/adoption", expect.anything(), ); }); @@ -140,7 +161,7 @@ test.each(["http", "invalid"])( fetcher.mockResolvedValueOnce( failure === "http" ? new Response("Backend unavailable", { status: 503 }) - : new Response(JSON.stringify({ ...tree, unexpected: true })), + : new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })), ); const view = renderEditor(); await waitFor(() => @@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)( test("a successfully loaded empty tree still offers source creation", async () => { fetcher.mockResolvedValueOnce( - new Response(JSON.stringify({ + new Response(JSON.stringify(sourceResponse({ ...tree, snapshot: { ...tree.snapshot, entries: {} }, - })), + }))), ); const view = renderEditor(); await waitFor(() => @@ -328,49 +349,186 @@ test("diagnostics from another tree digest are not displayed", async () => { expect(view.container.textContent).not.toContain("Wrong tree diagnostic"); }); -test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => { - const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" }; - const candidate = { - digest: "sha256:local-draft", - entries: { - ...tree.snapshot.entries, - "module.dcdl": { - path: change.path, - content_type: change.content_type, - content: change.content, - content_digest: "sha256:module", - }, +const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" } as const; +const candidate = { + digest: "sha256:local-draft", + entries: { + ...tree.snapshot.entries, + "module.dcdl": { + path: change.path, content_type: change.content_type, content: change.content, + content_digest: "sha256:module", }, - }; + }, +}; +const candidateState = { ...tree, snapshot: candidate }; + +async function prepareDraft() { toolchain.applyChanges.mockResolvedValue(candidate); toolchain.changesBetween.mockResolvedValue([change]); toolchain.format.mockImplementation(async (source: string) => source); const view = renderEditor(); await expectReady(view); await fireEvent.click(view.getByRole("button", { name: "Create local source" })); + return view; +} + +const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body); + +function commitReceiptResponse(state = candidateState, historyHead = "head:adopted") { + return async (_input: string, init: RequestInit) => + jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead)); +} + +test.each([409, 422])("preview rejection %s preserves the draft and observed editing metadata", async (status) => { + const view = await prepareDraft(); const commit = view.getByRole("button", { name: "Commit" }); - expect(commit).toBeInstanceOf(HTMLButtonElement); fetcher.mockResolvedValueOnce(new Response( - status === 409 ? "base content digest mismatch" : "candidate evaluation failed", - { status }, + status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status }, )); await fireEvent.click(commit); await waitFor(() => expect(view.container.textContent).toContain( - status === 409 ? "base content digest mismatch" : "candidate evaluation failed", + status === 409 ? "editing head mismatch" : "candidate evaluation failed", )); - const expectedRequest = { - base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"], - }; - expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest); + const first = requestAt(1); + expect(first).toEqual({ + operation_id: expect.any(String), expected_head: "head:observed", + expected_catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy", + source: { base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"] }, + }); + expect(fetcher.mock.calls[1][0]).toBe("/api/w/w/config/adoption/preview"); expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); expect(commit).toHaveProperty("disabled", false); expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate"); - fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate }))); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockImplementationOnce(commitReceiptResponse()); await fireEvent.click(commit); await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); - expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest); + const second = requestAt(2); + expect(second).toEqual({ ...first, operation_id: expect.any(String) }); + expect(second.operation_id).not.toBe(first.operation_id); + expect(requestAt(3)).toEqual(second); expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle); expect(commit).toHaveProperty("disabled", true); }); + +test("initial editable migration draft can be adopted without manually changing a file", async () => { + fetcher.mockResolvedValueOnce(jsonResponse({ + ...sourceResponse(), history_head: null, source_stored: false, + })); + toolchain.changesBetween.mockResolvedValue([]); + const view = renderEditor(); + await expectReady(view); + const commit = view.getByRole("button", { name: "Commit" }); + expect(commit).toHaveProperty("disabled", false); + fetcher.mockResolvedValueOnce(previewResponse(tree, null)); + fetcher.mockImplementationOnce(commitReceiptResponse(tree)); + await fireEvent.click(commit); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(requestAt(1).expected_head).toBeNull(); + expect(requestAt(1).source.changes).toEqual([]); + expect(requestAt(2)).toEqual(requestAt(1)); + expect(commit).toHaveProperty("disabled", true); +}); + +test("commit rejection does not refresh the head or discard local source", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockResolvedValueOnce(new Response("editing head mismatch", { status: 409 })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Discard local candidate")); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockImplementationOnce(commitReceiptResponse()); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(requestAt(3).expected_head).toBe(requestAt(1).expected_head); + expect(requestAt(3).operation_id).not.toBe(requestAt(1).operation_id); +}); + +test.each(["not_observed", "pending_transfer"])("lost commit followed by %s retains the exact request for explicit resume", async (status) => { + const view = await prepareDraft(); + const unknown = status === "not_observed" ? { status } : { + status, transfer_identity: "transfer:one", export_digest: "sha256:legacy", + }; + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockRejectedValueOnce(new TypeError("response lost")); + fetcher.mockResolvedValueOnce(jsonResponse(unknown)); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain( + status === "not_observed" ? "not observed does not mean failed" : "transfer pending", + )); + expect(fetcher).toHaveBeenCalledTimes(4); + const request = requestAt(1); + expect(requestAt(2)).toEqual(request); + expect(requestAt(3)).toEqual(request); + expect(fetcher.mock.calls[3][0]).toBe("/api/w/w/config/adoption/reconcile"); + expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); + + fetcher.mockResolvedValueOnce(jsonResponse(unknown)); + fetcher.mockImplementationOnce(commitReceiptResponse()); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(fetcher).toHaveBeenCalledTimes(6); + expect(requestAt(4)).toEqual(request); + expect(requestAt(5)).toEqual(request); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); +}); + +test("adopted reconciliation updates head and clears the legacy export for the next explicit adoption", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockResolvedValueOnce(new Response("invalid receipt")); + fetcher.mockImplementationOnce(async (_input: string, init: RequestInit) => jsonResponse({ + status: "adopted", receipt: receipt(JSON.parse(String(init.body)).operation_id, candidateState), + })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + const first = requestAt(2); + expect(requestAt(3)).toEqual(first); + // A local deletion is another explicit adoption, not a retry of the lost reply. + const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" }; + toolchain.applyChanges.mockResolvedValue(tree.snapshot); + toolchain.changesBetween.mockResolvedValue([deletion]); + await fireEvent.click(view.getByRole("button", { name: "Delete" })); + fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted")); + fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second")); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(6)); + const second = requestAt(4); + expect(second.expected_head).toBe("head:adopted"); + expect(second.expected_legacy_export).toBeNull(); + expect(second.expected_catalog_digest).toBe(first.expected_catalog_digest); + expect(second.operation_id).not.toBe(first.operation_id); + expect(second.source.base_digest).toBe(candidate.digest); +}); + +test("edited draft requires reconciliation and cannot replace or resume an unknown request", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockRejectedValueOnce(new TypeError("response lost")); + fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("not observed does not mean failed")); + const original = requestAt(2); + toolchain.format.mockResolvedValue("{ changed: true }\n"); + await fireEvent.click(view.getByRole("button", { name: "Format" })); + await waitFor(() => expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed")); + fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Draft changed; reconcile the original request")); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(requestAt(4)).toEqual(original); + expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); + + fetcher.mockResolvedValueOnce(jsonResponse({ status: "adopted", receipt: receipt(original.operation_id, candidateState) })); + await fireEvent.click(view.getByRole("button", { name: "Reconcile adoption" })); + await waitFor(() => expect(view.container.textContent).toContain("Later local edits remain unsaved")); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed"); + expect(requestAt(5)).toEqual(original); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index de04b71d7..588391e10 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -1,15 +1,22 @@