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],