From 9f8aad79a3c4b1b8274c2b905ae4c01db2d6b761 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 16:54:54 +0900 Subject: [PATCH] feat: connect owner configuration adoption and guarded policy cutover Integration checkpoint for T-838, not a completed reviewed handoff. Includes published T-840 startup dependencies. Root check, API artifacts and focused HTTP/cutover tests executed; Unicode renderer roundtrip still fails on the separately owned Decodal 0.4.0 issue (T-830). Web validation/visual review, broad suites and independent review remain outstanding. --- crates/server-api/README.md | 1 + ...generate_configuration_adoption_openapi.rs | 7 +- .../generate_configuration_adoption_types.rs | 11 +- .../server-api/src/configuration_adoption.rs | 182 +- crates/server-api/src/lib.rs | 3 +- crates/subjektiv/src/deletion.rs | 5 +- crates/subjektiv/src/lib.rs | 9 +- crates/subjektiv/src/permissions.rs | 8 +- crates/subjektiv/src/permissions/transfer.rs | 265 ++- .../src/permissions/transfer/tests.rs | 792 +++++++- crates/subjektiv/src/storage_format_tests.rs | 124 +- crates/subjektiv/src/storage_formats.rs | 25 +- crates/subjektiv/src/subject_creation.rs | 5 +- .../src/feature/builtin/permission_catalog.rs | 122 +- crates/workspace-server/README.md | 1 + .../workspace-server/src/config_adoption.rs | 312 ++- .../src/config_adoption_tests.rs | 428 +++- crates/workspace-server/src/config_source.rs | 92 +- .../src/configuration_migration.rs | 319 ++- .../src/configuration_transfer_tests.rs | 1062 ++++++++++ crates/workspace-server/src/lib.rs | 6 +- crates/workspace-server/src/policy_config.rs | 189 +- crates/workspace-server/src/server.rs | 50 +- .../src/server_configuration_adoption.rs | 523 +++++ .../server_configuration_adoption_tests.rs | 945 +++++++++ .../src/server_workspace_config.rs | 38 +- .../src/storage_format_tests.rs | 25 +- .../workspace-server/src/storage_formats.rs | 23 +- crates/workspace-server/src/store.rs | 16 +- openapi/configuration-adoption-api.json | 1776 +++++++++++++++++ openapi/feature-permissions-api.json | 17 +- web/workspace/deno.json | 2 +- .../configuration-adoption.generated.ts | 30 + .../ConfigSourceEditor.browser.spec.ts | 216 +- .../config-source/ConfigSourceEditor.svelte | 232 ++- .../config-source/adoption-api.test.ts | 143 ++ .../src/lib/workspace/config-source/api.ts | 184 +- web/workspace/test/config-source/api.test.ts | 216 +- .../test/config-source/editor-state.test.ts | 61 +- 39 files changed, 7647 insertions(+), 818 deletions(-) create mode 100644 crates/workspace-server/src/configuration_transfer_tests.rs create mode 100644 crates/workspace-server/src/server_configuration_adoption.rs create mode 100644 crates/workspace-server/src/server_configuration_adoption_tests.rs create mode 100644 openapi/configuration-adoption-api.json create mode 100644 web/workspace/src/lib/generated/configuration-adoption.generated.ts create mode 100644 web/workspace/src/lib/workspace/config-source/adoption-api.test.ts diff --git a/crates/server-api/README.md b/crates/server-api/README.md index ec5e41c1a..5ee2b587e 100644 --- a/crates/server-api/README.md +++ b/crates/server-api/README.md @@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API - [`ServerApi`] — HTTP operations and transport metadata - [`ServerRequestContext`] — Request context passed to Server authentication adapters +- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation - [`RequestActor`] — Human identity and authentication method - [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result diff --git a/crates/server-api/examples/generate_configuration_adoption_openapi.rs b/crates/server-api/examples/generate_configuration_adoption_openapi.rs index fbc58b1d5..882f50fb4 100644 --- a/crates/server-api/examples/generate_configuration_adoption_openapi.rs +++ b/crates/server-api/examples/generate_configuration_adoption_openapi.rs @@ -1,4 +1,7 @@ -fn main() -> Result<(),Box> { - print!("{}", server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?); +fn main() -> Result<(), Box> { + print!( + "{}", + server_api::configuration_adoption::configuration_contract_openapi()?.to_json()? + ); Ok(()) } diff --git a/crates/server-api/examples/generate_configuration_adoption_types.rs b/crates/server-api/examples/generate_configuration_adoption_types.rs index fe0798e49..b1e136cb8 100644 --- a/crates/server-api/examples/generate_configuration_adoption_types.rs +++ b/crates/server-api/examples/generate_configuration_adoption_types.rs @@ -1,8 +1,13 @@ -#[cfg(feature="typescript")] +#[cfg(feature = "typescript")] fn main() { - print!("{}",server_api::normalized_typescript(server_api::configuration_adoption::configuration_typescript())); + print!( + "{}", + server_api::normalized_typescript( + server_api::configuration_adoption::configuration_typescript() + ) + ); } -#[cfg(not(feature="typescript"))] +#[cfg(not(feature = "typescript"))] fn main() { eprintln!("generate_configuration_adoption_types requires --features typescript"); std::process::exit(2); diff --git a/crates/server-api/src/configuration_adoption.rs b/crates/server-api/src/configuration_adoption.rs index 6ea7b75cc..5fb8c0325 100644 --- a/crates/server-api/src/configuration_adoption.rs +++ b/crates/server-api/src/configuration_adoption.rs @@ -1,13 +1,15 @@ //! Workspace-owner source adoption transport. The Server authenticates the owner //! before reading source, previewing, sealing migration data, adopting or reconciling. //! A result lookup is not a new adoption and absence is not proof of failure. -use crate::{ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse}; +use crate::{ + ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse, +}; use feature_permissions::{PermissionAssignment, PermissionCatalog}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationAdoptionRequest { pub operation_id: String, @@ -18,90 +20,125 @@ pub struct ConfigurationAdoptionRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(rename_all="snake_case")] -pub enum RuntimePolicyApplication { NextCurrentPolicyCheck } +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimePolicyApplication { + NextCurrentPolicyCheck, +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationAdoptionReceipt { - pub workspace_id:String, - pub operation_id:String, - pub history_head:String, - pub state:WorkspaceConfigTreeResponse, - pub source_stored:bool, - pub policy_committed:bool, - pub runtime_application:RuntimePolicyApplication, + pub workspace_id: String, + pub operation_id: String, + pub history_head: String, + pub state: WorkspaceConfigTreeResponse, + pub source_stored: bool, + pub policy_committed: bool, + pub runtime_application: RuntimePolicyApplication, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationSubjectPolicy { - pub subject_id:String, - pub change_id:String, - pub execution:PermissionAssignment, - pub delegation:PermissionAssignment, + pub subject_id: String, + pub change_id: String, + pub execution: PermissionAssignment, + pub delegation: PermissionAssignment, /// Exact retained domain restrictions, not editable payloads on permission keys. - pub legacy_constraints:Option, + pub legacy_constraints: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(tag="kind",rename_all="snake_case",deny_unknown_fields)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ConfigurationMigrationDiagnostic { - Converted {subject_id:String,operation:String}, - DomainConstraint {subject_id:String,permission:crate::authorization::ScopedPermission}, - ExplicitConfigurationRequired {subject_id:String,permission:crate::authorization::ScopedPermission}, + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationSourceResponse { /// Before the first adoption this is an editable, evaluated migration draft. /// source_stored distinguishes it from the unchanged stored source. - pub state:WorkspaceConfigTreeResponse, - pub history_head:Option, - pub source_stored:bool, - pub catalog:PermissionCatalog, - pub catalog_digest:String, - pub expected_legacy_export:Option, - pub policies:Vec, - pub migration_diagnostics:Vec, + pub state: WorkspaceConfigTreeResponse, + pub history_head: Option, + pub source_stored: bool, + pub catalog: PermissionCatalog, + pub catalog_digest: String, + pub expected_legacy_export: Option, + pub policies: Vec, + pub migration_diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigurationPreviewResponse { - pub expected_head:Option, - pub state:WorkspaceConfigTreeResponse, - pub changed_paths:Vec, - pub changed_subjects:Vec, + pub expected_head: Option, + pub state: WorkspaceConfigTreeResponse, + pub changed_paths: Vec, + pub changed_subjects: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature="typescript",derive(ts_rs::TS))] -#[serde(tag="status",rename_all="snake_case",deny_unknown_fields)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] pub enum ConfigurationReconciliation { - Adopted {receipt:ConfigurationAdoptionReceipt}, - PendingTransfer {transfer_identity:String,export_digest:String}, + Adopted { + receipt: ConfigurationAdoptionReceipt, + }, + PendingTransfer { + transfer_identity: String, + export_digest: String, + }, /// No matching evidence was observed. This is not permission to change the ID. NotObserved, } -#[api_macros::api(reqwest,axum,openapi)] +#[api_macros::api(reqwest, axum, openapi)] pub trait ConfigurationAdoptionApi { #[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)] - async fn configuration_source(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String)->Result; + async fn configuration_source( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_preview(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_preview( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_adopt(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_adopt( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; #[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] - async fn configuration_reconcile(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String,#[body] request:ConfigurationAdoptionRequest)->Result; + async fn configuration_reconcile( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; } impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {} @@ -110,25 +147,46 @@ impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {} impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {} impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {} -pub fn configuration_contract_openapi() -> Result { - use sha2::{Digest,Sha256}; - let document=|source_digest:&str| configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { - title:"Workspace configuration adoption",version:env!("CARGO_PKG_VERSION"),source_digest, - }); - let normalized=document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?.to_json()?; - let digest:String=Sha256::digest(normalized.as_bytes()).iter().map(|byte|format!("{byte:02x}")).collect(); +pub fn configuration_contract_openapi() +-> Result { + use sha2::{Digest, Sha256}; + let document = |source_digest: &str| { + configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Workspace configuration adoption", + version: env!("CARGO_PKG_VERSION"), + source_digest, + }) + }; + let normalized = + document("sha256:0000000000000000000000000000000000000000000000000000000000000000")? + .to_json()?; + let digest: String = Sha256::digest(normalized.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); document(&format!("sha256:{digest}")) } -#[cfg(feature="typescript")] +#[cfg(feature = "typescript")] pub fn configuration_typescript() -> String { use ts_rs::TS; - let config=ts_rs::Config::default(); - let declarations=[ - ConfigurationAdoptionRequest::decl(&config), RuntimePolicyApplication::decl(&config), - ConfigurationAdoptionReceipt::decl(&config), ConfigurationSubjectPolicy::decl(&config), - ConfigurationMigrationDiagnostic::decl(&config), ConfigurationSourceResponse::decl(&config), - ConfigurationPreviewResponse::decl(&config), ConfigurationReconciliation::decl(&config), + let config = ts_rs::Config::default(); + let declarations = [ + ConfigurationAdoptionRequest::decl(&config), + RuntimePolicyApplication::decl(&config), + ConfigurationAdoptionReceipt::decl(&config), + ConfigurationSubjectPolicy::decl(&config), + ConfigurationMigrationDiagnostic::decl(&config), + ConfigurationSourceResponse::decl(&config), + ConfigurationPreviewResponse::decl(&config), + ConfigurationReconciliation::decl(&config), ]; - format!("// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",declarations.into_iter().map(|declaration|format!("export {declaration}")).collect::>().join("\n\n")) + format!( + "// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e9a8b77f3..fcc212361 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,9 +8,10 @@ pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod authorization; mod chat; -pub mod feature_permissions; pub mod configuration_adoption; +pub mod feature_permissions; pub use chat::*; +pub use configuration_adoption::*; mod subject_body; pub use subject_body::*; mod chat_transport; diff --git a/crates/subjektiv/src/deletion.rs b/crates/subjektiv/src/deletion.rs index fb2d4c6c2..5b964f9e8 100644 --- a/crates/subjektiv/src/deletion.rs +++ b/crates/subjektiv/src/deletion.rs @@ -407,7 +407,10 @@ impl SubjektivStore { )?; } if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { - tx.execute("DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; + tx.execute( + "DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", + [subject], + )?; } for table in DIRECT { tx.execute( diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index e425a8818..4fbe7e2b1 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -9,8 +9,8 @@ pub mod journal; mod legacy_migrations; pub mod permissions; pub use permissions::{ - CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, - PermissionTransferReceipt, PermissionTransferSnapshot, + CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt, + PermissionTransferSnapshot, }; mod schema; mod subject_creation; @@ -755,7 +755,10 @@ impl SubjektivStore { } } })?; - Ok(Self { database, canonical_permission_source: None }) + Ok(Self { + database, + canonical_permission_source: None, + }) } pub fn scope_id(&self) -> &str { diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs index 38f3f6f91..007fbc9ee 100644 --- a/crates/subjektiv/src/permissions.rs +++ b/crates/subjektiv/src/permissions.rs @@ -14,7 +14,7 @@ use sha2::{Digest, Sha256}; pub(crate) mod transfer; pub use transfer::{ - CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, + CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport, PermissionTransferReceipt, PermissionTransferSnapshot, }; @@ -238,11 +238,7 @@ fn grant_in_connection( "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)" }; let raw: Option = connection - .query_row( - sql, - params![runtime, worker], - |row| row.get(0), - ) + .query_row(sql, params![runtime, worker], |row| row.get(0)) .optional()?; raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) .transpose() diff --git a/crates/subjektiv/src/permissions/transfer.rs b/crates/subjektiv/src/permissions/transfer.rs index 059da3e61..aa2ce06c5 100644 --- a/crates/subjektiv/src/permissions/transfer.rs +++ b/crates/subjektiv/src/permissions/transfer.rs @@ -1,5 +1,7 @@ //! One-way preparation of a permission-domain transfer. No Server adoption or dual commit. use super::*; +use rusqlite::TransactionBehavior; +use std::cell::RefCell; use std::sync::Arc; /// Host connection to the canonical permission domain, not an ACL or grant issuer. @@ -69,8 +71,95 @@ pub struct PermissionTransferReceipt { pub snapshot: PermissionTransferSnapshot, } +/// Short-lived Feature authority fence for a synchronous Host operation. +/// +/// The IMMEDIATE transaction is acquired before the Host enters its Server transaction, +/// serializing a legacy logical-source check through Server commit against cutover even +/// across processes. Sealing commits only the Feature transaction, not the Server operation. +/// The outer native connection/lifecycle guard remains held until the action returns; +/// callbacks must not reenter this store, including through a canonical source provider. +/// No raw connection or physical storage path is exposed. +pub struct PermissionAuthorityGuard<'connection> { + transaction: RefCell>>, + expected: DatabaseIdentity, +} + +impl PermissionAuthorityGuard<'_> { + /// Gate a legacy logical-source writer before entering its Server transaction. + /// The physical write lock remains held through the enclosing action's return. + pub fn require_unsealed(&self) -> Result<()> { + let transaction = self.transaction.borrow(); + let tx = transaction + .as_ref() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + require_unsealed(tx) + } + + /// Validate Subject metadata and deletion fences only, without canonical-provider calls. + /// All such validation must precede sealing; this guard is unusable after a seal attempt. + pub fn require_subject(&self, subject: &str) -> Result<()> { + validate_authorization_identifier(subject).map_err(SubjektivError::InvalidRecord)?; + let transaction = self.transaction.borrow(); + let tx = transaction + .as_ref() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + require_subject_in_connection(tx, subject)?; + Ok(()) + } + + /// Commit the exact Feature seal independently of the enclosing action's result. + /// Exact receipt retries use the same identity, fingerprint and export checks, and + /// commit their read-only transaction too. Failure drops the transaction to roll back; + /// success releases the SQLite write lock but not the outer native connection guard. + pub fn seal_permission_sources( + &self, + transfer_id: &str, + expected_export_digest: &str, + ) -> Result { + let tx = self + .transaction + .borrow_mut() + .take() + .ok_or(SubjektivError::PermissionAuthoritySealed)?; + let receipt = + seal_in_transaction(&tx, &self.expected, transfer_id, expected_export_digest)?; + tx.commit()?; + Ok(receipt) + } +} + impl SubjektivStore { - pub fn with_canonical_permission_source(mut self, source: Arc) -> Self { + /// Hold the native Feature connection/lifecycle fence across a synchronous Host action. + /// An IMMEDIATE Feature transaction is entered before invoking the action (and thus before + /// any nested Server transaction). Without a seal it rolls back on every return path. + /// A successful seal survives even an action error: this is not a distributed transaction. + /// Callbacks must use this guard for validation and must not reenter Feature storage. + /// Hosts can retain Server error/status types by returning `Ok(server_result)` as `T`. + pub fn with_permission_authority_guard( + &self, + expected: &DatabaseIdentity, + action: impl FnOnce(&PermissionAuthorityGuard<'_>) -> Result, + ) -> Result { + if self.database.identity() != &expected.storage_identity() { + return Err(SubjektivError::PermissionTransferConflict); + } + // try_with_connection verifies the native physical identity before/after the action + // and retains its connection mutex and lifecycle lease even after the seal commits. + self.database.try_with_connection(|connection| { + let tx = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?; + require_store_scope(&tx, self.scope_id())?; + let guard = PermissionAuthorityGuard { + transaction: RefCell::new(Some(tx)), + expected: expected.clone(), + }; + action(&guard) + }) + } + + pub fn with_canonical_permission_source( + mut self, + source: Arc, + ) -> Self { self.canonical_permission_source = Some(source); self } @@ -83,7 +172,10 @@ impl SubjektivStore { /// Reads one coherent pre-seal snapshot without installing schema or changing authority. /// After a transfer, returns the retained exact snapshot, never a changed/deleted live set. - pub fn preview_permission_transfer(&self, expected: &DatabaseIdentity) -> Result { + pub fn preview_permission_transfer( + &self, + expected: &DatabaseIdentity, + ) -> Result { self.permission_transfer_transaction(expected, |tx| { if let Some(receipt) = retained_receipt(tx)? { require_receipt_identity(&receipt, expected)?; @@ -94,6 +186,22 @@ impl SubjektivStore { }) } + /// Observe saved transfer evidence without exporting, sealing or changing policy. + pub fn permission_transfer_receipt( + &self, + expected: &DatabaseIdentity, + ) -> Result> { + self.permission_transfer_transaction(expected, |tx| { + let receipt = retained_receipt(tx)?; + if let Some(receipt) = &receipt { + require_receipt_identity(receipt, expected)?; + } else { + require_unsealed(tx)?; + } + Ok(receipt) + }) + } + /// Permanently seals the old source writer after preview/CAS. All changes, the exact export /// and its identity-bound receipt commit in one old-DB IMMEDIATE transaction. The Host must /// authorize/preview first and bind transfer_id to the complete Server request fingerprint. @@ -104,31 +212,8 @@ impl SubjektivStore { transfer_id: &str, expected_export_digest: &str, ) -> Result { - validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?; self.permission_transfer_transaction(expected, |tx| { - if let Some(receipt) = retained_receipt(tx)? { - require_receipt_identity(&receipt, expected)?; - if receipt.transfer_id != transfer_id || receipt.snapshot.export_digest != expected_export_digest { - return Err(SubjektivError::PermissionTransferConflict); - } - return Ok(receipt); - } - // A published Permission18 seal is not ours to rewrite or relabel as this receipt. - require_unsealed(tx)?; - let snapshot = export_in_connection(tx, expected.clone())?; - if snapshot.export_digest != expected_export_digest { - return Err(SubjektivError::PermissionTransferConflict); - } - crate::storage_formats::install_permission_transfer(tx)?; - if !has_table(tx, "permission_source_transfer_receipt")? { - return Err(SubjektivError::InvalidRecord("permission transfer requires schema 1..17".into())); - } - let receipt = PermissionTransferReceipt { transfer_id: transfer_id.into(), snapshot }; - let raw = serde_json::to_string(&receipt)?; - tx.execute("INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)", - params![transfer_id, serde_json::to_string(&receipt.snapshot)?])?; - tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?; - Ok(receipt) + seal_in_transaction(tx, expected, transfer_id, expected_export_digest) }) } @@ -150,7 +235,11 @@ impl SubjektivStore { }) } - pub(super) fn current_permission_source(&self, connection: &Connection, subject: &str) -> Result> { + pub(super) fn current_permission_source( + &self, + connection: &Connection, + subject: &str, + ) -> Result> { if !is_sealed(connection)? { return source_in_connection(connection, subject); } @@ -158,7 +247,8 @@ impl SubjektivStore { .ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?; let source = provider.subject_permissions(self.scope_id(), subject)?; if let Some(source) = &source { - validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; validate_scope(self.scope_id(), &source.execution)?; validate_scope(self.scope_id(), &source.delegation)?; } @@ -166,51 +256,123 @@ impl SubjektivStore { } } +/// Shared exact seal/retry logic. The caller owns the transaction and its commit boundary. +fn seal_in_transaction( + tx: &Transaction<'_>, + expected: &DatabaseIdentity, + transfer_id: &str, + expected_export_digest: &str, +) -> Result { + validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?; + if let Some(receipt) = retained_receipt(tx)? { + require_receipt_identity(&receipt, expected)?; + if receipt.transfer_id != transfer_id + || receipt.snapshot.export_digest != expected_export_digest + { + return Err(SubjektivError::PermissionTransferConflict); + } + return Ok(receipt); + } + // A published Permission18 seal is not ours to rewrite or relabel as this receipt. + require_unsealed(tx)?; + let snapshot = export_in_connection(tx, expected.clone())?; + if snapshot.export_digest != expected_export_digest { + return Err(SubjektivError::PermissionTransferConflict); + } + crate::storage_formats::install_permission_transfer(tx)?; + if !has_table(tx, "permission_source_transfer_receipt")? { + return Err(SubjektivError::InvalidRecord( + "permission transfer requires schema 1..17".into(), + )); + } + let receipt = PermissionTransferReceipt { + transfer_id: transfer_id.into(), + snapshot, + }; + let raw = serde_json::to_string(&receipt)?; + tx.execute( + "INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)", + params![transfer_id, serde_json::to_string(&receipt.snapshot)?], + )?; + tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?; + Ok(receipt) +} + pub(crate) fn has_table(connection: &Connection, name: &str) -> Result { - Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", [name], |row| row.get(0))?) + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", + [name], + |row| row.get(0), + )?) } pub(crate) fn is_sealed(connection: &Connection) -> Result { - if !has_table(connection, "permission_authority_seal")? { return Ok(false); } - Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", [], |row| row.get(0))?) + if !has_table(connection, "permission_authority_seal")? { + return Ok(false); + } + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", + [], + |row| row.get(0), + )?) } pub(super) fn require_unsealed(connection: &Connection) -> Result<()> { - if is_sealed(connection)? { return Err(SubjektivError::PermissionAuthoritySealed); } + if is_sealed(connection)? { + return Err(SubjektivError::PermissionAuthoritySealed); + } Ok(()) } fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> { let stored: String = connection.query_row( "SELECT workspace_id FROM store_scope WHERE singleton=1", - [], |row| row.get(0), + [], + |row| row.get(0), )?; - if stored != scope { return Err(SubjektivError::PermissionTransferConflict); } + if stored != scope { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(()) } -fn require_receipt_identity(receipt: &PermissionTransferReceipt, expected: &DatabaseIdentity) -> Result<()> { - if receipt.snapshot.identity != *expected { return Err(SubjektivError::PermissionTransferConflict); } +fn require_receipt_identity( + receipt: &PermissionTransferReceipt, + expected: &DatabaseIdentity, +) -> Result<()> { + if receipt.snapshot.identity != *expected { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(()) } fn retained_receipt(connection: &Connection) -> Result> { - if !has_table(connection, "permission_source_transfer_receipt")? { return Ok(None); } + if !has_table(connection, "permission_source_transfer_receipt")? { + return Ok(None); + } let raw: Option = connection.query_row( "SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1", [], |row| row.get(0), ).optional()?; - let receipt: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; - if receipt.is_some() && !is_sealed(connection)? { return Err(SubjektivError::PermissionTransferConflict); } + let receipt: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if receipt.is_some() && !is_sealed(connection)? { + return Err(SubjektivError::PermissionTransferConflict); + } Ok(receipt) } -fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> Result { +fn export_in_connection( + connection: &Connection, + identity: DatabaseIdentity, +) -> Result { let orphaned: bool = connection.query_row( "SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)", [], |row| row.get(0), )?; - if orphaned { return Err(SubjektivError::PermissionTransferConflict); } + if orphaned { + return Err(SubjektivError::PermissionTransferConflict); + } let mut statement = connection.prepare( "SELECT s.subject_id,p.change_id,c.record_json FROM subjects s LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id @@ -224,20 +386,31 @@ fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?; let change: Option = row.get(1)?; let raw: Option = row.get(2)?; - let permissions: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + let permissions: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() { return Err(SubjektivError::PermissionTransferConflict); } if let Some(source) = &permissions { - validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; validate_scope(&identity.scope_id, &source.execution)?; validate_scope(&identity.scope_id, &source.delegation)?; } - sources.push(PermissionSourceExport { subject_id, permissions }); + sources.push(PermissionSourceExport { + subject_id, + permissions, + }); } let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?) - .iter().map(|byte| format!("{byte:02x}")).collect(); - Ok(PermissionTransferSnapshot { identity, sources, export_digest }) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(PermissionTransferSnapshot { + identity, + sources, + export_digest, + }) } #[cfg(test)] diff --git a/crates/subjektiv/src/permissions/transfer/tests.rs b/crates/subjektiv/src/permissions/transfer/tests.rs index 57d94a12e..e237136c0 100644 --- a/crates/subjektiv/src/permissions/transfer/tests.rs +++ b/crates/subjektiv/src/permissions/transfer/tests.rs @@ -1,22 +1,37 @@ //! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim. use super::*; use crate::tests::{open_store, role}; -use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant}; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant, +}; use std::collections::BTreeMap; use std::sync::{Barrier, Mutex}; fn read_permission() -> ScopedPermission { ScopedPermission { operation: "file.read".into(), - resource: PermissionResource::Workdir { working_directory_id: "wd".into(), path: ".".into(), recursive: true }, + resource: PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: ".".into(), + recursive: true, + }, } } -fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: Vec) -> (SubjectPermissionsUpdate, SubjectPermissions) { +fn replace( + store: &SubjektivStore, + subject: &str, + operation: &str, + delegation: Vec, +) -> (SubjectPermissionsUpdate, SubjectPermissions) { let update = SubjectPermissionsUpdate { operation_id: operation.into(), - expected_change_id: store.subject_permissions(subject).unwrap().map(|source| source.change_id), - execution: vec![], delegation, + expected_change_id: store + .subject_permissions(subject) + .unwrap() + .map(|source| source.change_id), + execution: vec![], + delegation, }; let source = store.set_subject_permissions(subject, &update).unwrap(); (update, source) @@ -24,25 +39,52 @@ fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: V fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { WorkerExecutionGrant { - subject_id: subject.into(), source_change_id: source.into(), - runtime_id: "runtime".into(), worker_id: worker.into(), - origin_runtime_id: "runtime".into(), origin_worker_id: "host-authenticated-root".into(), - profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), - permissions: vec![read_permission()], redelegation: vec![], + subject_id: subject.into(), + source_change_id: source.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "host-authenticated-root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![read_permission()], + redelegation: vec![], } } fn counts(store: &SubjektivStore) -> Vec { - store.database.try_with_connection::<_, SubjektivError>(|connection| { - ["subject_permissions", "subject_permission_changes", "subject_permission_receipts", "worker_execution_grants"] - .iter().map(|table| Ok(connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?)).collect() - }).unwrap() + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", + ] + .iter() + .map(|table| { + Ok( + connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + })?, + ) + }) + .collect() + }) + .unwrap() } fn seal(store: &SubjektivStore) -> PermissionTransferReceipt { let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); - store.seal_permission_sources(&identity, "server-request-fingerprint", &preview.export_digest).unwrap() + store + .seal_permission_sources( + &identity, + "server-request-fingerprint", + &preview.export_digest, + ) + .unwrap() } #[test] @@ -55,11 +97,14 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon assert_eq!(identity.feature_id, native.feature); assert_eq!(identity.physical_id, native.physical); let encoded = serde_json::to_value(&identity).unwrap(); - assert_eq!(encoded, serde_json::json!({ - "scope_id": native.scope, - "feature_id": native.feature, - "physical_id": native.physical, - })); + assert_eq!( + encoded, + serde_json::json!({ + "scope_id": native.scope, + "feature_id": native.feature, + "physical_id": native.physical, + }) + ); let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap(); let restored = decoded.storage_identity(); assert_eq!(restored.scope, native.scope); @@ -75,13 +120,36 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon "physical" => wrong.physical_id.push_str("-different"), _ => unreachable!(), } - assert!(matches!(store.preview_permission_transfer(&wrong), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.permission_transfer_transaction::<()>(&wrong, |_| { - panic!("an identity mismatch must not enter the SQL transaction body") - }), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.preview_permission_transfer(&wrong), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.with_permission_authority_guard::<()>(&wrong, |_| { + panic!("an identity mismatch must not enter the guarded action") + }), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.permission_transfer_transaction::<()>(&wrong, |_| { + panic!("an identity mismatch must not enter the SQL transaction body") + }), + Err(SubjektivError::PermissionTransferConflict) + )); } - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); } #[test] @@ -91,48 +159,131 @@ fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_ let unset = store.create_subject(role()).unwrap().id; let empty = store.create_subject(role()).unwrap().id; let limited = store.create_subject(role()).unwrap().id; - store.database.try_transaction::<_, SubjektivError>(|tx| { - tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&unset])?; - Ok(()) - }).unwrap(); + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset], + )?; + Ok(()) + }) + .unwrap(); let (_, empty_source) = replace(&store, &empty, "empty", vec![]); - let (old_update, limited_source) = replace(&store, &limited, "limited", vec![read_permission()]); + let (old_update, limited_source) = + replace(&store, &limited, "limited", vec![read_permission()]); let grant = child(&limited, &limited_source.change_id, "historical-child"); store.bind_worker_execution_grant(&grant).unwrap(); let before = counts(&store); let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); assert_eq!(preview.sources.len(), 3); - assert!(preview.sources.windows(2).all(|pair| pair[0].subject_id < pair[1].subject_id)); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == unset).unwrap().permissions, None); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == empty).unwrap().permissions, Some(empty_source)); - assert_eq!(preview.sources.iter().find(|row| row.subject_id == limited).unwrap().permissions, Some(limited_source.clone())); - assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + assert!( + preview + .sources + .windows(2) + .all(|pair| pair[0].subject_id < pair[1].subject_id) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == unset) + .unwrap() + .permissions, + None + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == empty) + .unwrap() + .permissions, + Some(empty_source) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == limited) + .unwrap() + .permissions, + Some(limited_source.clone()) + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); // Preview is physically read-only, including the domain schema. - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); - let receipt = store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap(); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); + let receipt = store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap(); assert_eq!(receipt.snapshot, preview); assert_eq!(counts(&store), before); - assert_eq!(store.worker_execution_grant("runtime", "historical-child").unwrap(), Some(grant.clone())); + assert_eq!( + store + .worker_execution_grant("runtime", "historical-child") + .unwrap(), + Some(grant.clone()) + ); store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority. - assert!(matches!(store.set_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.set_managed_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.seal_permission_sources(&identity, "different", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.seal_permission_sources(&identity, "transfer", "different-export"), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.set_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.set_managed_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "different", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", "different-export"), + Err(SubjektivError::PermissionTransferConflict) + )); let mut wrong_identity = identity.clone(); wrong_identity.scope_id = "workspace-b".into(); - assert!(matches!(store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store.preview_permission_transfer(&wrong_identity), Err(SubjektivError::PermissionTransferConflict))); + assert!(matches!( + store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.preview_permission_transfer(&wrong_identity), + Err(SubjektivError::PermissionTransferConflict) + )); drop(store); scope.shutdown().unwrap(); drop(scope); drop(manager); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); assert_eq!(identity, store.database_identity().unwrap()); - assert_eq!(receipt, store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap()); - assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); + assert_eq!( + receipt, + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap() + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); assert_eq!(counts(&store), before); - assert!(matches!(store.subject_permissions(&limited), Err(SubjektivError::AuthorizationDenied(_)))); + assert!(matches!( + store.subject_permissions(&limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); } #[test] @@ -143,13 +294,31 @@ fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); replace(&store, &subject, "old-writer-after-preview", vec![]); - assert!(matches!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); replace(&store, &subject, "still-writable", vec![]); let fresh = store.preview_permission_transfer(&identity).unwrap(); assert_ne!(fresh.export_digest, preview.export_digest); - store.seal_permission_sources(&identity, "new-request", &fresh.export_digest).unwrap(); + store + .seal_permission_sources(&identity, "new-request", &fresh.export_digest) + .unwrap(); } #[test] @@ -159,14 +328,32 @@ fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() { let subject = store.create_subject(role()).unwrap().id; let identity = store.database_identity().unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap(); - store.database.transaction(crate::storage_formats::install_permission_transfer).unwrap(); + store + .database + .transaction(crate::storage_formats::install_permission_transfer) + .unwrap(); store.database.try_with_connection::<_, SubjektivError>(|connection| { connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; Ok(()) }).unwrap(); - assert!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest).is_err()); - assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)).unwrap().is_none()); + assert!( + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .is_err() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)) + .unwrap() + .is_none() + ); replace(&store, &subject, "writer-after-rollback", vec![]); drop(store); scope.shutdown().unwrap(); @@ -182,29 +369,70 @@ struct Canonical { calls: Mutex>, } impl CanonicalPermissionSource for Canonical { - fn subject_permissions(&self, workspace: &str, subject: &str) -> Result> { - self.calls.lock().unwrap().push((workspace.into(), subject.into())); - if workspace != "workspace-a" { return Err(denied("foreign Workspace")); } - self.sources.get(subject).cloned().ok_or_else(|| denied("canonical source unavailable")) + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> Result> { + self.calls + .lock() + .unwrap() + .push((workspace.into(), subject.into())); + if workspace != "workspace-a" { + return Err(denied("foreign Workspace")); + } + self.sources + .get(subject) + .cloned() + .ok_or_else(|| denied("canonical source unavailable")) } } #[test] -fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() { +fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() + { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; let (_, old) = replace(&store, &subject, "old", vec![read_permission()]); let receipt = seal(&store); - assert!(matches!(store.subject_permissions(&subject), Err(SubjektivError::AuthorizationDenied(_)))); - assert!(matches!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), Err(SubjektivError::AuthorizationDenied(_)))); + assert!(matches!( + store.subject_permissions(&subject), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), + Err(SubjektivError::AuthorizationDenied(_)) + )); let before = counts(&store); - let created = store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap(); - assert_eq!(created, store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap()); + let created = store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap(); + assert_eq!( + created, + store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap() + ); let non_idempotent = store.create_subject(role()).unwrap(); - assert!(store.subject_permission_enrollment(&created.id).unwrap().is_some()); - assert!(store.subject_permission_enrollment(&non_idempotent.id).unwrap().is_some()); - assert!(store.subject_permission_enrollment(&subject).unwrap().is_none()); + assert!( + store + .subject_permission_enrollment(&created.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&non_idempotent.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&subject) + .unwrap() + .is_none() + ); assert_eq!(counts(&store), before); store.database.try_with_connection::<_, SubjektivError>(|connection| { for id in [&created.id, &non_idempotent.id] { @@ -215,35 +443,103 @@ fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_ } Ok(()) }).unwrap(); - let canonical = SubjectPermissions { change_id: "canonical-change".into(), execution: vec![], delegation: vec![read_permission()] }; + let canonical = SubjectPermissions { + change_id: "canonical-change".into(), + execution: vec![], + delegation: vec![read_permission()], + }; let provider = Arc::new(Canonical { - sources: BTreeMap::from([(subject.clone(), Some(canonical.clone())), (created.id.clone(), None)]), + sources: BTreeMap::from([ + (subject.clone(), Some(canonical.clone())), + (created.id.clone(), None), + ]), calls: Mutex::new(vec![]), }); let store = store.with_canonical_permission_source(provider.clone()); - assert_eq!(store.subject_permissions(&subject).unwrap(), Some(canonical.clone())); + assert_eq!( + store.subject_permissions(&subject).unwrap(), + Some(canonical.clone()) + ); assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default. assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates. - assert!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")).is_err()); - store.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")).unwrap(); - assert_eq!(store.list_subject_worker_execution_grants(&subject).unwrap(), vec![child(&subject, &canonical.change_id, "canonical-child")]); - assert_eq!(store.worker_execution_grant("runtime", "canonical-child").unwrap(), Some(child(&subject, &canonical.change_id, "canonical-child"))); + assert!( + store + .bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")) + .is_err() + ); + store + .bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")) + .unwrap(); + assert_eq!( + store + .list_subject_worker_execution_grants(&subject) + .unwrap(), + vec![child(&subject, &canonical.change_id, "canonical-child")] + ); + assert_eq!( + store + .worker_execution_grant("runtime", "canonical-child") + .unwrap(), + Some(child(&subject, &canonical.change_id, "canonical-child")) + ); for sql in [ "INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants", "UPDATE canonical_worker_execution_grants SET source_change_id='forged'", "DELETE FROM canonical_worker_execution_grants", ] { - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err()); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err() + ); } - assert!(provider.calls.lock().unwrap().iter().all(|(workspace, id)| workspace == "workspace-a" && [subject.as_str(), created.id.as_str(), non_idempotent.id.as_str()].contains(&id.as_str()))); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); - store.begin_subject_deletion(&subject, "delete-canonical-subject").unwrap(); + assert!( + provider + .calls + .lock() + .unwrap() + .iter() + .all(|(workspace, id)| workspace == "workspace-a" + && [ + subject.as_str(), + created.id.as_str(), + non_idempotent.id.as_str() + ] + .contains(&id.as_str())) + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + store + .begin_subject_deletion(&subject, "delete-canonical-subject") + .unwrap(); let workers = store.deletion_subject_workers(&subject).unwrap(); assert!(workers.contains(&("runtime".into(), "canonical-child".into()))); assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into()))); - assert!(store.finish_subject_deletion(&subject, "delete-canonical-subject").unwrap().completed); - assert!(store.worker_execution_grant("runtime", "canonical-child").is_err()); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); + assert!( + store + .finish_subject_deletion(&subject, "delete-canonical-subject") + .unwrap() + .completed + ); + assert!( + store + .worker_execution_grant("runtime", "canonical-child") + .is_err() + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); } #[test] @@ -255,10 +551,18 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r let receipt = seal(&store); // Reacquire a managed store handle; tests must not infer/open physical storage paths. let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap(); - assert!(old_writer.database.try_transaction::<_, SubjektivError>(|tx| { - tx.execute("UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", [&subject])?; - Ok(()) - }).is_err()); + assert!( + old_writer + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", + [&subject], + )?; + Ok(()) + }) + .is_err() + ); drop(old_writer); for sql in [ "INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions", @@ -278,7 +582,16 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r "UPDATE permission_source_transfer_receipt SET receipt_json='{}'", "DELETE FROM permission_source_transfer_receipt", ] { - assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err(), "{sql}"); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err(), + "{sql}" + ); } store.database.try_transaction::<_, SubjektivError>(|tx| { tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?; @@ -287,8 +600,22 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?; Ok(()) }).unwrap(); - assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); - assert_eq!(store.seal_permission_sources(&receipt.snapshot.identity, &receipt.transfer_id, &receipt.snapshot.export_digest).unwrap(), receipt); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + assert_eq!( + store + .seal_permission_sources( + &receipt.snapshot.identity, + &receipt.transfer_id, + &receipt.snapshot.export_digest + ) + .unwrap(), + receipt + ); } #[test] @@ -304,16 +631,29 @@ fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_expor let start = barrier.clone(); let thread = std::thread::spawn(move || { start.wait(); - writer.set_subject_permissions(&subject, &SubjectPermissionsUpdate { - operation_id: "racing-old-writer".into(), expected_change_id: Some(initial.change_id), execution: vec![], delegation: vec![], - }) + writer.set_subject_permissions( + &subject, + &SubjectPermissionsUpdate { + operation_id: "racing-old-writer".into(), + expected_change_id: Some(initial.change_id), + execution: vec![], + delegation: vec![], + }, + ) }); barrier.wait(); let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest); let written = thread.join().unwrap(); match (sealed, written) { - (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => assert_eq!(receipt.snapshot, preview), - (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(!store.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()), + (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => { + assert_eq!(receipt.snapshot, preview) + } + (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(is_sealed) + .unwrap() + ), other => panic!("unexpected serialized outcomes: {other:?}"), } } @@ -325,20 +665,241 @@ fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() let subject = store.create_subject(role()).unwrap().id; seal(&store); let provider = Arc::new(Canonical { - sources: BTreeMap::from([(subject.clone(), Some(SubjectPermissions { - change_id: "foreign-source".into(), - execution: vec![ScopedPermission { - operation: "file.read".into(), - resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() }, - }], - delegation: vec![read_permission()], - }))]), + sources: BTreeMap::from([( + subject.clone(), + Some(SubjectPermissions { + change_id: "foreign-source".into(), + execution: vec![ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workspace { + workspace_id: "workspace-b".into(), + }, + }], + delegation: vec![read_permission()], + }), + )]), calls: Mutex::new(vec![]), }); let store = store.with_canonical_permission_source(provider); assert!(store.subject_permissions(&subject).is_err()); - assert!(store.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")).is_err()); - assert!(store.worker_execution_grant("runtime", "foreign-child").unwrap().is_none()); + assert!( + store + .bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")) + .is_err() + ); + assert!( + store + .worker_execution_grant("runtime", "foreign-child") + .unwrap() + .is_none() + ); +} + +#[test] +fn authority_guard_gates_legacy_callback_and_releases_an_unsealed_transaction() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let calls = std::cell::Cell::new(0); + let legacy_write = || { + store.with_permission_authority_guard(&identity, |guard| { + assert!( + !guard.transaction.borrow().as_ref().unwrap().is_autocommit(), + "the Feature transaction must already be active on callback entry" + ); + guard.require_unsealed()?; + guard.require_subject(&subject)?; + // Stand-in for a logical Server write/commit. Never reenter Feature storage here. + calls.set(calls.get() + 1); + Ok(()) + }) + }; + legacy_write().unwrap(); + assert_eq!(calls.get(), 1); + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + assert!(connection.is_autocommit()); + assert!(!has_table( + connection, + "permission_source_transfer_receipt" + )?); + assert!(!is_sealed(connection)?); + Ok(()) + }) + .unwrap(); + // A logical write does not mutate Feature, and an early callback failure does not seal. + let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| { + guard.require_unsealed()?; + Err(SubjektivError::InvalidRecord("callback before seal".into())) + }); + assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_)))); + replace(&store, &subject, "writer-after-guard-return", vec![]); + seal(&store); + assert!(matches!( + legacy_write(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(calls.get(), 1, "a sealed legacy callback must not execute"); +} + +#[test] +fn authority_guard_metadata_checks_do_not_call_canonical_source_and_honor_deletion() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let deleting = store.create_subject(role()).unwrap().id; + seal(&store); + store + .begin_subject_deletion(&deleting, "deleting-before-guard") + .unwrap(); + let provider = Arc::new(Canonical { + sources: BTreeMap::new(), // Any provider call would fail, and could reenter Server. + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider.clone()); + let identity = store.database_identity().unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; // Metadata still exists even when authority is sealed. + assert!(matches!( + guard.require_subject("missing-subject"), + Err(SubjektivError::SubjectNotFound(_)) + )); + assert!(matches!( + guard.require_subject(&deleting), + Err(SubjektivError::SubjectNotFound(_)) + )); + assert!(matches!( + guard.require_unsealed(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + Ok(()) + }) + .unwrap(); + assert!(provider.calls.lock().unwrap().is_empty()); +} + +#[test] +fn authority_guard_seal_survives_callback_failure_and_exact_retry_preserves_server_error() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + let before = counts(&store); + let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; + let receipt = + guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?; + assert_eq!(receipt.snapshot, preview); + assert!( + guard.transaction.borrow().is_none(), + "seal must end the Feature transaction before adoption" + ); + assert!(matches!( + guard.require_subject(&subject), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + guard.require_unsealed(), + Err(SubjektivError::PermissionAuthoritySealed) + )); + Err(SubjektivError::InvalidRecord( + "callback after committed seal".into(), + )) + }); + assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_)))); + let receipt = store + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!(receipt.transfer_id, "server-fingerprint"); + assert_eq!(receipt.snapshot, preview); + assert_eq!(counts(&store), before); + let nested: Result> = + store.with_permission_authority_guard(&identity, |guard| { + guard.require_subject(&subject)?; + assert_eq!( + guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?, + receipt + ); + assert!( + guard.transaction.borrow().is_none(), + "receipt retry must also end its transaction" + ); + // Preserve the Server error/status independently of the Feature result. + Ok(Err(409)) + }); + assert!(matches!(nested, Ok(Err(409)))); + assert_eq!( + store.permission_transfer_receipt(&identity).unwrap(), + Some(receipt) + ); + for (transfer, digest) in [ + ("another-request", preview.export_digest.as_str()), + ("server-fingerprint", "changed-export"), + ] { + assert!(matches!( + store.with_permission_authority_guard(&identity, |guard| { + guard.seal_permission_sources(transfer, digest) + }), + Err(SubjektivError::PermissionTransferConflict) + )); + } + assert_eq!(counts(&store), before); +} + +#[test] +fn authority_guard_seal_sql_failure_rolls_back_before_callback_returns() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + store.create_subject(role()).unwrap(); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store + .database + .transaction(crate::storage_formats::install_permission_transfer) + .unwrap(); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + connection.execute_batch("CREATE TEMP TRIGGER fail_guard_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected guarded receipt failure'); END;")?; + Ok(()) + }).unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + assert!( + guard + .seal_permission_sources("transfer", &preview.export_digest) + .is_err() + ); + assert!(guard.transaction.borrow().is_none()); + // Failure consumes the transaction; a caught error must not enable an unfenced writer. + assert!(guard.require_unsealed().is_err()); + assert!( + guard + .seal_permission_sources("transfer", &preview.export_digest) + .is_err() + ); + Ok(()) + }) + .unwrap(); + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + assert!(connection.is_autocommit()); + assert!(!is_sealed(connection)?); + assert!(retained_receipt(connection)?.is_none()); + connection.execute_batch("DROP TRIGGER fail_guard_transfer")?; + Ok(()) + }) + .unwrap(); + store + .with_permission_authority_guard(&identity, |guard| { + guard.require_unsealed()?; + guard.seal_permission_sources("transfer", &preview.export_digest) + }) + .unwrap(); } #[test] @@ -351,7 +912,24 @@ fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_works let b = store_b.database_identity().unwrap(); assert_ne!(a, b); let preview = store_a.preview_permission_transfer(&a).unwrap(); - assert!(matches!(store_b.preview_permission_transfer(&a), Err(SubjektivError::PermissionTransferConflict))); - assert!(matches!(store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); - assert!(!store_b.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()); + assert!(matches!( + store_b.preview_permission_transfer(&a), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store_b.with_permission_authority_guard::<()>(&a, |_| { + panic!("a different physical database must not enter the guarded action") + }), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!( + !store_b + .database + .try_with_connection::<_, SubjektivError>(is_sealed) + .unwrap() + ); } diff --git a/crates/subjektiv/src/storage_format_tests.rs b/crates/subjektiv/src/storage_format_tests.rs index 229180918..e46933b2c 100644 --- a/crates/subjektiv/src/storage_format_tests.rs +++ b/crates/subjektiv/src/storage_format_tests.rs @@ -586,46 +586,96 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab let temp = tempfile::tempdir().unwrap(); published_fixture(temp.path(), format); let (scope, db) = open(temp.path(), REGISTRATION); - let saved = db.with_connection(|connection| Ok(receipts(connection))).unwrap(); - let old_seal = db.with_connection(|connection| { - if format == Format::Permission18 { - Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?)) - } else { Ok(None) } - }).unwrap(); + let saved = db + .with_connection(|connection| Ok(receipts(connection))) + .unwrap(); + let old_seal = db + .with_connection(|connection| { + if format == Format::Permission18 { + Ok(Some(connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0), + )?)) + } else { + Ok(None) + } + }) + .unwrap(); db.transaction(install_permission_transfer).unwrap(); db.with_connection(|connection| { assert_eq!(receipts(connection), saved); - assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); if let Some(raw) = &old_seal { - assert_eq!(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?, *raw); + assert_eq!( + connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0) + )?, + *raw + ); } assert_foreign_keys(connection); Ok(()) - }).unwrap(); + }) + .unwrap(); let store = SubjektivStore::from_database(db.clone()).unwrap(); if old_seal.is_some() { let identity = store.database_identity().unwrap(); - assert!(matches!(store.preview_permission_transfer(&identity), Err(crate::SubjektivError::PermissionAuthoritySealed))); - assert!(matches!(store.seal_permission_sources(&identity, "new-transfer", "export"), Err(crate::SubjektivError::PermissionAuthoritySealed))); + assert!(matches!( + store.preview_permission_transfer(&identity), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "new-transfer", "export"), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); struct CanonicalEmpty; impl crate::CanonicalPermissionSource for CanonicalEmpty { - fn subject_permissions(&self, workspace: &str, _subject: &str) -> crate::Result> { + fn subject_permissions( + &self, + workspace: &str, + _subject: &str, + ) -> crate::Result> + { assert_eq!(workspace, SCOPE); Ok(Some(server_api::authorization::SubjectPermissions { - change_id: "canonical-source".into(), execution: vec![], delegation: vec![], + change_id: "canonical-source".into(), + execution: vec![], + delegation: vec![], })) } } - let connected = store.clone().with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); + let connected = store + .clone() + .with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); let subject = connected.list_subjects(1).unwrap().items[0].id.clone(); - assert_eq!(connected.subject_permissions(&subject).unwrap().unwrap().change_id, "canonical-source"); - connected.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { - subject_id: subject, source_change_id: "canonical-source".into(), - runtime_id: "runtime".into(), worker_id: "canonical-child".into(), - origin_runtime_id: "runtime".into(), origin_worker_id: "root".into(), - profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), - permissions: vec![], redelegation: vec![], - }).unwrap(); + assert_eq!( + connected + .subject_permissions(&subject) + .unwrap() + .unwrap() + .change_id, + "canonical-source" + ); + connected + .bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { + subject_id: subject, + source_change_id: "canonical-source".into(), + runtime_id: "runtime".into(), + worker_id: "canonical-child".into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![], + redelegation: vec![], + }) + .unwrap(); } drop(store); drop(db); @@ -633,10 +683,14 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab let (scope, db) = open(temp.path(), REGISTRATION); db.with_connection(|connection| { assert_eq!(receipts(connection), saved); - assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); assert_foreign_keys(connection); Ok(()) - }).unwrap(); + }) + .unwrap(); drop(db); scope.shutdown().unwrap(); } @@ -647,7 +701,9 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope let temp = tempfile::tempdir().unwrap(); let (scope, db) = open(temp.path(), REGISTRATION); let before_shape = db.with_connection(domain_shape).unwrap(); - let before_rows = db.with_connection(|connection| Ok(all_rows(connection))).unwrap(); + let before_rows = db + .with_connection(|connection| Ok(all_rows(connection))) + .unwrap(); let result = db.transaction(|tx| { install_permission_transfer(tx)?; tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?; @@ -658,16 +714,24 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope assert_eq!(domain_shape(connection)?, before_shape); assert_eq!(all_rows(connection), before_rows); Ok(()) - }).unwrap(); + }) + .unwrap(); db.transaction(install_permission_transfer).unwrap(); db.with_connection(|connection| { - connection.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; + connection + .execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; Ok(()) - }).unwrap(); + }) + .unwrap(); drop(db); scope.shutdown().unwrap(); - let scope = feature_storage::FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let scope = feature_storage::FeatureStorage::new(temp.path()) + .scope(SCOPE) + .unwrap(); let registered = scope.register(REGISTRATION).unwrap(); - assert!(matches!(scope.open(®istered), Err(FeatureStorageError::MigrationHistory { .. }))); + assert!(matches!( + scope.open(®istered), + Err(FeatureStorageError::MigrationHistory { .. }) + )); scope.shutdown().unwrap(); } diff --git a/crates/subjektiv/src/storage_formats.rs b/crates/subjektiv/src/storage_formats.rs index 3f1c39643..f6991a15f 100644 --- a/crates/subjektiv/src/storage_formats.rs +++ b/crates/subjektiv/src/storage_formats.rs @@ -86,7 +86,9 @@ pub(super) fn validate( [], |row| row.get::<_, bool>(0), )?; if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) { - return Err(incompatible("permission transfer requires complete common 1..17")); + return Err(incompatible( + "permission transfer requires complete common 1..17", + )); } let expected = reference_shape_with_transfer(format, transfer)?; let actual = domain_shape(tx)?; @@ -127,7 +129,10 @@ fn reference_shape(format: Format) -> feature_storage::Result> reference_shape_with_transfer(format, false) } -fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_storage::Result> { +fn reference_shape_with_transfer( + format: Format, + transfer: bool, +) -> feature_storage::Result> { // Cache reference shapes only; every target is reread under its caller's transaction. static SHAPES: [OnceLock>; 50] = [const { OnceLock::new() }; 50]; let index = match format { @@ -208,8 +213,11 @@ fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_stor /// Existing published seals and their serialized exports are never rewritten or relabelled. /// The FeatureDatabase guard and caller's transaction cover validation and installation. pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> { - let mut statement = tx.prepare(&format!("SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"))?; - let applied = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + let mut statement = tx.prepare(&format!( + "SELECT version,name FROM {HISTORY_TABLE} ORDER BY version" + ))?; + let applied = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::, _>>()?; let format = recognize(&applied)?; if matches!(format, Format::Common(count) if count < COMMON_COUNT) { @@ -226,7 +234,10 @@ pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_stora validate(tx, &applied) } -fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> feature_storage::Result<()> { +fn install_transfer_shape( + tx: &Transaction<'_>, + published_permission: bool, +) -> feature_storage::Result<()> { if !published_permission { tx.execute_batch(PERMISSION_SEAL_SQL)?; } @@ -237,7 +248,9 @@ fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> f for action in ["INSERT", "UPDATE", "DELETE"] { let deletion_exception = if action == "DELETE" { " AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)" - } else { "" }; + } else { + "" + }; tx.execute_batch(&format!( "CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table} WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception} diff --git a/crates/subjektiv/src/subject_creation.rs b/crates/subjektiv/src/subject_creation.rs index 3ba7399df..388eb1fed 100644 --- a/crates/subjektiv/src/subject_creation.rs +++ b/crates/subjektiv/src/subject_creation.rs @@ -21,7 +21,10 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { impl SubjektivStore { /// Creation-only enrollment evidence for the canonical domain. Never a current permission /// source or a read-time default; the Host must not apply it to an existing/unset policy. - pub fn subject_permission_enrollment(&self, subject_id: &str) -> Result> { + pub fn subject_permission_enrollment( + &self, + subject_id: &str, + ) -> Result> { self.database.try_transaction(|tx| { require_subject(tx, subject_id)?; if !permissions::transfer::has_table(tx, "permission_authority_seal")? { diff --git a/crates/worker/src/feature/builtin/permission_catalog.rs b/crates/worker/src/feature/builtin/permission_catalog.rs index 2a026c1af..16d952cbb 100644 --- a/crates/worker/src/feature/builtin/permission_catalog.rs +++ b/crates/worker/src/feature/builtin/permission_catalog.rs @@ -6,54 +6,102 @@ //! exact names. No dotted-prefix children are inferred. Profiles/provider scopes //! and actual operation authorization remain independent of this metadata load. -use std::collections::BTreeMap; -use feature_permissions::{FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, - PermissionDefinition, PermissionHost, PermissionKey, PermissionReference, PermissionSetId, - SetContribution, WorkspaceId}; use super::super::FeatureInstallError; +use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, PermissionDefinition, + PermissionHost, PermissionKey, PermissionReference, PermissionSetId, SetContribution, + WorkspaceId, +}; +use std::collections::BTreeMap; const OWNERS: &[(&str, &str)] = &[ - ("filesystem", "builtin:workdir"), ("bash", "builtin:bash"), - ("workdir", "builtin:workdir"), ("worker", "builtin:worker"), - ("subworker", "builtin:sub-worker-control"), ("ticket", "builtin:ticket"), - ("objective", "builtin:objective"), ("merge_request", "builtin:merge-request"), - ("repository", "builtin:repository"), ("drive", "builtin:drive"), - ("workspace_config", "builtin:workspace-config"), ("memory", "builtin:memory"), - ("journal", "builtin:journal-recording"), ("chat", "builtin:subjektiv-chat"), - ("session", "builtin:session-explore"), ("web", "builtin:web"), + ("filesystem", "builtin:workdir"), + ("bash", "builtin:bash"), + ("workdir", "builtin:workdir"), + ("worker", "builtin:worker"), + ("subworker", "builtin:sub-worker-control"), + ("ticket", "builtin:ticket"), + ("objective", "builtin:objective"), + ("merge_request", "builtin:merge-request"), + ("repository", "builtin:repository"), + ("drive", "builtin:drive"), + ("workspace_config", "builtin:workspace-config"), + ("memory", "builtin:memory"), + ("journal", "builtin:journal-recording"), + ("chat", "builtin:subjektiv-chat"), + ("session", "builtin:session-explore"), + ("web", "builtin:web"), ("notification", "builtin:notification"), ]; -pub fn builtin_permission_host(workspace: WorkspaceId) -> Result { +pub fn builtin_permission_host( + workspace: WorkspaceId, +) -> Result { let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string()); let mut host = PermissionHost::new(workspace); let mut features = BTreeMap::new(); - let operations = server_api::authorization::subject_permission_operations(); + // MCP's scalar startup permission is the retained T-787 exception, not a + // payload-free operation key. Do not invent mcp.* authority or a Set for it. + // Legacy migration retains it as an explicit-configuration-required diagnostic. + let operations = server_api::authorization::subject_permission_operations() + .into_iter() + .filter(|operation| operation.operation != "mcp") + .collect::>(); for (namespace, owner) in OWNERS { let feature = match features.get(owner) { Some(feature) => feature, None => { - let feature = host.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?).map_err(|e| invalid(&e))?; + let feature = host + .register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?) + .map_err(|e| invalid(&e))?; features.entry(owner).or_insert(feature) - }, + } }; let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?; - let handle = host.bind_namespace(feature, id.clone()).map_err(|e| invalid(&e))?; - let permissions = operations.iter().filter(|operation| operation.operation.split('.').next() == Some(namespace)) - .map(|operation| Ok(PermissionDefinition { - key:PermissionKey::parse(operation.operation.clone()).map_err(|e| invalid(&e))?, - description:operation.description.clone(),children:vec![], - })).collect::,FeatureInstallError>>()?; - let keys = permissions.iter().map(|definition| definition.key.clone()).collect(); - host.declare(&handle,NamespaceDeclaration { - permissions, - sets:vec![SetContribution { set:PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:id,keys})] }], - }).map_err(|e|invalid(&e))?; + let handle = host + .bind_namespace(feature, id.clone()) + .map_err(|e| invalid(&e))?; + let permissions = operations + .iter() + .filter(|operation| operation.operation.split('.').next() == Some(namespace)) + .map(|operation| { + Ok(PermissionDefinition { + key: PermissionKey::parse(operation.operation.clone()) + .map_err(|e| invalid(&e))?, + description: operation.description.clone(), + children: vec![], + }) + }) + .collect::, FeatureInstallError>>()?; + let keys = permissions + .iter() + .map(|definition| definition.key.clone()) + .collect(); + host.declare( + &handle, + NamespaceDeclaration { + permissions, + sets: vec![SetContribution { + set: PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: id, + keys, + })], + }], + }, + ) + .map_err(|e| invalid(&e))?; } // A new builtin operation needs an intentional namespace ownership decision. for operation in operations { - if !OWNERS.iter().any(|(namespace,_)| operation.operation.split('.').next() == Some(*namespace)) { - return Err(FeatureInstallError::InvalidDescriptor(format!("builtin operation has no namespace owner: {}",operation.operation))); + if !OWNERS + .iter() + .any(|(namespace, _)| operation.operation.split('.').next() == Some(*namespace)) + { + return Err(FeatureInstallError::InvalidDescriptor(format!( + "builtin operation has no namespace owner: {}", + operation.operation + ))); } } Ok(host) @@ -64,15 +112,23 @@ mod tests { use super::*; #[test] fn builtin_keys_are_complete_exact_and_have_no_inferred_children() { - let host=builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); - let catalog=host.catalog(); - let mut keys=std::collections::BTreeSet::new(); + let host = builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); + let catalog = host.catalog(); + let mut keys = std::collections::BTreeSet::new(); for namespace in catalog.namespaces { for permission in namespace.declaration.permissions { assert!(permission.children.is_empty()); assert!(keys.insert(permission.key.as_str().to_owned())); } } - assert_eq!(keys,server_api::authorization::subject_permission_operations().into_iter().map(|o|o.operation).collect()); + assert!(!keys.contains("mcp")); + assert_eq!( + keys, + server_api::authorization::subject_permission_operations() + .into_iter() + .filter(|operation| operation.operation != "mcp") + .map(|o| o.operation) + .collect() + ); } } diff --git a/crates/workspace-server/README.md b/crates/workspace-server/README.md index bbffda327..28c7c0e09 100644 --- a/crates/workspace-server/README.md +++ b/crates/workspace-server/README.md @@ -9,3 +9,4 @@ Backend that manages persistent Workspace resources and authorization and provid - [`hosts`] / [`worker_source`] — Runtime delegation and request source verification - [`backend_job`] — Immutable input, attempt, and structured result boundaries - [`workspace_catalog`] — Workspace creation and replay contracts +- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments diff --git a/crates/workspace-server/src/config_adoption.rs b/crates/workspace-server/src/config_adoption.rs index 7d2673f89..018bec136 100644 --- a/crates/workspace-server/src/config_adoption.rs +++ b/crates/workspace-server/src/config_adoption.rs @@ -5,6 +5,53 @@ //! after seal leaves an explicit, resumable transfer, never a legacy fallback. //! Normal saves never touch the old database. Object preparation, source, //! projection, policy, editing head and receipt commit in one caller-owned transaction. +//! +//! # Editing and reconciliation +//! +//! Owner authentication precedes all source, preview, adoption and reconciliation +//! operations. The public transport contract belongs to +//! [`server_api::configuration_adoption`]. It extends the existing virtual tree; +//! imports resolve within one snapshot and no host filesystem path grants access. +//! Before cutover, retrieval returns an evaluated editable migration draft, not a +//! saved change. Preview is read-only and reports changed paths and Subjects. +//! +//! Content identity and editing history are distinct. Adoption compares the +//! observed history head as well as the source digest, so A -> B -> A cannot +//! authorize an old editor. The request fingerprint binds Workspace, authenticated +//! owner and the entire request (including catalog, export, head and changes). +//! Reuse of an operation ID with different input is a conflict. Exact replay +//! returns historical evidence before stale-head checks; it never reapplies policy. +//! +//! After a lost response, retain the full request and reconcile it with that same +//! ID. An adopted receipt proves that request's stored result, not today's head. +//! A pending transfer proves the immutable legacy seal, not Server adoption. +//! Absence of either is not evidence of failure or permission to generate a new +//! ID. Retry the exact request; read current source before a distinct edit. +//! +//! # Cutover boundary +//! +//! Initial cutover and legacy logical config writes acquire the guarded Feature +//! authority transaction before Server storage. Sealing commits independently; +//! the outer Feature connection guard remains held through the Server result. +//! After seal, both old policy writes and old logical source writes reject, +//! including when Server persistence rolls back. Reopen/retry uses the same +//! identity-bound export and request. No in-memory mutex is the durable fence. +//! Validation (including blocking migration diagnostics) precedes seal. Failure +//! there retains legacy authority and the previous source/policy/head/receipt. +//! A failure after seal retains the old Server source but fails closed for runtime +//! reads until the same transfer is adopted. This is not a distributed atomic +//! commit or a guarantee for arbitrary machine loss at every instruction. +//! +//! Existing explicit permissions are never automatically merged or replaced, +//! even when empty. Unsupported operations or child-expanding conversions block +//! adoption rather than dropping entries. Exact limited legacy constraints and +//! change identities remain in the domain, separate from editable permission keys. +//! History and child grants are retained, not regenerated as new authorizations. +//! +//! A receipt separately confirms source storage and policy commit. Its typed +//! runtime application means the next current-policy check, not interruption, +//! reload or termination of an already-running Worker. Legacy consumers cannot +//! turn a new unrepresented key into an all-target grant. use std::collections::{BTreeMap, BTreeSet}; @@ -78,13 +125,26 @@ pub(crate) struct CanonicalConfigurationSource { } impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource { - fn subject_permissions(&self, workspace: &str, subject: &str) -> subjektiv::Result> { + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> subjektiv::Result> { if self.host.catalog().workspace.as_str() != workspace { return Err(subjektiv::SubjektivError::PermissionTransferConflict); } - self.store.adopted_subject_policy(workspace, subject) - .and_then(|policy| policy.map(|policy| policy.legacy_read(&self.host)).transpose()) - .map_err(|error| subjektiv::SubjektivError::InvalidRecord(format!("canonical configuration policy unavailable: {error}"))) + self.store + .adopted_subject_policy(workspace, subject) + .and_then(|policy| { + policy + .map(|policy| policy.legacy_read(&self.host)) + .transpose() + }) + .map_err(|error| { + subjektiv::SubjektivError::InvalidRecord(format!( + "canonical configuration policy unavailable: {error}" + )) + }) } } @@ -106,7 +166,8 @@ pub(crate) struct ConfigurationMigrationBaseline<'a> { struct OwnerScope<'a>(&'a str); impl Authority for OwnerScope<'_> { fn allows(&self, key: &ScopeKey, access: Access) -> bool { - key.workspace == self.0 && key.target == "configuration" + key.workspace == self.0 + && key.target == "configuration" && matches!(access, Access::Read | Access::Write) } } @@ -124,75 +185,137 @@ fn decode(value: &str) -> Result { serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string())) } fn scope(workspace: &str) -> ScopeKey { - ScopeKey { workspace: workspace.into(), target: "configuration".into() } + ScopeKey { + workspace: workspace.into(), + target: "configuration".into(), + } } fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> { let allowed: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", params![workspace, account], |r| r.get(0), )?; - if !allowed { return Err(Error::WorkspacePermissionDenied("configuration management requires the Workspace owner".into())); } + if !allowed { + return Err(Error::WorkspacePermissionDenied( + "configuration management requires the Workspace owner".into(), + )); + } Ok(()) } pub(crate) fn head(conn: &Connection, workspace: &str) -> Result> { - let bytes: Option> = conn.query_row( - "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", [workspace], |r| r.get(0), - ).optional()?.flatten(); - bytes.map(|bytes| bytes.try_into().map(HistoryId::from_bytes) - .map_err(|_| Error::RegistryInconsistency("invalid configuration editing head".into()))).transpose() + let bytes: Option> = conn + .query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", + [workspace], + |r| r.get(0), + ) + .optional()? + .flatten(); + bytes + .map(|bytes| { + bytes.try_into().map(HistoryId::from_bytes).map_err(|_| { + Error::RegistryInconsistency("invalid configuration editing head".into()) + }) + }) + .transpose() } -pub(crate) fn request_fingerprint(workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result { - server_api::authorization::validate_authorization_identifier(&request.operation_id).map_err(Error::InvalidInput)?; - Ok(config_source::digest_bytes(json(&(workspace, account, request))?.as_bytes())) +pub(crate) fn request_fingerprint( + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, +) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id) + .map_err(Error::InvalidInput)?; + Ok(config_source::digest_bytes( + json(&(workspace, account, request))?.as_bytes(), + )) } pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result { - Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |row| row.get(0))?) + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", + [workspace], + |row| row.get(0), + )?) } pub fn catalog_digest(host: &PermissionHost) -> Result { - Ok(config_source::digest_bytes(json(&host.catalog())?.as_bytes())) + Ok(config_source::digest_bytes( + json(&host.catalog())?.as_bytes(), + )) } fn policies(conn: &Connection, workspace: &str) -> Result> { let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?; let mut result = BTreeMap::new(); - for row in query.query_map([workspace], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? { + for row in query.query_map([workspace], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? { let (subject, raw) = row?; let policy: AdoptedSubjectPolicy = decode(&raw)?; - if subject != policy.source.subject_id { return Err(Error::RegistryInconsistency("Subject policy identity mismatch".into())); } + if subject != policy.source.subject_id { + return Err(Error::RegistryInconsistency( + "Subject policy identity mismatch".into(), + )); + } result.insert(subject, policy); } Ok(result) } -fn receipt(conn: &Connection, workspace: &str, operation: &str) -> Result> { +fn receipt( + conn: &Connection, + workspace: &str, + operation: &str, +) -> Result> { let row: Option<(String,String)> = conn.query_row( "SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2", params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)), ).optional()?; - row.map(|(fingerprint,result)| Ok((fingerprint,decode(&result)?))).transpose() + row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?))) + .transpose() } fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec { - before.entries.keys().chain(after.entries.keys()).collect::>().into_iter() + before + .entries + .keys() + .chain(after.entries.keys()) + .collect::>() + .into_iter() .filter(|path| before.entries.get(*path) != after.entries.get(*path)) - .map(|path| path.as_str().to_owned()).collect() + .map(|path| path.as_str().to_owned()) + .collect() } -fn changed_subjects(before: &BTreeMap, after: &BTreeMap) -> Vec { - before.keys().chain(after.keys()).collect::>().into_iter() - .filter(|subject| before.get(*subject) != after.get(*subject)).cloned().collect() +fn changed_subjects( + before: &BTreeMap, + after: &BTreeMap, +) -> Vec { + before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter(|subject| before.get(*subject) != after.get(*subject)) + .cloned() + .collect() } impl SqliteWorkspaceStore { - pub(crate) fn configuration_current(&self, workspace: &str, account: &str) -> Result { + pub(crate) fn configuration_current( + &self, + workspace: &str, + account: &str, + ) -> Result { self.with_conn(|conn| { let tx = conn.unchecked_transaction()?; require_owner(&tx, workspace, account)?; Ok(ConfigurationCurrent { - state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?, + state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| { + Error::InvalidInput("Workspace source not materialized".into()) + })?, history_head: head(&tx, workspace)?.map(|id| id.to_string()), canonical: canonical(&tx, workspace)?, policies: policies(&tx, workspace)?, @@ -200,7 +323,12 @@ impl SqliteWorkspaceStore { }) } - pub(crate) fn configuration_request_result(&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result> { + pub(crate) fn configuration_request_result( + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + ) -> Result> { let fingerprint = request_fingerprint(workspace, account, request)?; self.with_conn(|conn| { require_owner(conn, workspace, account)?; @@ -214,31 +342,58 @@ impl SqliteWorkspaceStore { /// Authentication supplies `account`; it is never accepted from an adoption DTO. pub fn configuration_head(&self, workspace: &str, account: &str) -> Result> { - self.with_conn(|conn| { require_owner(conn, workspace, account)?; Ok(head(conn,workspace)?.map(|id|id.to_string())) }) + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(head(conn, workspace)?.map(|id| id.to_string())) + }) } /// Result lookup is a read. A historical receipt never reactivates policy. - pub fn configuration_adoption_result(&self, workspace: &str, account: &str, operation: &str) -> Result> { - self.with_conn(|conn| { require_owner(conn,workspace,account)?; Ok(receipt(conn,workspace,operation)?.map(|(_,result)|result)) }) + pub fn configuration_adoption_result( + &self, + workspace: &str, + account: &str, + operation: &str, + ) -> Result> { + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result)) + }) } pub(crate) fn preview_configuration_adoption( - &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, mut require_subject: impl FnMut(&str) -> Result<()>, ) -> Result { self.with_conn(|conn| { - require_owner(conn,workspace,account)?; + require_owner(conn, workspace, account)?; let tx = conn.unchecked_transaction()?; - let (current,state,source) = prepare(&tx,workspace,request,schema,host,baseline,&mut require_subject)?; - let before = match baseline { Some(baseline) => baseline.policies.clone(), None => policies(&tx,workspace)? }; + let (current, state, source) = prepare( + &tx, + workspace, + request, + schema, + host, + baseline, + &mut require_subject, + )?; + let before = match baseline { + Some(baseline) => baseline.policies.clone(), + None => policies(&tx, workspace)?, + }; let mut after = before.clone(); - policy_config::apply(&mut after,&source,"preview"); + policy_config::apply(&mut after, &source, "preview"); Ok(ConfigurationPreview { expected_head: request.expected_head.clone(), - changed_paths: changed_paths(¤t.snapshot,&state.snapshot), - changed_subjects: changed_subjects(&before,&after), state, + changed_paths: changed_paths(¤t.snapshot, &state.snapshot), + changed_subjects: changed_subjects(&before, &after), + state, }) }) } @@ -247,8 +402,12 @@ impl SqliteWorkspaceStore { /// current-head checks. If subsequent Server persistence fails, that specific /// transfer remains pending; callers reconcile/retry the same request, not a new ID. pub(crate) fn adopt_configuration( - &self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, mut require_subject: impl FnMut(&str) -> Result<()>, mut prepare_transfer: impl FnMut(&str) -> Result, @@ -301,7 +460,11 @@ impl SqliteWorkspaceStore { }) } - pub fn adopted_subject_policy(&self, workspace: &str, subject: &str) -> Result> { + pub fn adopted_subject_policy( + &self, + workspace: &str, + subject: &str, + ) -> Result> { self.with_conn(|conn| { let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?; if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); } @@ -312,39 +475,68 @@ impl SqliteWorkspaceStore { } fn prepare( - tx: &Transaction<'_>, workspace: &str, request: &ConfigurationAdoptionRequest, - schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, + tx: &Transaction<'_>, + workspace: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, baseline: Option<&ConfigurationMigrationBaseline<'_>>, require_subject: &mut impl FnMut(&str) -> Result<()>, -) -> Result<(WorkspaceConfigState,WorkspaceConfigState,PermissionSource)> { +) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> { if let Some(baseline) = baseline { - if baseline.diagnostics.iter().any(|diagnostic| matches!(diagnostic, policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. })) { + if baseline.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic, + policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. } + ) + }) { return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into())); } } if host.catalog().workspace.as_str() != workspace { - return Err(Error::WorkspacePermissionDenied("permission registry belongs to another Workspace".into())); + return Err(Error::WorkspacePermissionDenied( + "permission registry belongs to another Workspace".into(), + )); } - if catalog_digest(host)? != request.expected_catalog_digest { return Err(conflict("Feature catalog changed")); } - if head(tx,workspace)?.map(|id|id.to_string()) != request.expected_head { return Err(conflict("editing history head changed")); } - let current = crate::config_source::load_state(tx,workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; + if catalog_digest(host)? != request.expected_catalog_digest { + return Err(conflict("Feature catalog changed")); + } + if head(tx, workspace)?.map(|id| id.to_string()) != request.expected_head { + return Err(conflict("editing history head changed")); + } + let current = crate::config_source::load_state(tx, workspace)? + .ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; let editing_base = if let Some(baseline) = baseline { - if *baseline.original != current || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) { + if *baseline.original != current + || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) + { return Err(conflict("migration source or export changed")); } baseline.state - } else { ¤t }; - if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); } + } else { + ¤t + }; + if editing_base.snapshot.digest != request.source.base_digest { + return Err(conflict("source content changed")); + } crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?; let candidate = crate::config_source::evaluate_candidate_in_transaction( - tx,workspace,editing_base.clone(),crate::config_source::load_evaluation_identity(tx,workspace)?, - &request.source.changes,schema, + tx, + workspace, + editing_base.clone(), + crate::config_source::load_evaluation_identity(tx, workspace)?, + &request.source.changes, + schema, )?; - crate::config_source::validate_workspace_config_candidate_projections(workspace,&candidate)?; + crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?; let source = policy_config::source(&candidate.evaluation)?; - policy_config::validate_source(host,&source,require_subject)?; - let state = WorkspaceConfigState { snapshot:candidate.snapshot,contract:candidate.contract,projection_digest:candidate.evaluation.projection_digest }; - Ok((current,state,source)) + policy_config::validate_source(host, &source, require_subject)?; + let state = WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }; + Ok((current, state, source)) } #[cfg(test)] diff --git a/crates/workspace-server/src/config_adoption_tests.rs b/crates/workspace-server/src/config_adoption_tests.rs index 502ed7fad..379be9446 100644 --- a/crates/workspace-server/src/config_adoption_tests.rs +++ b/crates/workspace-server/src/config_adoption_tests.rs @@ -12,24 +12,55 @@ const OWNER: &str = "adoption-owner"; fn host(children: bool) -> PermissionHost { let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); - let feature = host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()).unwrap(); - let namespace = host.bind_namespace(&feature,NamespaceId::parse("ticket").unwrap()).unwrap(); - host.declare(&namespace, NamespaceDeclaration { - permissions: vec![ - PermissionDefinition { key: PermissionKey::parse("ticket.hoge").unwrap(), description:String::new(), children: if children {vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]} else {vec![]} }, - PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] }, - ], - sets: vec![SetContribution { set:PermissionSetId::parse("ticket").unwrap(),children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:NamespaceId::parse("ticket").unwrap(),keys:vec![PermissionKey::parse("ticket.hoge").unwrap()]})] }], - }).unwrap(); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("ticket").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![SetContribution { + set: PermissionSetId::parse("ticket").unwrap(), + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse("ticket").unwrap(), + keys: vec![PermissionKey::parse("ticket.hoge").unwrap()], + })], + }], + }, + ) + .unwrap(); host } fn schema() -> WorkspaceConfigSchemaBundle { WorkspaceConfigSchemaBundle::compose([ PermissionConfigSchemaProvider.contribution().unwrap(), - crate::prompt_settings::PromptConfigSchemaProvider.contribution().unwrap(), - ]).unwrap() + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} +fn open(path: &std::path::Path) -> SqliteWorkspaceStore { + SqliteWorkspaceStore::open_for_test(path).unwrap() } -fn open(path: &std::path::Path) -> SqliteWorkspaceStore { SqliteWorkspaceStore::open_for_test(path).unwrap() } fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { let dir = tempfile::tempdir().unwrap(); let db = open(&dir.path().join("server.sqlite")); @@ -37,119 +68,350 @@ fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?; Ok(()) }).unwrap(); - db.ensure_workspace_config_materialized_with_schema(WORKSPACE,"now",schema()).unwrap(); - (dir,db) + db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema()) + .unwrap(); + (dir, db) } -fn request(db: &SqliteWorkspaceStore, host: &PermissionHost, id: &str, source: Option<&str>) -> ConfigurationAdoptionRequest { +fn request( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + id: &str, + source: Option<&str>, +) -> ConfigurationAdoptionRequest { let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap(); let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap(); - let changes = source.map(|source| vec![ConfigTreeChange::Update { - path: path.clone(), expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), content:source.into(), - }]).unwrap_or_default(); + let changes = source + .map(|source| { + vec![ConfigTreeChange::Update { + path: path.clone(), + expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), + content: source.into(), + }] + }) + .unwrap_or_default(); ConfigurationAdoptionRequest { - operation_id:id.into(), expected_legacy_export:db.configuration_head(WORKSPACE,OWNER).unwrap().is_none().then(||"fixture-empty".into()), expected_head:db.configuration_head(WORKSPACE,OWNER).unwrap(),expected_catalog_digest:catalog_digest(host).unwrap(), - source:ConfigCommitRequest {base_digest:current.snapshot.digest,changes,entrypoints:vec![path]}, + operation_id: id.into(), + expected_legacy_export: db + .configuration_head(WORKSPACE, OWNER) + .unwrap() + .is_none() + .then(|| "fixture-empty".into()), + expected_head: db.configuration_head(WORKSPACE, OWNER).unwrap(), + expected_catalog_digest: catalog_digest(host).unwrap(), + source: ConfigCommitRequest { + base_digest: current.snapshot.digest, + changes, + entrypoints: vec![path], + }, } } -fn adopt(db: &SqliteWorkspaceStore, host: &PermissionHost, request: &ConfigurationAdoptionRequest) -> Result { - db.adopt_configuration(WORKSPACE,OWNER,request,schema(),host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))}, |fingerprint|Ok(ConfigurationPolicyTransfer { - transfer_identity:fingerprint.into(),physical_identity:"fixture-physical".into(),export_digest:"fixture-empty".into(),policies:BTreeMap::new(), - })) +fn adopt( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + request: &ConfigurationAdoptionRequest, +) -> Result { + db.adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |fingerprint| { + Ok(ConfigurationPolicyTransfer { + transfer_identity: fingerprint.into(), + physical_identity: "fixture-physical".into(), + export_digest: "fixture-empty".into(), + policies: BTreeMap::new(), + }) + }, + ) } const A: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; #[test] fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() { - let (_dir,db) = fixture(); let host=host(false); - let initial=request(&db,&host,"initial",Some(A)); - let first=adopt(&db,&host,&initial).unwrap(); - let stale=request(&db,&host,"stale-a",Some(B)); - adopt(&db,&host,&request(&db,&host,"to-b",Some(B))).unwrap(); - let final_a=adopt(&db,&host,&request(&db,&host,"back-a",Some(A))).unwrap(); - assert_eq!(first.state.snapshot,final_a.state.snapshot); - assert_ne!(first.history_head,final_a.history_head); - assert!(adopt(&db,&host,&stale).is_err()); + let (_dir, db) = fixture(); + let host = host(false); + let initial = request(&db, &host, "initial", Some(A)); + let first = adopt(&db, &host, &initial).unwrap(); + let stale = request(&db, &host, "stale-a", Some(B)); + adopt(&db, &host, &request(&db, &host, "to-b", Some(B))).unwrap(); + let final_a = adopt(&db, &host, &request(&db, &host, "back-a", Some(A))).unwrap(); + assert_eq!(first.state.snapshot, final_a.state.snapshot); + assert_ne!(first.history_head, final_a.history_head); + assert!(adopt(&db, &host, &stale).is_err()); // Exact replay precedes stale checks and returns only the saved old result. - assert_eq!(adopt(&db,&host,&initial).unwrap(),first); - assert_eq!(db.configuration_adoption_result(WORKSPACE,OWNER,"initial").unwrap(),Some(first)); - assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),Some(final_a.history_head)); - let mut changed=initial; changed.source.changes.clear(); - assert!(adopt(&db,&host,&changed).is_err()); + assert_eq!(adopt(&db, &host, &initial).unwrap(), first); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, "initial") + .unwrap(), + Some(first) + ); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(final_a.history_head) + ); + let mut changed = initial; + changed.source.changes.clear(); + assert!(adopt(&db, &host, &changed).is_err()); } #[test] fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() { - for (table,event) in [ - ("content_objects","INSERT"), ("workspace_config_entries","INSERT"), - ("workspace_configuration_policies","INSERT"), ("content_scopes","UPDATE"), - ("workspace_configuration_receipts","INSERT"), + for (table, event) in [ + ("content_objects", "INSERT"), + ("workspace_config_entries", "INSERT"), + ("workspace_configuration_policies", "INSERT"), + ("content_scopes", "UPDATE"), + ("workspace_configuration_receipts", "INSERT"), ] { - let (dir,db)=fixture(); let host=host(false); - let before=adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); - let candidate=request(&db,&host,"failing",Some(B)); + let (dir, db) = fixture(); + let host = host(false); + let before = adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let candidate = request(&db, &host, "failing", Some(B)); db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap(); - assert!(adopt(&db,&host,&candidate).is_err(),"{table}"); + assert!(adopt(&db, &host, &candidate).is_err(), "{table}"); drop(db); - let reopened=open(&dir.path().join("server.sqlite")); - assert_eq!(reopened.load_workspace_config(WORKSPACE).unwrap(),Some(before.state),"{table}"); - assert_eq!(reopened.configuration_head(WORKSPACE,OWNER).unwrap(),Some(before.history_head),"{table}"); - assert_eq!(reopened.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),policy,"{table}"); - assert!(reopened.configuration_adoption_result(WORKSPACE,OWNER,"failing").unwrap().is_none(),"{table}"); - adopt(&reopened,&host,&candidate).unwrap(); + let reopened = open(&dir.path().join("server.sqlite")); + assert_eq!( + reopened.load_workspace_config(WORKSPACE).unwrap(), + Some(before.state), + "{table}" + ); + assert_eq!( + reopened.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(before.history_head), + "{table}" + ); + assert_eq!( + reopened + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap(), + policy, + "{table}" + ); + assert!( + reopened + .configuration_adoption_result(WORKSPACE, OWNER, "failing") + .unwrap() + .is_none(), + "{table}" + ); + adopt(&reopened, &host, &candidate).unwrap(); } } #[test] fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() { - let (_dir,db)=fixture(); let host=host(false); - let initial=db.load_workspace_config(WORKSPACE).unwrap(); + let (_dir, db) = fixture(); + let host = host(false); + let initial = db.load_workspace_config(WORKSPACE).unwrap(); for source in [ - "not valid Decodal {", + "not valid Decodal {", r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#, ] { - let request=request(&db,&host,"bad",Some(source)); - let result=db.adopt_configuration(WORKSPACE,OWNER,&request,schema(),&host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))},|_|panic!("validation failure must precede seal")); - assert!(result.is_err(),"{source}"); - assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(),initial); - assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),None); + let request = request(&db, &host, "bad", Some(source)); + let result = db.adopt_configuration( + WORKSPACE, + OWNER, + &request, + schema(), + &host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |_| panic!("validation failure must precede seal"), + ); + assert!(result.is_err(), "{source}"); + assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(), initial); + assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None); } - let request=request(&db,&host,"other-owner",None); - assert!(db.adopt_configuration(WORKSPACE,"non-owner",&request,schema(),&host,None,|_|Ok(()),|_|panic!("owner refusal must precede seal")).is_err()); + let request = request(&db, &host, "other-owner", None); + assert!( + db.adopt_configuration( + WORKSPACE, + "non-owner", + &request, + schema(), + &host, + None, + |_| Ok(()), + |_| panic!("owner refusal must precede seal") + ) + .is_err() + ); } #[test] fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() { - let (_dir,db)=fixture(); let host=host(false); - adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let original=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); - let preview=db.preview_configuration_adoption(WORKSPACE,OWNER,&request(&db,&host,"noop",None),schema(),&host,None,|_|Ok(())).unwrap(); + let (_dir, db) = fixture(); + let host = host(false); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let original = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let preview = db + .preview_configuration_adoption( + WORKSPACE, + OWNER, + &request(&db, &host, "noop", None), + schema(), + &host, + None, + |_| Ok(()), + ) + .unwrap(); assert!(preview.changed_paths.is_empty()); assert!(preview.changed_subjects.is_empty()); - adopt(&db,&host,&request(&db,&host,"noop",None)).unwrap(); - assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); - adopt(&db,&host,&request(&db,&host,"omit",Some("{} as WorkspaceConfigSchema"))).unwrap(); - assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); - adopt(&db,&host,&request(&db,&host,"remove",Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#))).unwrap(); - assert!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().is_none()); + adopt(&db, &host, &request(&db, &host, "noop", None)).unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request(&db, &host, "omit", Some("{} as WorkspaceConfigSchema")), + ) + .unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request( + &db, + &host, + "remove", + Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#), + ), + ) + .unwrap(); + assert!( + db.adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .is_none() + ); } #[test] fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() { - for children in [false,true] { - let (_dir,db)=fixture(); let host=host(children); - adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); - let resolution=host.resolve(Some(&policy.source.execution.assignment())); - assert_eq!(resolution.projection().allowed.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),children); - assert!(host.resolve(Some(&policy.source.delegation.assignment())).projection().allowed.is_empty()); - adopt(&db,&host,&request(&db,&host,"multi",Some(B))).unwrap(); - let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); - assert_eq!(policy.source.execution.namespaces[0].keys[0].as_str(),"ticket.hoge.fuga"); + for children in [false, true] { + let (_dir, db) = fixture(); + let host = host(children); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + let resolution = host.resolve(Some(&policy.source.execution.assignment())); + assert_eq!( + resolution + .projection() + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()), + children + ); + assert!( + host.resolve(Some(&policy.source.delegation.assignment())) + .projection() + .allowed + .is_empty() + ); + adopt(&db, &host, &request(&db, &host, "multi", Some(B))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + assert_eq!( + policy.source.execution.namespaces[0].keys[0].as_str(), + "ticket.hoge.fuga" + ); } } + +#[test] +fn client_operation_id_cannot_reuse_a_previous_policy_change_identity() { + let (_dir, db) = fixture(); + let host = host(false); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let before = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + let change = request(&db, &host, &before.change_id, Some(B)); + adopt(&db, &host, &change).unwrap(); + let after = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + assert_ne!(after.change_id, before.change_id); + assert_ne!(after.source, before.source); +} + +#[test] +fn independent_database_connections_serialize_concurrent_web_and_cli_heads() { + let (dir, db) = fixture(); + let registry = host(false); + adopt(&db, ®istry, &request(&db, ®istry, "initial", Some(A))).unwrap(); + let other = open(&dir.path().join("server.sqlite")); + let web = request(&db, ®istry, "web-edit", Some(B)); + let cli = request( + &other, + ®istry, + "cli-edit", + Some("{} as WorkspaceConfigSchema"), + ); + assert_eq!(web.expected_head, cli.expected_head); + let barrier = std::sync::Barrier::new(2); + let (web, cli) = std::thread::scope(|scope| { + let web = scope.spawn(|| { + barrier.wait(); + adopt(&db, &host(false), &web) + }); + let cli = scope.spawn(|| { + barrier.wait(); + adopt(&other, &host(false), &cli) + }); + (web.join().unwrap(), cli.join().unwrap()) + }); + let (winner, loser) = match (web, cli) { + (Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser), + results => panic!("exactly one observed head may adopt: {results:?}"), + }; + assert!(matches!(loser, Error::WorkspaceConfigConflict(_))); + assert_eq!( + db.load_workspace_config(WORKSPACE).unwrap(), + Some(winner.state) + ); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(winner.history_head) + ); + assert_eq!( + other + .configuration_current(WORKSPACE, OWNER) + .unwrap() + .history_head, + db.configuration_head(WORKSPACE, OWNER).unwrap() + ); +} diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 9036a50ac..2162d4045 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -183,49 +183,56 @@ impl SqliteWorkspaceStore { schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { let desired_schema = schema_bundle.clone(); - let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| { - let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let workspace_exists: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", - [workspace_id], - |row| row.get(0), - )?; - if !workspace_exists { - return Err(Error::WorkspaceIdMismatch); - } - let stored_decodal_version = tx + let (state, requires_toolchain_refresh, base_identity, canonical) = + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let workspace_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + [workspace_id], + |row| row.get(0), + )?; + if !workspace_exists { + return Err(Error::WorkspaceIdMismatch); + } + let stored_decodal_version = tx .query_row( "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", [workspace_id], |row| row.get::<_, String>(0), ) .optional()?; - let requires_toolchain_refresh = stored_decodal_version - .as_deref() - .is_some_and(|version| version != DECODAL_VERSION); - let state = match load_state(&tx, workspace_id)? { - Some(state) => state, - None => { - let mut state = initial_state_with_schema(schema_bundle.clone())?; - if has_plugin_provider(&schema_bundle) { - let (contract, evaluation, _) = - crate::plugin_settings::evaluate_catalog_config( - &tx, - workspace_id, - &state.snapshot, - schema_bundle.clone(), - )?; - state.contract = contract; - state.projection_digest = evaluation.projection_digest; + let requires_toolchain_refresh = stored_decodal_version + .as_deref() + .is_some_and(|version| version != DECODAL_VERSION); + let state = match load_state(&tx, workspace_id)? { + Some(state) => state, + None => { + let mut state = initial_state_with_schema(schema_bundle.clone())?; + if has_plugin_provider(&schema_bundle) { + let (contract, evaluation, _) = + crate::plugin_settings::evaluate_catalog_config( + &tx, + workspace_id, + &state.snapshot, + schema_bundle.clone(), + )?; + state.contract = contract; + state.projection_digest = evaluation.projection_digest; + } + insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; + state } - insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; - state - } - }; - let base_identity = load_evaluation_identity(&tx, workspace_id)?; - tx.commit()?; - Ok((state, requires_toolchain_refresh, base_identity)) - })?; + }; + let base_identity = load_evaluation_identity(&tx, workspace_id)?; + let canonical = crate::config_adoption::canonical(&tx, workspace_id)?; + tx.commit()?; + Ok((state, requires_toolchain_refresh, base_identity, canonical)) + })?; + // Once adopted, schema changes must be previewed/adopted by the owner, + // not persisted as a second startup writer outside the policy/history tx. + if canonical { + return Ok(state); + } let desired_schema = crate::plugin_settings::materialization_schema( desired_schema, &state.contract.schema_bundle, @@ -570,14 +577,21 @@ fn evaluate_candidate( } pub(crate) fn sync_adopted_config_references( - tx: &rusqlite::Transaction<'_>, workspace: &str, state: &WorkspaceConfigState, + tx: &rusqlite::Transaction<'_>, + workspace: &str, + state: &WorkspaceConfigState, ) -> Result<()> { if has_plugin_provider(&state.contract.schema_bundle) { let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config( - tx, workspace, &state.snapshot, state.contract.schema_bundle.clone(), + tx, + workspace, + &state.snapshot, + state.contract.schema_bundle.clone(), )?; if contract != state.contract || evaluation.projection_digest != state.projection_digest { - return Err(config_conflict("Plugin catalog/schema changed during adoption")); + return Err(config_conflict( + "Plugin catalog/schema changed during adoption", + )); } crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?; } diff --git a/crates/workspace-server/src/configuration_migration.rs b/crates/workspace-server/src/configuration_migration.rs index e0cfd7c95..c0fb37625 100644 --- a/crates/workspace-server/src/configuration_migration.rs +++ b/crates/workspace-server/src/configuration_migration.rs @@ -62,13 +62,20 @@ pub(crate) fn preview_migration( Error::RegistryInconsistency("workspace config tree is not materialized".into()) }) })?; - let snapshot = legacy.preview_permission_transfer(&identity).map_err(legacy_error)?; + let snapshot = legacy + .preview_permission_transfer(&identity) + .map_err(legacy_error)?; if snapshot.identity != identity { - return Err(Error::WorkspaceConfigConflict("legacy export identity changed".into())); + return Err(Error::WorkspaceConfigConflict( + "legacy export identity changed".into(), + )); } let (policies, diagnostics) = migrate_policies(host, &snapshot)?; let source = PermissionSource { - subjects: policies.values().map(|policy| policy.source.clone()).collect(), + subjects: policies + .values() + .map(|policy| policy.source.clone()) + .collect(), remove_subjects: vec![], }; let changes = draft_changes(&original, &source)?; @@ -90,7 +97,11 @@ pub(crate) fn preview_migration( "migration import did not produce the exact generated Subject assignments".into(), )); } - let subjects: BTreeSet<_> = snapshot.sources.iter().map(|item| item.subject_id.as_str()).collect(); + let subjects: BTreeSet<_> = snapshot + .sources + .iter() + .map(|item| item.subject_id.as_str()) + .collect(); policy_config::validate_source(host, &evaluated_source, |subject| { if subjects.contains(subject) { Ok(()) @@ -103,7 +114,13 @@ pub(crate) fn preview_migration( contract: candidate.contract, projection_digest: candidate.evaluation.projection_digest, }; - Ok(MigrationSeed { original, state, snapshot, policies, diagnostics }) + Ok(MigrationSeed { + original, + state, + snapshot, + policies, + diagnostics, + }) } fn legacy_error(error: subjektiv::SubjektivError) -> Error { @@ -117,17 +134,25 @@ fn path(value: &str) -> Result { fn migrate_policies( host: &PermissionHost, snapshot: &PermissionTransferSnapshot, -) -> Result<(BTreeMap, Vec)> { +) -> Result<( + BTreeMap, + Vec, +)> { let mut policies = BTreeMap::new(); let mut diagnostics = vec![]; let mut seen = BTreeSet::new(); for item in &snapshot.sources { if !seen.insert(&item.subject_id) { - return Err(Error::RegistryInconsistency("duplicate Subject in legacy export".into())); + return Err(Error::RegistryInconsistency( + "duplicate Subject in legacy export".into(), + )); } // None stays unset. In particular, never enroll it in a default Set. - let Some(legacy) = &item.permissions else { continue }; - let (policy, mut converted) = policy_config::migrate_subject(host, &item.subject_id, legacy)?; + let Some(legacy) = &item.permissions else { + continue; + }; + let (policy, mut converted) = + policy_config::migrate_subject(host, &item.subject_id, legacy)?; policies.insert(item.subject_id.clone(), policy); // ExplicitConfigurationRequired must survive preview, even though the // unsupported operation is absent from the editable assignment. Core must @@ -137,7 +162,10 @@ fn migrate_policies( Ok((policies, diagnostics)) } -fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Result> { +fn draft_changes( + original: &WorkspaceConfigState, + source: &PermissionSource, +) -> Result> { // Evaluate with the existing open schema, not application defaults. This // distinguishes an explicit `permissions = {}` (including imports/dotted // fields) from an omitted field without introducing another Decodal parser. @@ -149,10 +177,16 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> ); let raw = SnapshotEnvironment::new(original.snapshot.clone()) .evaluate_contract(&open_contract) - .map_err(|diagnostics| Error::InvalidInput(format!( - "cannot inspect current configuration before permission migration: {diagnostics:?}" - )))?; - if raw.projections.iter().any(|projection| projection.data_json.get("permissions").is_some()) { + .map_err(|diagnostics| { + Error::InvalidInput(format!( + "cannot inspect current configuration before permission migration: {diagnostics:?}" + )) + })?; + if raw + .projections + .iter() + .any(|projection| projection.data_json.get("permissions").is_some()) + { return Err(Error::InvalidInput( "current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(), )); @@ -163,13 +197,17 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Error::RegistryInconsistency("workspace config tree has no main.dcdl".into()) })?; if main.content_type != ConfigContentType::Decodal { - return Err(Error::InvalidInput("main.dcdl is not Decodal source".into())); + return Err(Error::InvalidInput( + "main.dcdl is not Decodal source".into(), + )); } // Use the existing syntax formatter to supply a final field separator when // needed. It preserves comments; no local lexer or alternate language rules. let formatted_main = SnapshotEnvironment::new(original.snapshot.clone()) .format(&main.content) - .map_err(|error| Error::InvalidInput(format!("cannot format migration entrypoint: {error}")))?; + .map_err(|error| { + Error::InvalidInput(format!("cannot format migration entrypoint: {error}")) + })?; Ok(vec![ ConfigTreeChange::Create { path: generated_path.clone(), @@ -206,8 +244,10 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result { // assertion. Limit this edit to that existing representation: no rewrites of // arbitrary expressions, no JSON round-trip, no replacement of other fields. let trimmed = source.trim_end(); - let object = trimmed.strip_suffix("as WorkspaceConfigSchema") - .map(str::trim_end).unwrap_or(trimmed); + let object = trimmed + .strip_suffix("as WorkspaceConfigSchema") + .map(str::trim_end) + .unwrap_or(trimmed); if !object.starts_with('{') || !object.ends_with('}') { return Err(Error::InvalidInput( "permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(), @@ -215,7 +255,11 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result { } let close = object.len() - 1; let import = quote(&format!("./{}", imported.as_str())); - Ok(format!("{}\n permissions = import {import};\n{}", &source[..close], &source[close..])) + Ok(format!( + "{}\n permissions = import {import};\n{}", + &source[..close], + &source[close..] + )) } // A deliberately specific, payload-free renderer. JSON quoting is used only for @@ -225,15 +269,23 @@ fn quote(value: &str) -> String { } fn render_strings<'a>(values: impl IntoIterator) -> String { - format!("[{}]", values.into_iter().map(quote).collect::>().join(", ")) + format!( + "[{}]", + values.into_iter().map(quote).collect::>().join(", ") + ) } fn render_namespaces(values: &[NamespacePermissions]) -> String { - let items = values.iter().map(|value| format!( - "{{ namespace = {}; keys = {}; }}", - quote(value.namespace.as_str()), - render_strings(value.keys.iter().map(|key| key.as_str())), - )).collect::>(); + let items = values + .iter() + .map(|value| { + format!( + "{{ namespace = {}; keys = {}; }}", + quote(value.namespace.as_str()), + render_strings(value.keys.iter().map(|key| key.as_str())), + ) + }) + .collect::>(); format!("[{}]", items.join(", ")) } @@ -249,10 +301,19 @@ fn render_assignment(source: &AssignmentSource) -> String { } fn render_permission_source(source: &PermissionSource) -> String { - let subjects = source.subjects.iter().map(|item| format!( - " {{ subject_id = {}; execution = {}; delegation = {}; }}", - quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation), - )).collect::>().join(",\n"); + let subjects = source + .subjects + .iter() + .map(|item| { + format!( + " {{ subject_id = {}; execution = {}; delegation = {}; }}", + quote(&item.subject_id), + render_assignment(&item.execution), + render_assignment(&item.delegation), + ) + }) + .collect::>() + .join(",\n"); format!( "{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n", render_strings(source.remove_subjects.iter().map(String::as_str)), @@ -264,8 +325,8 @@ mod tests { use super::*; use config_source::{ConfigEntry, EvaluationResult}; use feature_permissions::{ - FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, - PermissionKey, PermissionSetId, WorkspaceId, + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionKey, + PermissionSetId, WorkspaceId, }; use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions}; use subjektiv::{DatabaseIdentity, PermissionSourceExport}; @@ -274,22 +335,35 @@ mod tests { fn host(children: bool) -> PermissionHost { let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); - let feature = host.register_feature(FeatureIdentity::parse("builtin:file").unwrap()).unwrap(); - let namespace = host.bind_namespace(&feature, NamespaceId::parse("file").unwrap()).unwrap(); - host.declare(&namespace, NamespaceDeclaration { - permissions: vec![ - PermissionDefinition { - key: PermissionKey::parse("file.read").unwrap(), - description: String::new(), - children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] }, - }, - PermissionDefinition { - key: PermissionKey::parse("file.read.text").unwrap(), - description: String::new(), children: vec![], - }, - ], - sets: vec![], - }).unwrap(); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:file").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("file").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("file.read").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("file.read.text").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("file.read.text").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![], + }, + ) + .unwrap(); host } @@ -297,7 +371,9 @@ mod tests { ScopedPermission { operation: operation.into(), resource: PermissionResource::Workdir { - working_directory_id: "one-workdir".into(), path: "only-this.txt".into(), recursive: false, + working_directory_id: "one-workdir".into(), + path: "only-this.txt".into(), + recursive: false, }, } } @@ -305,18 +381,25 @@ mod tests { fn export(sources: Vec) -> PermissionTransferSnapshot { PermissionTransferSnapshot { identity: DatabaseIdentity { - scope_id: WORKSPACE.into(), feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), + scope_id: WORKSPACE.into(), + feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), physical_id: "typed-test-identity".into(), }, - sources, export_digest: "not-used-by-pure-conversion-tests".into(), + sources, + export_digest: "not-used-by-pure-conversion-tests".into(), } } fn current(main: &str, extra: Vec) -> WorkspaceConfigState { let mut current = crate::config_source::initial_state().unwrap(); - let mut entries = vec![ConfigEntry::new( - path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, main, - ).unwrap()]; + let mut entries = vec![ + ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + main, + ) + .unwrap(), + ]; entries.extend(extra); current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap(); current @@ -324,43 +407,81 @@ mod tests { fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult { let contract = crate::config_source::initial_state().unwrap().contract; - SnapshotEnvironment::new(snapshot).evaluate_contract(&contract).unwrap() + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap() } #[test] fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() { - let empty = SubjectPermissions { change_id: "explicit-empty".into(), execution: vec![], delegation: vec![] }; + let empty = SubjectPermissions { + change_id: "explicit-empty".into(), + execution: vec![], + delegation: vec![], + }; let limited = SubjectPermissions { - change_id: "keep-limited-change".into(), execution: vec![limited("file.read")], + change_id: "keep-limited-change".into(), + execution: vec![limited("file.read")], delegation: vec![limited("file.read.text")], }; let snapshot = export(vec![ - PermissionSourceExport { subject_id: "unset".into(), permissions: None }, - PermissionSourceExport { subject_id: "empty".into(), permissions: Some(empty.clone()) }, - PermissionSourceExport { subject_id: "limited".into(), permissions: Some(limited.clone()) }, + PermissionSourceExport { + subject_id: "unset".into(), + permissions: None, + }, + PermissionSourceExport { + subject_id: "empty".into(), + permissions: Some(empty.clone()), + }, + PermissionSourceExport { + subject_id: "limited".into(), + permissions: Some(limited.clone()), + }, ]); let host = host(false); let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); assert!(!policies.contains_key("unset")); assert_eq!(policies["empty"].change_id, empty.change_id); - assert_eq!(policies["empty"].source.execution, AssignmentSource::default()); - assert_eq!(policies["empty"].source.delegation, AssignmentSource::default()); + assert_eq!( + policies["empty"].source.execution, + AssignmentSource::default() + ); + assert_eq!( + policies["empty"].source.delegation, + AssignmentSource::default() + ); assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited); assert_eq!(diagnostics.len(), 2); - assert!(diagnostics.iter().all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))); + assert!( + diagnostics + .iter() + .all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. })) + ); } #[test] fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() { - for (host, permission) in [(host(false), limited("file.unknown")), (host(true), limited("file.read"))] { + for (host, permission) in [ + (host(false), limited("file.unknown")), + (host(true), limited("file.read")), + ] { let legacy = SubjectPermissions { - change_id: "keep-change".into(), execution: vec![permission.clone()], delegation: vec![], + change_id: "keep-change".into(), + execution: vec![permission.clone()], + delegation: vec![], }; - let snapshot = export(vec![PermissionSourceExport { subject_id: "blocked".into(), permissions: Some(legacy.clone()) }]); - let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); - assert_eq!(diagnostics, vec![MigrationDiagnostic::ExplicitConfigurationRequired { - subject_id: "blocked".into(), permission, + let snapshot = export(vec![PermissionSourceExport { + subject_id: "blocked".into(), + permissions: Some(legacy.clone()), }]); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert_eq!( + diagnostics, + vec![MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: "blocked".into(), + permission, + }] + ); assert_eq!(policies["blocked"].legacy_constraints, Some(legacy)); assert!(policies["blocked"].source.execution.namespaces.is_empty()); assert!(policies["blocked"].source.execution.sets.is_empty()); @@ -384,7 +505,8 @@ mod tests { }; let source = PermissionSource { subjects: vec![policy_config::SubjectAssignmentSource { - subject_id: "subject-\"quoted\\日本語".into(), execution: assignment, + subject_id: "subject-\"quoted\\日本語".into(), + execution: assignment, delegation: AssignmentSource::default(), }], remove_subjects: vec!["removed".into()], @@ -393,33 +515,58 @@ mod tests { assert!(rendered.contains("sets = [\"shared\"]")); assert!(rendered.contains("keys = [\"file.read.text\"]")); assert!(!rendered.contains("legacy_constraints")); - let snapshot = ConfigTreeSnapshot::from_entries([ - ConfigEntry::new(path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, - format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema")).unwrap(), - ]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema"), + ) + .unwrap()]) + .unwrap(); assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source); } #[test] fn draft_preserves_main_comments_other_settings_and_existing_migration_files() { - let main = "{\n // permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; - let occupied = ConfigEntry::new(path("permissions/migrated.dcdl").unwrap(), ConfigContentType::Decodal, "{ keep = true; }\n").unwrap(); + let main = "{\n # permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; + let occupied = ConfigEntry::new( + path("permissions/migrated.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ keep = true; }\n", + ) + .unwrap(); let original = current(main, vec![occupied.clone()]); let source = PermissionSource::default(); let changes = draft_changes(&original, &source).unwrap(); let draft = original.snapshot.apply(&changes).unwrap(); assert_eq!(draft.get(&occupied.path), Some(&occupied)); - assert!(draft.get(&path("permissions/migrated-1.dcdl").unwrap()).is_some()); - let updated_main = &draft.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content; - assert!(updated_main.contains("// permissions in a comment are not a field")); + assert!( + draft + .get(&path("permissions/migrated-1.dcdl").unwrap()) + .is_some() + ); + let updated_main = &draft + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content; + assert!(updated_main.contains("# permissions in a comment are not a field")); assert!(updated_main.contains("brace } and permissions")); assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n")); let before = evaluate(original.snapshot.clone()); let after = evaluate(draft); - assert_eq!(after.projections[0].data_json["other"], before.projections[0].data_json["other"]); + assert_eq!( + after.projections[0].data_json["other"], + before.projections[0].data_json["other"] + ); assert_eq!(policy_config::source(&after).unwrap(), source); - assert_eq!(original.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content, main); + assert_eq!( + original + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content, + main + ); } #[test] @@ -429,11 +576,19 @@ mod tests { "{ permissions.subjects = []; } as WorkspaceConfigSchema", "{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema", ] { - let existing = ConfigEntry::new(path("permissions/existing.dcdl").unwrap(), ConfigContentType::Decodal, "{ subjects = []; }").unwrap(); + let existing = ConfigEntry::new( + path("permissions/existing.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ subjects = []; }", + ) + .unwrap(); let original = current(main, vec![existing]); let before = original.clone(); let error = draft_changes(&original, &PermissionSource::default()).unwrap_err(); - assert!(error.to_string().contains("explicitly defines permissions"), "{error}"); + assert!( + error.to_string().contains("explicitly defines permissions"), + "{error}" + ); assert_eq!(original, before); } } diff --git a/crates/workspace-server/src/configuration_transfer_tests.rs b/crates/workspace-server/src/configuration_transfer_tests.rs new file mode 100644 index 000000000..d5271da48 --- /dev/null +++ b/crates/workspace-server/src/configuration_transfer_tests.rs @@ -0,0 +1,1062 @@ +//! Cross-store component tests connect configuration migration and adoption to a +//! real guarded Subjektiv FeatureDatabase and the production canonical-source +//! bridge, which reads the real Server policy store on every call. +//! These ordinary crate tests use isolated temporary SQLite stores, not HTTP, +//! authenticated Worker execution, or a distributed transaction/crash guarantee. + +use std::path::Path; +use std::sync::Arc; + +use config_source::{ + ConfigContentType, ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle, +}; +use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionHost, + PermissionKey, WorkspaceId, +}; +use feature_storage::{FeatureDatabase, FeatureStorage, ScopedFeatureStorage}; +use rusqlite::Connection; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + WorkerExecutionGrant, +}; +use subjektiv::{SubjectRole, SubjektivError, SubjektivStore}; + +use crate::config_adoption::{ + CanonicalConfigurationSource, ConfigurationAdoptionRequest, ConfigurationAdoptionResult, + ConfigurationMigrationBaseline, ConfigurationPolicyTransfer, catalog_digest, + request_fingerprint, +}; +use crate::config_source::{ + ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider, + WorkspaceConfigState, evaluate_workspace_config_state, +}; +use crate::configuration_migration::{MigrationSeed, preview_migration}; +use crate::policy_config::{AssignmentSource, MigrationDiagnostic, PermissionConfigSchemaProvider}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +const WORKSPACE: &str = "configuration-transfer-workspace"; +const OWNER: &str = "configuration-transfer-owner"; +const EXISTING_MAIN: &str = r#"{ + # Keep this owner's explanation, including the word permissions. + prompts = { common = { language = import "./existing.dcdl"; }; }; +} as WorkspaceConfigSchema +"#; +const EXISTING_DATA: &str = "\"KEEP LANGUAGE\"\n"; +const OCCUPIED_MIGRATION: &str = + "# An unrelated, unreferenced file must not be overwritten.\n{ keep = true; }\n"; + +fn path(value: &str) -> VirtualPath { + VirtualPath::parse(value).unwrap() +} + +fn schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ + PermissionConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} + +fn host() -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:file").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("file").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: ["file.read", "file.read.text"] + .into_iter() + .map(|key| PermissionDefinition { + key: PermissionKey::parse(key).unwrap(), + description: String::new(), + children: vec![], + }) + .collect(), + sets: vec![], + }, + ) + .unwrap(); + host +} + +fn limited(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), + path: "only-this.txt".into(), + recursive: false, + }, + } +} + +struct FeatureFixture { + store: SubjektivStore, + database: FeatureDatabase, + scope: ScopedFeatureStorage, + manager: FeatureStorage, +} + +impl FeatureFixture { + fn open(root: &Path, expected: Option<&feature_storage::DatabaseIdentity>) -> Self { + let manager = FeatureStorage::new(root); + let scope = manager.workspace(WORKSPACE).unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let database = match expected { + Some(identity) => scope.open_existing(®istration, identity).unwrap(), + None => scope + .provision( + ®istration, + "fixture-initial-provision", + "isolated component-test Host provisioning", + ) + .unwrap(), + }; + let store = SubjektivStore::from_database(database.clone()).unwrap(); + Self { + store, + database, + scope, + manager, + } + } + + fn close(self) { + drop(self.store); + drop(self.database); + self.scope.shutdown().unwrap(); + self.manager.shutdown().unwrap(); + } +} + +struct Subjects { + unset: String, + empty: String, + limited: String, + empty_source: SubjectPermissions, + limited_source: SubjectPermissions, + exact_legacy_update: SubjectPermissionsUpdate, + stale_grant: WorkerExecutionGrant, + live_grant: WorkerExecutionGrant, +} + +fn grant(subject: &str, change: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), + source_change_id: change.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "authenticated-root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![limited("file.read.text")], + redelegation: vec![], + } +} + +fn replace( + store: &SubjektivStore, + subject: &str, + id: &str, + execution: Vec, + delegation: Vec, +) -> (SubjectPermissionsUpdate, SubjectPermissions) { + let update = SubjectPermissionsUpdate { + operation_id: id.into(), + expected_change_id: store + .subject_permissions(subject) + .unwrap() + .map(|source| source.change_id), + execution, + delegation, + }; + let source = store.set_subject_permissions(subject, &update).unwrap(); + (update, source) +} + +fn subjects(features: &FeatureFixture) -> Subjects { + let create = || { + features + .store + .create_subject(SubjectRole::new("component fixture").unwrap()) + .unwrap() + .id + }; + let unset = create(); + let empty = create(); + let subject = create(); + // Historical Subjects can have no source. Keep the production schema and + // physical identity; seed only that historical row state through its guard. + features + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset], + )?; + Ok(()) + }) + .unwrap(); + let (_, empty_source) = replace(&features.store, &empty, "explicit-empty", vec![], vec![]); + let (_, old) = replace( + &features.store, + &subject, + "old-limited", + vec![limited("file.read")], + vec![limited("file.read.text")], + ); + let stale_grant = grant(&subject, &old.change_id, "already-stale-child"); + features + .store + .bind_worker_execution_grant(&stale_grant) + .unwrap(); + let (exact_legacy_update, limited_source) = replace( + &features.store, + &subject, + "current-limited", + vec![limited("file.read")], + vec![limited("file.read.text")], + ); + let live_grant = grant(&subject, &limited_source.change_id, "live-child"); + features + .store + .bind_worker_execution_grant(&live_grant) + .unwrap(); + Subjects { + unset, + empty, + limited: subject, + empty_source, + limited_source, + exact_legacy_update, + stale_grant, + live_grant, + } +} + +fn server(root: &Path) -> SqliteWorkspaceStore { + SqliteWorkspaceStore::open_for_test(root.join("server.sqlite")).unwrap() +} + +fn fixture() -> ( + tempfile::TempDir, + SqliteWorkspaceStore, + FeatureFixture, + Subjects, +) { + let root = tempfile::tempdir().unwrap(); + let server = server(root.path()); + server.with_immediate_transaction(|tx| { + tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?1,'Owner','1','1')", [OWNER])?; + tx.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES(?1,?2,'Transfer','active','1','1')", [WORKSPACE, OWNER])?; + Ok(()) + }).unwrap(); + let initial = server + .ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema()) + .unwrap(); + let main = path(MAIN_CONFIG_ENTRYPOINT); + let candidate = server + .evaluate_workspace_config_candidate_with_schema( + WORKSPACE, + &ConfigCommitRequest { + base_digest: initial.snapshot.digest.clone(), + changes: vec![ + ConfigTreeChange::Update { + path: main.clone(), + expected_digest: initial + .snapshot + .get(&main) + .unwrap() + .content_digest + .clone(), + content: EXISTING_MAIN.into(), + }, + ConfigTreeChange::Create { + path: path("existing.dcdl"), + content_type: ConfigContentType::Decodal, + content: EXISTING_DATA.into(), + }, + ConfigTreeChange::Create { + path: path("permissions/migrated.dcdl"), + content_type: ConfigContentType::Decodal, + content: OCCUPIED_MIGRATION.into(), + }, + ], + entrypoints: vec![main], + }, + schema(), + ) + .unwrap(); + server + .commit_evaluated_workspace_config(WORKSPACE, &candidate) + .unwrap(); + let features = FeatureFixture::open(&root.path().join("features"), None); + let subjects = subjects(&features); + (root, server, features, subjects) +} + +fn migration( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, +) -> MigrationSeed { + preview_migration(server, &features.store, WORKSPACE, schema(), host).unwrap() +} + +fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> { + ConfigurationMigrationBaseline { + original: &seed.original, + state: &seed.state, + policies: &seed.policies, + diagnostics: &seed.diagnostics, + export_digest: &seed.snapshot.export_digest, + } +} + +fn migration_request(seed: &MigrationSeed, host: &PermissionHost) -> ConfigurationAdoptionRequest { + ConfigurationAdoptionRequest { + operation_id: "adopt-migration".into(), + expected_head: None, + expected_catalog_digest: catalog_digest(host).unwrap(), + expected_legacy_export: Some(seed.snapshot.export_digest.clone()), + source: ConfigCommitRequest { + base_digest: seed.state.snapshot.digest.clone(), + changes: vec![], + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + } +} + +fn require_subject(store: &SubjektivStore, subject: &str) -> Result<()> { + store + .subject(subject) + .map_err(|error| Error::Store(error.to_string()))? + .ok_or_else(|| Error::SubjektivSubjectNotFound(subject.into()))?; + Ok(()) +} + +fn adopt_migration( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, + seed: &MigrationSeed, + request: &ConfigurationAdoptionRequest, +) -> Result { + features + .store + .with_permission_authority_guard(&seed.snapshot.identity, |authority| { + Ok(server.adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + Some(&baseline(seed)), + |subject| { + authority + .require_subject(subject) + .map_err(|error| Error::Store(error.to_string())) + }, + |fingerprint| { + let receipt = authority + .seal_permission_sources(fingerprint, &seed.snapshot.export_digest) + .map_err(|error| Error::Store(error.to_string()))?; + assert_eq!(receipt.snapshot, seed.snapshot); + Ok(ConfigurationPolicyTransfer { + transfer_identity: receipt.transfer_id, + physical_identity: receipt.snapshot.identity.physical_id, + export_digest: receipt.snapshot.export_digest, + policies: seed.policies.clone(), + }) + }, + )) + }) + .map_err(|error| Error::Store(error.to_string()))? +} + +// Observe persisted records rather than calls. Sorting row renderings also covers +// composite-key/WITHOUT ROWID tables without relying on SQLite traversal order. +fn table_rows(conn: &Connection, table: &str) -> rusqlite::Result> { + let mut statement = conn.prepare(&format!("SELECT * FROM {table}"))?; + let columns = statement.column_count(); + let mut rows = statement + .query_map([], |row| { + let values = (0..columns) + .map(|index| row.get::<_, rusqlite::types::Value>(index)) + .collect::>>()?; + Ok(format!("{values:?}")) + })? + .collect::>>()?; + rows.sort(); + Ok(rows) +} + +fn server_records(server: &SqliteWorkspaceStore) -> Vec<(String, Vec)> { + server.with_conn(|conn| { + let tables = conn.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND (name LIKE 'workspace_config%' OR name LIKE 'content_%') ORDER BY name")? + .query_map([], |row| row.get::<_, String>(0))?.collect::>>()?; + tables.into_iter().map(|table| Ok((table.clone(), table_rows(conn, &table)?))).collect() + }).unwrap() +} + +fn legacy_records(features: &FeatureFixture) -> Vec> { + features + .database + .try_with_connection::<_, SubjektivError>(|conn| { + [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", + ] + .into_iter() + .map(|table| Ok(table_rows(conn, table)?)) + .collect() + }) + .unwrap() +} + +fn feature_schema(features: &FeatureFixture) -> Vec { + features + .database + .try_with_connection::<_, SubjektivError>(|conn| Ok(table_rows(conn, "sqlite_schema")?)) + .unwrap() +} + +fn assert_existing_data_preserved(before: &WorkspaceConfigState, after: &WorkspaceConfigState) { + let before_projection = evaluate_workspace_config_state(before, schema()).unwrap(); + let after_projection = evaluate_workspace_config_state(after, schema()).unwrap(); + for field in ["prompts"] { + assert_eq!( + after_projection.projections[0].data_json[field], + before_projection.projections[0].data_json[field], + "{field}" + ); + } + for file in ["existing.dcdl", "permissions/migrated.dcdl"] { + assert_eq!( + after.snapshot.get(&path(file)), + before.snapshot.get(&path(file)), + "{file}" + ); + } + let main = &after + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT)) + .unwrap() + .content; + assert!(main.contains("# Keep this owner's explanation, including the word permissions.")); + assert!(main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); + assert!( + after + .snapshot + .get(&path("permissions/migrated-1.dcdl")) + .is_some() + ); +} + +#[test] +fn migration_and_adoption_preview_preserve_existing_data_without_writing_either_store() { + let (_root, server, features, subjects) = fixture(); + let host = host(); + let before = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let server_before = server_records(&server); + let legacy_before = legacy_records(&features); + let schema_before = feature_schema(&features); + let seed = migration(&server, &features, &host); + let request = migration_request(&seed, &host); + let preview = server + .preview_configuration_adoption( + WORKSPACE, + OWNER, + &request, + schema(), + &host, + Some(&baseline(&seed)), + |subject| require_subject(&features.store, subject), + ) + .unwrap(); + assert_eq!(preview.state, seed.state); + assert_existing_data_preserved(&before, &preview.state); + assert!(!seed.policies.contains_key(&subjects.unset)); + assert_eq!( + seed.policies[&subjects.empty].change_id, + subjects.empty_source.change_id + ); + assert_eq!( + seed.policies[&subjects.empty].source.execution, + AssignmentSource::default() + ); + assert_eq!( + seed.policies[&subjects.empty].source.delegation, + AssignmentSource::default() + ); + assert_eq!( + seed.policies[&subjects.limited].legacy_read(&host).unwrap(), + subjects.limited_source + ); + assert!( + seed.diagnostics + .iter() + .all(|diagnostic| matches!(diagnostic, MigrationDiagnostic::DomainConstraint { .. })) + ); + assert_eq!(seed.diagnostics.len(), 2); + assert_eq!( + server.load_workspace_config(WORKSPACE).unwrap(), + Some(before) + ); + assert_eq!(server.configuration_head(WORKSPACE, OWNER).unwrap(), None); + assert_eq!( + server + .configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + None + ); + assert_eq!(server_records(&server), server_before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!(feature_schema(&features), schema_before); + assert!( + features + .store + .permission_transfer_receipt(&seed.snapshot.identity) + .unwrap() + .is_none() + ); + assert_eq!( + features + .store + .subject_permissions(&subjects.limited) + .unwrap(), + Some(subjects.limited_source) + ); +} + +#[test] +fn unsupported_legacy_operation_blocks_core_adoption_before_the_real_seal_boundary() { + let (_root, server, features, subjects) = fixture(); + let host = host(); + let unsupported = limited("file.unknown"); + replace( + &features.store, + &subjects.limited, + "unsupported-legacy-source", + vec![unsupported.clone()], + vec![limited("file.read.text")], + ); + let before = server_records(&server); + let legacy_before = legacy_records(&features); + let schema_before = feature_schema(&features); + let seed = migration(&server, &features, &host); + assert!( + seed.diagnostics + .contains(&MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: subjects.limited.clone(), + permission: unsupported, + }) + ); + let request = migration_request(&seed, &host); + let error = adopt_migration(&server, &features, &host, &seed, &request).unwrap_err(); + assert!( + matches!(error, Error::InvalidInput(ref message) if message.contains("require explicit configuration")), + "{error}" + ); + assert_eq!(server_records(&server), before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!(feature_schema(&features), schema_before); + assert!( + features + .store + .permission_transfer_receipt(&seed.snapshot.identity) + .unwrap() + .is_none() + ); + // This is a pre-seal rejection, not a sealed-but-unadopted transfer. + replace( + &features.store, + &subjects.limited, + "writer-after-diagnostic", + vec![], + vec![], + ); +} + +#[test] +fn server_failure_after_seal_rolls_back_and_reopen_resumes_only_the_exact_full_request() { + let (root, db, features, subjects) = fixture(); + let host = host(); + let seed = migration(&db, &features, &host); + let request = migration_request(&seed, &host); + let before = server_records(&db); + let legacy_before = legacy_records(&features); + let identity = features.store.database_identity().unwrap(); + let physical_identity = features.database.identity().clone(); + let old_writer = SubjektivStore::from_database(features.database.clone()).unwrap(); + db.with_conn(|conn| { + conn.execute_batch("CREATE TEMP TRIGGER fail_adoption_receipt BEFORE INSERT ON workspace_configuration_receipts BEGIN SELECT RAISE(ABORT,'injected adoption receipt failure'); END;")?; + Ok(()) + }).unwrap(); + let error = adopt_migration(&db, &features, &host, &seed, &request).unwrap_err(); + assert!( + error + .to_string() + .contains("injected adoption receipt failure"), + "{error}" + ); + assert_eq!( + db.load_workspace_config(WORKSPACE).unwrap(), + Some(seed.original.clone()) + ); + assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + None + ); + assert_eq!(server_records(&db), before); + assert_eq!(legacy_records(&features), legacy_before); + assert!( + db.adopted_subject_policy(WORKSPACE, &subjects.limited) + .is_err() + ); + let fingerprint = request_fingerprint(WORKSPACE, OWNER, &request).unwrap(); + let sealed = features + .store + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!(sealed.transfer_id, fingerprint); + assert_eq!(sealed.snapshot, seed.snapshot); + // This is the same physical gate used by legacy logical configuration writes. + // A Server rollback must not reopen that writer or change its editing base. + let legacy_candidate = db + .evaluate_workspace_config_candidate_with_schema( + WORKSPACE, + &ConfigCommitRequest { + base_digest: seed.original.snapshot.digest.clone(), + changes: vec![ConfigTreeChange::Create { + path: path("late-legacy.txt"), + content_type: ConfigContentType::Text, + content: "must not be stored during pending cutover".into(), + }], + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + schema(), + ) + .unwrap(); + let legacy_write = features + .store + .with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok(db.commit_evaluated_workspace_config(WORKSPACE, &legacy_candidate)) + }); + assert!(matches!( + legacy_write, + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(server_records(&db), before); + assert_eq!( + features + .store + .seal_permission_sources(&identity, &fingerprint, &seed.snapshot.export_digest) + .unwrap(), + sealed + ); + assert!(matches!( + old_writer.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + old_writer.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.limited_source.change_id, + "no-canonical-provider" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + for update in [ + subjects.exact_legacy_update.clone(), + SubjectPermissionsUpdate { + operation_id: "late-old-writer".into(), + expected_change_id: Some(subjects.limited_source.change_id.clone()), + execution: vec![], + delegation: vec![], + }, + ] { + assert!(matches!( + old_writer.set_subject_permissions(&subjects.limited, &update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + old_writer.set_managed_subject_permissions(&subjects.limited, &update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + } + assert!( + features + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", + [&subjects.limited], + )?; + Ok(()) + }) + .is_err() + ); + drop(old_writer); + drop(db); + features.close(); + + let db = server(root.path()); + let features = FeatureFixture::open(&root.path().join("features"), Some(&physical_identity)); + assert_eq!(features.store.database_identity().unwrap(), identity); + let reopened_legacy_write = + features + .store + .with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok(db.commit_evaluated_workspace_config(WORKSPACE, &legacy_candidate)) + }); + assert!(matches!( + reopened_legacy_write, + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert_eq!(server_records(&db), before); + assert_eq!(legacy_records(&features), legacy_before); + assert_eq!( + features + .store + .seal_permission_sources(&identity, &fingerprint, &seed.snapshot.export_digest) + .unwrap(), + sealed + ); + assert!(matches!( + features.store.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + features + .store + .set_subject_permissions(&subjects.limited, &subjects.exact_legacy_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + let resumed = migration(&db, &features, &host); + assert_eq!(resumed.original, seed.original); + assert_eq!(resumed.state, seed.state); + assert_eq!(resumed.snapshot, seed.snapshot); + assert_eq!(migration_request(&resumed, &host), request); + let mut different_id = request.clone(); + different_id.operation_id = "not-the-pending-request".into(); + let error = adopt_migration(&db, &features, &host, &resumed, &different_id).unwrap_err(); + assert!( + error.to_string().contains("permission transfer conflicts"), + "{error}" + ); + let mut different_content = request.clone(); + let existing = path("existing.dcdl"); + let entry = resumed.state.snapshot.get(&existing).unwrap(); + different_content + .source + .changes + .push(ConfigTreeChange::Update { + path: existing, + expected_digest: entry.content_digest.clone(), + content: EXISTING_DATA.replace("count = 7", "count = 8"), + }); + let error = adopt_migration(&db, &features, &host, &resumed, &different_content).unwrap_err(); + assert!( + error.to_string().contains("permission transfer conflicts"), + "{error}" + ); + let mut different_export = request.clone(); + different_export.expected_legacy_export = Some("another-export".into()); + assert!(adopt_migration(&db, &features, &host, &resumed, &different_export).is_err()); + assert!(matches!( + features + .store + .seal_permission_sources(&identity, &fingerprint, "another-export"), + Err(SubjektivError::PermissionTransferConflict) + )); + assert_eq!(server_records(&db), before); + let adopted = adopt_migration(&db, &features, &host, &resumed, &request).unwrap(); + assert_eq!(adopted.state, seed.state); + assert_existing_data_preserved(&seed.original, &adopted.state); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(adopted.history_head.clone()) + ); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, &request.operation_id) + .unwrap(), + Some(adopted.clone()) + ); + assert_eq!( + adopt_migration(&db, &features, &host, &resumed, &request).unwrap(), + adopted + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.unset) + .unwrap(), + None + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.empty) + .unwrap() + .unwrap() + .legacy_read(&host) + .unwrap(), + subjects.empty_source + ); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, &subjects.limited) + .unwrap() + .unwrap() + .legacy_read(&host) + .unwrap(), + subjects.limited_source + ); + for grant in [&subjects.stale_grant, &subjects.live_grant] { + assert_eq!( + features + .store + .worker_execution_grant(&grant.runtime_id, &grant.worker_id) + .unwrap(), + Some(grant.clone()) + ); + } + assert_eq!(legacy_records(&features), legacy_before); +} + +fn edit_request( + server: &SqliteWorkspaceStore, + host: &PermissionHost, + id: &str, + changes: Vec, +) -> ConfigurationAdoptionRequest { + let current = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + ConfigurationAdoptionRequest { + operation_id: id.into(), + expected_head: server.configuration_head(WORKSPACE, OWNER).unwrap(), + expected_catalog_digest: catalog_digest(host).unwrap(), + expected_legacy_export: None, + source: ConfigCommitRequest { + base_digest: current.snapshot.digest, + changes, + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + } +} + +fn adopt_edit( + server: &SqliteWorkspaceStore, + features: &FeatureFixture, + host: &PermissionHost, + request: &ConfigurationAdoptionRequest, +) -> ConfigurationAdoptionResult { + server + .adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + None, + |subject| require_subject(&features.store, subject), + |_| panic!("an ordinary edit must not prepare another legacy transfer"), + ) + .unwrap() +} + +#[test] +fn adopted_source_remains_editable_and_canonical_reads_cannot_widen_or_revive_old_grants() { + let (root, server, features, subjects) = fixture(); + let host = host(); + let seed = migration(&server, &features, &host); + let initial_request = migration_request(&seed, &host); + let initial = adopt_migration(&server, &features, &host, &seed, &initial_request).unwrap(); + let legacy_before = legacy_records(&features); + let identity = features.database.identity().clone(); + drop(server); + features.close(); + let server = self::server(root.path()); + let features = FeatureFixture::open(&root.path().join("features"), Some(&identity)); + assert_eq!( + server.load_workspace_config(WORKSPACE).unwrap(), + Some(initial.state.clone()) + ); + assert_eq!( + server + .configuration_adoption_result(WORKSPACE, OWNER, &initial_request.operation_id) + .unwrap(), + Some(initial.clone()) + ); + assert_eq!(legacy_records(&features), legacy_before); + assert!(matches!( + features.store.subject_permissions(&subjects.limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let canonical = features + .store + .clone() + .with_canonical_permission_source(Arc::new(CanonicalConfigurationSource { + store: server.clone(), + host: self::host(), + })); + assert_eq!( + canonical.subject_permissions(&subjects.unset).unwrap(), + None + ); + assert_eq!( + canonical.subject_permissions(&subjects.empty).unwrap(), + Some(subjects.empty_source.clone()) + ); + assert_eq!( + canonical.subject_permissions(&subjects.limited).unwrap(), + Some(subjects.limited_source.clone()) + ); + let child = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "canonical-child", + ); + canonical.bind_worker_execution_grant(&child).unwrap(); + let mut execution_only = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "execution-is-not-delegation", + ); + execution_only.permissions = vec![limited("file.read")]; + assert!(matches!( + canonical.bind_worker_execution_grant(&execution_only), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let mut wider = grant( + &subjects.limited, + &subjects.limited_source.change_id, + "wider-child", + ); + wider.permissions[0].resource = PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), + path: ".".into(), + recursive: true, + }; + assert!(matches!( + canonical.bind_worker_execution_grant(&wider), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.stale_grant.source_change_id, + "stale-source-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let mut wrong_workdir = child.clone(); + wrong_workdir.worker_id = "other-workdir-child".into(); + wrong_workdir.permissions[0].resource = PermissionResource::Workdir { + working_directory_id: "another-workdir".into(), + path: "only-this.txt".into(), + recursive: false, + }; + assert!(matches!( + canonical.bind_worker_execution_grant(&wrong_workdir), + Err(SubjektivError::AuthorizationDenied(_)) + )); + + let generated = path("permissions/migrated-1.dcdl"); + let state = server.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let entry = state.snapshot.get(&generated).unwrap(); + let revoked_source = format!( + "{{ subjects = [{{ subject_id = {}; execution = {{ revoked = true; }}; delegation = {{ revoked = true; }}; }}]; }}\n", + serde_json::to_string(&subjects.limited).unwrap(), + ); + // A client-chosen operation ID equal to the old change identity must not + // make its retained child grants current again. + let revoke = edit_request( + &server, + &host, + &subjects.limited_source.change_id, + vec![ConfigTreeChange::Update { + path: generated.clone(), + expected_digest: entry.content_digest.clone(), + content: revoked_source, + }], + ); + let revoked = adopt_edit(&server, &features, &host, &revoke); + assert_ne!(revoked.history_head, initial.history_head); + assert_existing_data_preserved(&seed.original, &revoked.state); + let current = canonical + .subject_permissions(&subjects.limited) + .unwrap() + .unwrap(); + assert_ne!(current.change_id, subjects.limited_source.change_id); + assert_ne!(current.change_id, subjects.stale_grant.source_change_id); + assert!(current.execution.is_empty()); + assert!(current.delegation.is_empty()); + let policy = server + .adopted_subject_policy(WORKSPACE, &subjects.limited) + .unwrap() + .unwrap(); + assert_eq!( + policy.legacy_constraints, + Some(subjects.limited_source.clone()) + ); + assert!(policy.source.execution.revoked && policy.source.delegation.revoked); + // Binding replays are history only; neither replay can restore current policy. + canonical.bind_worker_execution_grant(&child).unwrap(); + canonical + .bind_worker_execution_grant(&subjects.live_grant) + .unwrap(); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + &subjects.limited_source.change_id, + "revoked-old-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + canonical.bind_worker_execution_grant(&grant( + &subjects.limited, + ¤t.change_id, + "revoked-current-child" + )), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!( + canonical + .worker_execution_grant("runtime", "revoked-current-child") + .unwrap() + .is_none() + ); + assert_eq!( + adopt_migration(&server, &features, &host, &seed, &initial_request).unwrap(), + initial + ); + assert_eq!( + canonical.subject_permissions(&subjects.limited).unwrap(), + Some(current.clone()) + ); + assert_eq!( + server.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(revoked.history_head) + ); + assert_eq!( + canonical.subject_permissions(&subjects.empty).unwrap(), + Some(subjects.empty_source) + ); + assert_eq!( + canonical.subject_permissions(&subjects.unset).unwrap(), + None + ); + assert_eq!(legacy_records(&features), legacy_before); +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 6113fc670..e013be2f0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,9 +6,10 @@ pub mod backend_job; pub mod companion; pub mod config; pub mod config_adoption; -mod configuration_migration; -pub mod policy_config; pub mod config_source; +mod configuration_migration; +#[cfg(test)] +mod configuration_transfer_tests; #[cfg(test)] mod content_history_tests; pub mod db_executor; @@ -27,6 +28,7 @@ pub mod plugin_plan; pub mod plugin_receipt; pub mod plugin_service; pub mod plugin_settings; +pub mod policy_config; pub mod profile_settings; pub mod prompt_settings; pub mod records; diff --git a/crates/workspace-server/src/policy_config.rs b/crates/workspace-server/src/policy_config.rs index 165c3eef7..b57f53ea9 100644 --- a/crates/workspace-server/src/policy_config.rs +++ b/crates/workspace-server/src/policy_config.rs @@ -3,6 +3,38 @@ //! An omitted Subject is unchanged; removal is explicit. Assignments contain only //! references to Feature-owned Sets and Namespaces. Legacy resource restrictions //! remain separate domain constraints, never payloads on permission keys. +//! +//! Assigning a Subject replaces both execution and delegation assignments for +//! that Subject. An omitted lane is empty, not its old value. `{}` is explicit +//! empty; `revoked = true` denies that lane without altering the other lane. +//! `remove_subjects` removes policy, not the Subject, and never restores a default. +//! Omitting `permissions`, or omitting an individual Subject, preserves policy. +//! Consequently deleting an assignment from source alone is not revocation. +//! +//! For example, when the catalog declares `filesystem.read` and `subject-a` exists: +//! +//! ```text +//! { +//! permissions = { +//! subjects = [{ +//! subject_id = "subject-a"; +//! execution = { +//! namespaces = [{ namespace = "filesystem"; keys = ["filesystem.read"]; }]; +//! }; +//! delegation = {}; +//! }]; +//! remove_subjects = []; +//! }; +//! } as WorkspaceConfigSchema +//! ``` +//! +//! `sets` and `exclude_sets` reference Feature-owned shared definitions; +//! `namespaces` and `exclude_namespaces` reference exact keys in their declared +//! Namespace. This source does not redeclare Sets, infer children from dot +//! prefixes, attach payloads/arguments to keys or issue provider capabilities. +//! Multi-segment keys remain exact. Only catalog-declared children expand them. +//! Unknown references are rejected even in revoked assignments, so reactivation +//! cannot conceal invalid configuration. use std::collections::{BTreeMap, BTreeSet}; @@ -68,8 +100,16 @@ pub struct AssignmentSource { impl AssignmentSource { pub fn assignment(&self) -> PermissionAssignment { let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| { - sets.iter().cloned().map(PermissionReference::Set) - .chain(namespaces.iter().cloned().map(PermissionReference::Namespace)).collect() + sets.iter() + .cloned() + .map(PermissionReference::Set) + .chain( + namespaces + .iter() + .cloned() + .map(PermissionReference::Namespace), + ) + .collect() }; PermissionAssignment { grants: references(&self.sets, &self.namespaces), @@ -112,16 +152,29 @@ pub struct AdoptedSubjectPolicy { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)] pub enum MigrationDiagnostic { - Converted { subject_id: String, operation: String }, - DomainConstraint { subject_id: String, permission: ScopedPermission }, - ExplicitConfigurationRequired { subject_id: String, permission: ScopedPermission }, + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: ScopedPermission, + }, } pub fn source(evaluation: &EvaluationResult) -> Result { - let value = evaluation.projections.first() + let value = evaluation + .projections + .first() .and_then(|p| p.data_json.get("permissions")) - .cloned().unwrap_or_else(|| serde_json::json!({})); - serde_json::from_value(value).map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + serde_json::from_value(value) + .map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) } fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> { @@ -130,10 +183,18 @@ fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Resu let mut assignment = source.assignment(); assignment.revoked = false; let resolution = host.resolve(Some(&assignment)); - if resolution.projection().diagnostics.iter().any(|d| matches!(d, - PermissionDiagnostic::UnknownKey(_) | PermissionDiagnostic::UnknownNamespace(_) - | PermissionDiagnostic::UnknownSet(_))) { - return Err(Error::InvalidInput(format!("invalid permission references: {:?}", resolution.projection().diagnostics))); + if resolution.projection().diagnostics.iter().any(|d| { + matches!( + d, + PermissionDiagnostic::UnknownKey(_) + | PermissionDiagnostic::UnknownNamespace(_) + | PermissionDiagnostic::UnknownSet(_) + ) + }) { + return Err(Error::InvalidInput(format!( + "invalid permission references: {:?}", + resolution.projection().diagnostics + ))); } Ok(()) } @@ -158,7 +219,9 @@ pub fn validate_source( server_api::authorization::validate_authorization_identifier(subject) .map_err(Error::InvalidInput)?; if !subjects.insert(subject) { - return Err(Error::InvalidInput("Subject both assigned and removed, or removed twice".into())); + return Err(Error::InvalidInput( + "Subject both assigned and removed, or removed twice".into(), + )); } require_subject(subject)?; } @@ -172,19 +235,26 @@ pub fn apply( source: &PermissionSource, change_id: &str, ) { - for subject in &source.remove_subjects { policies.remove(subject); } + for subject in &source.remove_subjects { + policies.remove(subject); + } for item in &source.subjects { match policies.get_mut(&item.subject_id) { - Some(previous) if previous.source == *item => {}, + Some(previous) if previous.source == *item => {} Some(previous) => { previous.source = item.clone(); previous.change_id = change_id.to_owned(); - }, + } None => { - policies.insert(item.subject_id.clone(), AdoptedSubjectPolicy { - source: item.clone(), change_id: change_id.to_owned(), legacy_constraints: None, - }); - }, + policies.insert( + item.subject_id.clone(), + AdoptedSubjectPolicy { + source: item.clone(), + change_id: change_id.to_owned(), + legacy_constraints: None, + }, + ); + } } } } @@ -201,36 +271,66 @@ pub fn migrate_subject( let mut lane = |permissions: &[ScopedPermission]| -> Result { let mut by_namespace: BTreeMap> = BTreeMap::new(); for permission in permissions { - let operation = server_api::authorization::canonical_worker_operation(&permission.operation).unwrap_or(&permission.operation); - let key = PermissionKey::parse(operation.to_owned()).map_err(Error::InvalidInput)?; - let assignment = PermissionAssignment { grants: vec![PermissionReference::Namespace(NamespacePermissions { - namespace: key.namespace(), keys: vec![key.clone()], - })], exclusions: vec![], revoked: false }; + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); + let Ok(key) = PermissionKey::parse(operation.to_owned()) else { + diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: subject_id.into(), + permission: permission.clone(), + }); + continue; + }; + let assignment = PermissionAssignment { + grants: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: key.namespace(), + keys: vec![key.clone()], + })], + exclusions: vec![], + revoked: false, + }; let resolution = host.resolve(Some(&assignment)); let exact = resolution.projection().diagnostics.is_empty() && resolution.projection().allowed.len() == 1 && resolution.projection().allowed.contains_key(&key); if !exact { diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { - subject_id: subject_id.into(), permission: permission.clone(), + subject_id: subject_id.into(), + permission: permission.clone(), }); continue; } by_namespace.entry(key.namespace()).or_default().insert(key); diagnostics.push(MigrationDiagnostic::DomainConstraint { - subject_id: subject_id.into(), permission: permission.clone(), + subject_id: subject_id.into(), + permission: permission.clone(), }); } - Ok(AssignmentSource { namespaces: by_namespace.into_iter().map(|(namespace, keys)| NamespacePermissions { - namespace, keys: keys.into_iter().collect(), - }).collect(), ..Default::default() }) + Ok(AssignmentSource { + namespaces: by_namespace + .into_iter() + .map(|(namespace, keys)| NamespacePermissions { + namespace, + keys: keys.into_iter().collect(), + }) + .collect(), + ..Default::default() + }) }; let execution = lane(&legacy.execution)?; let delegation = lane(&legacy.delegation)?; - Ok((AdoptedSubjectPolicy { - source: SubjectAssignmentSource { subject_id: subject_id.into(), execution, delegation }, - change_id: legacy.change_id.clone(), legacy_constraints: Some(legacy.clone()), - }, diagnostics)) + Ok(( + AdoptedSubjectPolicy { + source: SubjectAssignmentSource { + subject_id: subject_id.into(), + execution, + delegation, + }, + change_id: legacy.change_id.clone(), + legacy_constraints: Some(legacy.clone()), + }, + diagnostics, + )) } impl AdoptedSubjectPolicy { @@ -241,12 +341,15 @@ impl AdoptedSubjectPolicy { pub fn legacy_read(&self, host: &PermissionHost) -> Result { let execution = host.resolve(Some(&self.source.execution.assignment())); let delegation = host.resolve(Some(&self.source.delegation.assignment())); - let filter = |allowed: &feature_permissions::PermissionResolution, constraints: &[ScopedPermission]| -> Result> { + let filter = |allowed: &feature_permissions::PermissionResolution, + constraints: &[ScopedPermission]| + -> Result> { let mut represented = BTreeSet::new(); let mut output = vec![]; for permission in constraints { - let operation = server_api::authorization::canonical_worker_operation(&permission.operation) - .unwrap_or(&permission.operation); + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); if let Ok(key) = PermissionKey::parse(operation.to_owned()) { if allowed.allowed.contains_key(&key) { represented.insert(key); @@ -259,12 +362,16 @@ impl AdoptedSubjectPolicy { } Ok(output) }; - let empty = SubjectPermissions { change_id:self.change_id.clone(),execution:vec![],delegation:vec![] }; + let empty = SubjectPermissions { + change_id: self.change_id.clone(), + execution: vec![], + delegation: vec![], + }; let retained = self.legacy_constraints.as_ref().unwrap_or(&empty); Ok(SubjectPermissions { - change_id:self.change_id.clone(), - execution:filter(execution.projection(),&retained.execution)?, - delegation:filter(delegation.projection(),&retained.delegation)?, + change_id: self.change_id.clone(), + execution: filter(execution.projection(), &retained.execution)?, + delegation: filter(delegation.projection(), &retained.delegation)?, }) } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d79a839ff..991472c36 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -60,6 +60,8 @@ mod plugin_host; #[path = "server_plugin_runtime.rs"] mod plugin_runtime; pub use plugin_delivery::InProcessPluginArtifactSource; +#[path = "server_configuration_adoption.rs"] +mod configuration_adoption; #[path = "server_workspace_config.rs"] mod workspace_config; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool { }; resource == "config/source-tree" || resource.starts_with("config/source-tree/") + || resource == "config/adoption" + || resource.starts_with("config/adoption/") || resource == "settings/profiles" || resource.starts_with("settings/profiles/") } @@ -4106,7 +4110,8 @@ fn workspace_server_router(api: WorkspaceServerApi) -> Router { .merge(generated_auth_contract_router(contract_service.clone())) .merge(generated_workspace_catalog_contract_router( contract_service, - )); + )) + .merge(configuration_adoption::routes(api.clone())); catalog.layer(axum::middleware::from_fn_with_state( api, enforce_server_cookie_mutation_origin, @@ -4636,6 +4641,9 @@ impl WorkspaceApi { crate::profile_settings::ProfileConfigSchemaProvider, )) .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) + .with_provider(Arc::new( + crate::policy_config::PermissionConfigSchemaProvider, + )) .with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider)) @@ -16486,22 +16494,13 @@ async fn scoped_get_workspace_config_entry( } async fn scoped_commit_workspace_config_tree( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, + State(_api): State, + AxumPath(_path): AxumPath, + Json(_request): Json, ) -> ApiResult<(StatusCode, Json)> { - api.host_executors - .host()? - .run(move || { - let request = config_commit_request_from_api(request)?; - validate_workspace_scope(&api, &path.workspace_id)?; - let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?; - Ok(( - StatusCode::CREATED, - Json(workspace_config_state_to_api(state)), - )) - }) - .await + Err(Error::WorkspaceConfigConflict( + "source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(), + ).into()) } /// Shared canonical commit and activation boundary for UI and logical config operations. @@ -24198,11 +24197,27 @@ where executor.run(move || runtime.block_on(future)).await } -fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { +fn open_legacy_subjektiv_store(api: &WorkspaceApi) -> ApiResult { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|error| ApiError::from(Error::Store(error.to_string()))) } +fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { + let host = worker::feature::permissions::builtin_permission_host( + feature_permissions::WorkspaceId::parse(api.config.workspace_id.clone()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + ) + .map_err(|error| Error::Config(error.to_string()))?; + Ok( + open_legacy_subjektiv_store(api)?.with_canonical_permission_source(Arc::new( + crate::config_adoption::CanonicalConfigurationSource { + store: api.config_store.as_ref().clone(), + host, + }, + )), + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SubjektivWorkerAuthority { Subject, @@ -40388,6 +40403,7 @@ mod tests { mod workspace_admission_tests; mod workspace_protocol_backpressure_tests; include!("server_workspace_config_tests.rs"); + include!("server_configuration_adoption_tests.rs"); use super::*; use axum::body::{Body, to_bytes}; use axum::http::Request; diff --git a/crates/workspace-server/src/server_configuration_adoption.rs b/crates/workspace-server/src/server_configuration_adoption.rs new file mode 100644 index 000000000..b64baf2d7 --- /dev/null +++ b/crates/workspace-server/src/server_configuration_adoption.rs @@ -0,0 +1,523 @@ +//! Owner-only HTTP boundary for source adoption and the one-way legacy cutover. +//! +//! Every preview and adoption acquires the native Feature authority guard before +//! entering the Server transaction. Subject references are checked against current +//! metadata/deletion fences under its IMMEDIATE transaction, not a preloaded set. +//! Preview and canonical saves never seal: their nonmutating Feature transaction +//! remains held through the Server operation and rolls back when the guard drops. +//! +//! Only migration adoption seals. Seal commits Feature evidence, not Server +//! adoption; the outer connection/lifecycle guard remains held until Server commit +//! or rollback, and no Feature access follows the seal. Legacy logical writers +//! must use that same domain guard and require_unsealed. IMMEDIATE serializes gate +//! users across connections/processes; after seal releases the SQLite write lock, +//! the durable seal rejects legacy writers. This is not a distributed commit: +//! Server failure can leave a pending transfer requiring the exact original retry. +//! Runtime operations must still check current policy and Subject lifecycle. +//! Callbacks must not reenter the Feature store or its canonical source provider. + +use super::*; + +use crate::config_adoption::{ + ConfigurationAdoptionRequest as AdoptionRequest, ConfigurationAdoptionResult as AdoptionResult, + ConfigurationCurrent, ConfigurationMigrationBaseline, ConfigurationPolicyTransfer, + request_fingerprint, +}; +use crate::configuration_migration::{MigrationSeed, preview_migration}; +use crate::policy_config::{AdoptedSubjectPolicy, MigrationDiagnostic}; +use crate::subjektiv::{ + DatabaseIdentity, PermissionTransferReceipt, SubjektivError, SubjektivStore, +}; +use feature_permissions::PermissionHost; +use server_api::{ + ConfigurationAdoptionApi, ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest, + ConfigurationMigrationDiagnostic, ConfigurationPreviewResponse, ConfigurationReconciliation, + ConfigurationSourceResponse, ConfigurationSubjectPolicy, RepositoryApiError, + RuntimePolicyApplication, ServerRequestContext, +}; + +/// Mount on the Server router, under its cookie-mutation-origin middleware. +/// These routes own admission because they bypass Workspace fallback dispatch. +pub(super) fn routes(service: WorkspaceServerApi) -> Router { + let context_service = ServerApiContractService::Server(service.clone()); + let service = Arc::new(service); + Router::new() + .merge(server_api::configuration_adoption_api_axum::configuration_source(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_preview(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_adopt(service.clone())) + .merge(server_api::configuration_adoption_api_axum::configuration_reconcile(service)) + .layer(middleware::from_fn_with_state( + context_service, + attach_server_request_context, + )) +} + +async fn owner_operation( + service: &WorkspaceServerApi, + context: ServerRequestContext, + workspace: String, + operation: impl FnOnce(WorkspaceApi, String) -> Result + Send + 'static, +) -> std::result::Result { + // These owner-only routes are mounted above Workspace fallback dispatch. + // A Runtime proof must not be ignored in favor of a shared human credential. + if context.transport_headers.iter().any(|(name, _)| { + name.eq_ignore_ascii_case(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + }) { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Runtime source proofs cannot authorize owner configuration management".into(), + )) + .into_repository_api_error()); + } + let actor = subject_authorization::administration_actor(&context) + .map_err(ApiError::into_repository_api_error)? + .clone(); + let result: ApiResult = async { + let gate = service.admission_for_workspace(&workspace).await; + let lease = gate.admit(false).ok_or_else(|| { + Error::WorkspaceConfigConflict("Workspace deletion is draining requests".into()) + })?; + crate::db_executor::with_operation_lifetime(Arc::new(lease), async { + // Authenticate against catalog ownership before resolving an API: + // API construction itself can load configuration and Feature stores. + let record = service + .store + .get_workspace(&workspace) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + if record.owner_account_id != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "configuration management requires the Workspace owner".into(), + ) + .into()); + } + if record.state != "active" { + return Err( + Error::WorkspaceConfigConflict("Workspace is not active".into()).into(), + ); + } + let api = service + .api_for_workspace(&workspace) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + require_workspace_owner(&api, &workspace, &actor, "Configuration adoption").await?; + // The admitted lifetime survives HTTP cancellation. The synchronous + // DB job owns native Feature guards through Server commit/rollback. + service + .store + .db_executor() + .run(move || operation(api, actor.account_id)) + .await + .map_err(ApiError::from) + }) + .await + } + .await; + result.map_err(ApiError::into_repository_api_error) +} + +fn conflict(message: &str) -> Error { + Error::WorkspaceConfigConflict(message.into()) +} + +fn request_from_api(request: ConfigurationAdoptionRequest) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id) + .map_err(Error::InvalidInput)?; + Ok(AdoptionRequest { + operation_id: request.operation_id, + expected_head: request.expected_head, + expected_catalog_digest: request.expected_catalog_digest, + expected_legacy_export: request.expected_legacy_export, + source: config_commit_request_from_api(request.source)?, + }) +} + +fn host(workspace: &str) -> Result { + let workspace = + feature_permissions::WorkspaceId::parse(workspace).map_err(Error::InvalidInput)?; + worker::feature::permissions::builtin_permission_host(workspace) + .map_err(|error| Error::Config(error.to_string())) +} + +fn raw_legacy(api: &WorkspaceApi) -> Result { + // Intentionally do not call open_subjektiv_store: the normal helper connects + // the canonical provider. Transfer preparation must retain the raw guard. + open_legacy_subjektiv_store(api).map_err(|error| error.error) +} + +fn transfer_error(error: SubjektivError) -> Error { + match error { + SubjektivError::PermissionTransferConflict | SubjektivError::PermissionAuthoritySealed => { + conflict("legacy permission transfer changed or is sealed for another request") + } + error => subjektiv_store_error(error), + } +} + +fn source_snapshot(api: &WorkspaceApi, account: &str) -> Result { + api.config_store + .configuration_current(&api.config.workspace_id, account) +} + +fn saved_result( + api: &WorkspaceApi, + account: &str, + request: &AdoptionRequest, +) -> Result> { + // Core authenticates the current owner and compares the complete request. + api.config_store + .configuration_request_result(&api.config.workspace_id, account, request) +} + +fn pending_transfer( + legacy: &SubjektivStore, + workspace: &str, + account: &str, + request: &AdoptionRequest, +) -> Result> { + let identity = legacy.database_identity().map_err(transfer_error)?; + if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID { + return Err(Error::WorkspaceIdMismatch); + } + // Read-only guarded lookup verifies native identity; lookup must never seal. + let receipt = legacy + .permission_transfer_receipt(&identity) + .map_err(transfer_error)?; + if let Some(receipt) = &receipt { + if receipt.snapshot.identity != identity + || receipt.transfer_id != request_fingerprint(workspace, account, request)? + || request.expected_legacy_export.as_deref() + != Some(receipt.snapshot.export_digest.as_str()) + { + return Err(conflict( + "sealed transfer belongs to another adoption request", + )); + } + } + Ok(receipt) +} + +fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> { + ConfigurationMigrationBaseline { + original: &seed.original, + state: &seed.state, + policies: &seed.policies, + diagnostics: &seed.diagnostics, + export_digest: &seed.snapshot.export_digest, + } +} + +struct Preparation { + host: PermissionHost, + schema: ::config_source::WorkspaceConfigSchemaBundle, + legacy: SubjektivStore, + identity: DatabaseIdentity, + seed: Option, +} + +fn prepare(api: &WorkspaceApi, account: &str, request: &AdoptionRequest) -> Result { + let current = source_snapshot(api, account)?; + if current.canonical && request.expected_legacy_export.is_some() { + return Err(conflict( + "legacy transfer is already adopted; reload current source", + )); + } + let host = host(&api.config.workspace_id)?; + let schema = api.config_schema_registry.compose()?; + let legacy = raw_legacy(api)?; + let sealed = if current.canonical { + None + } else { + pending_transfer(&legacy, &api.config.workspace_id, account, request)? + }; + let seed = if current.canonical { + None + } else { + let seed = preview_migration( + &api.config_store, + &legacy, + &api.config.workspace_id, + schema.clone(), + &host, + )?; + if sealed + .as_ref() + .is_some_and(|receipt| receipt.snapshot != seed.snapshot) + { + return Err(conflict( + "sealed transfer differs from the evaluated migration snapshot", + )); + } + Some(seed) + }; + // Observe identity outside the guarded Server operation. Inside the native + // gate, use only authority methods; reopening this store would reenter it. + let identity = match &seed { + Some(seed) => seed.snapshot.identity.clone(), + None => legacy.database_identity().map_err(transfer_error)?, + }; + Ok(Preparation { + host, + schema, + legacy, + identity, + seed, + }) +} + +fn policy_to_api(policy: AdoptedSubjectPolicy) -> ConfigurationSubjectPolicy { + ConfigurationSubjectPolicy { + subject_id: policy.source.subject_id, + change_id: policy.change_id, + execution: policy.source.execution.assignment(), + delegation: policy.source.delegation.assignment(), + legacy_constraints: policy.legacy_constraints, + } +} + +fn diagnostic_to_api(diagnostic: MigrationDiagnostic) -> ConfigurationMigrationDiagnostic { + match diagnostic { + MigrationDiagnostic::Converted { + subject_id, + operation, + } => ConfigurationMigrationDiagnostic::Converted { + subject_id, + operation, + }, + MigrationDiagnostic::DomainConstraint { + subject_id, + permission, + } => ConfigurationMigrationDiagnostic::DomainConstraint { + subject_id, + permission, + }, + MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id, + permission, + } => ConfigurationMigrationDiagnostic::ExplicitConfigurationRequired { + subject_id, + permission, + }, + } +} + +fn receipt_to_api(result: AdoptionResult) -> ConfigurationAdoptionReceipt { + ConfigurationAdoptionReceipt { + workspace_id: result.workspace_id, + operation_id: result.operation_id, + history_head: result.history_head, + state: workspace_config_state_to_api(result.state), + source_stored: result.source_stored, + policy_committed: result.policy_committed, + runtime_application: match result.runtime_application { + crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck => { + RuntimePolicyApplication::NextCurrentPolicyCheck + } + }, + } +} + +impl ConfigurationAdoptionApi for WorkspaceServerApi { + async fn configuration_source( + &self, + context: ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, |api, account| { + let current = source_snapshot(&api, &account)?; + let host = host(&api.config.workspace_id)?; + let catalog_digest = crate::config_adoption::catalog_digest(&host)?; + let (state, expected_legacy_export, policies, diagnostics) = if current.canonical { + (current.state, None, current.policies, vec![]) + } else { + let legacy = raw_legacy(&api)?; + let seed = preview_migration( + &api.config_store, + &legacy, + &api.config.workspace_id, + api.config_schema_registry.compose()?, + &host, + )?; + let observed = source_snapshot(&api, &account)?; + if observed.canonical + || observed.history_head != current.history_head + || observed.state != seed.original + { + return Err(conflict( + "configuration changed during migration preview; reload source", + )); + } + ( + seed.state, + Some(seed.snapshot.export_digest), + seed.policies, + seed.diagnostics, + ) + }; + Ok(ConfigurationSourceResponse { + state: workspace_config_state_to_api(state), + history_head: current.history_head, + source_stored: current.canonical, + catalog: host.catalog(), + catalog_digest, + expected_legacy_export, + policies: policies.into_values().map(policy_to_api).collect(), + migration_diagnostics: diagnostics.into_iter().map(diagnostic_to_api).collect(), + }) + }) + .await + } + + async fn configuration_preview( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + // Detect operation-ID reuse even though preview itself never persists. + saved_result(&api, &account, &request)?; + let Preparation { + host, + schema, + legacy, + identity, + seed, + } = prepare(&api, &account, &request)?; + let baseline = seed.as_ref().map(baseline); + // Preview never seals. The native read-only IMMEDIATE transaction + // fences metadata updates through core validation, then rolls back. + let preview = legacy + .with_permission_authority_guard(&identity, |authority| { + Ok(api.config_store.preview_configuration_adoption( + &api.config.workspace_id, + &account, + &request, + schema, + &host, + baseline.as_ref(), + |subject| authority.require_subject(subject).map_err(transfer_error), + )) + }) + .map_err(transfer_error)??; + Ok(ConfigurationPreviewResponse { + expected_head: preview.expected_head, + state: workspace_config_state_to_api(preview.state), + changed_paths: preview.changed_paths, + changed_subjects: preview.changed_subjects, + }) + }) + .await + } + + async fn configuration_adopt( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(receipt_to_api(result)); + } + let Preparation { + host, + schema, + legacy, + identity, + seed, + } = prepare(&api, &account, &request)?; + let baseline = seed.as_ref().map(baseline); + // Feature connection + IMMEDIATE transaction always precede Server. + // Never call store-level Feature APIs from inside this closure. + let result = legacy + .with_permission_authority_guard(&identity, |authority| { + // Keep Server failures as the inner Result. Only native gate + // acquisition/finalization failures belong to the outer lane. + Ok(api.config_store.adopt_configuration( + &api.config.workspace_id, + &account, + &request, + schema, + &host, + baseline.as_ref(), + |subject| authority.require_subject(subject).map_err(transfer_error), + |fingerprint| { + // Canonical saves must not seal. Their Feature transaction + // stays nonmutating until guard-drop rollback after core. + let seed = seed.as_ref().ok_or_else(|| { + conflict("canonical adoption must not prepare a legacy transfer") + })?; + // Core completes every reference check before this final + // Feature access. One method verifies a retained seal or + // creates the first one; the rest is in-memory work only. + let receipt = authority + .seal_permission_sources(fingerprint, &seed.snapshot.export_digest) + .map_err(transfer_error)?; + if receipt.transfer_id != fingerprint + || receipt.snapshot != seed.snapshot + { + return Err(conflict( + "sealed transfer differs from the evaluated migration snapshot", + )); + } + Ok(ConfigurationPolicyTransfer { + transfer_identity: receipt.transfer_id, + physical_identity: receipt.snapshot.identity.physical_id, + export_digest: receipt.snapshot.export_digest, + policies: seed.policies.clone(), + }) + }, + )) + }) + .map_err(transfer_error)??; + Ok(receipt_to_api(result)) + }) + .await + } + + async fn configuration_reconcile( + &self, + context: ServerRequestContext, + workspace_id: String, + request: ConfigurationAdoptionRequest, + ) -> std::result::Result { + owner_operation(self, context, workspace_id, move |api, account| { + let request = request_from_api(request)?; + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }); + } + // Canonical operations do not use the old transfer. A missing normal + // save receipt is not evidence about the unrelated initial cutover. + if source_snapshot(&api, &account)?.canonical { + return Ok(match saved_result(&api, &account, &request)? { + Some(result) => ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }, + None => ConfigurationReconciliation::NotObserved, + }); + } + // No draft generation, validation against today's head/catalog, seal, + // or adoption here: this is observation of the exact original input. + let legacy = raw_legacy(&api)?; + let pending = pending_transfer(&legacy, &api.config.workspace_id, &account, &request)?; + // Adoption may have committed between the first lookup and Feature read. + if let Some(result) = saved_result(&api, &account, &request)? { + return Ok(ConfigurationReconciliation::Adopted { + receipt: receipt_to_api(result), + }); + } + Ok(match pending { + Some(receipt) => ConfigurationReconciliation::PendingTransfer { + transfer_identity: receipt.transfer_id, + export_digest: receipt.snapshot.export_digest, + }, + None => ConfigurationReconciliation::NotObserved, + }) + }) + .await + } +} diff --git a/crates/workspace-server/src/server_configuration_adoption_tests.rs b/crates/workspace-server/src/server_configuration_adoption_tests.rs new file mode 100644 index 000000000..12ab1c5dc --- /dev/null +++ b/crates/workspace-server/src/server_configuration_adoption_tests.rs @@ -0,0 +1,945 @@ +// Included inside server::tests to reuse its catalog, identity and storage fixtures. +mod configuration_adoption_http_integration { + //! Ordinary Server library integration tests: real catalog router (including + //! cookie-origin middleware), owner/session lookup, builtin permission catalog, + //! migration, guarded Feature storage and Server SQLite adoption. Only unused + //! Runtime execution is the existing deterministic fixture. Router oneshot is + //! the HTTP boundary; these are not TCP-loss, OS-crash or store-fault tests. + //! The separate configuration_transfer/config_adoption suites own store faults. + use super::*; + use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + }; + use server_api::{ + ConfigCommitRequest as SourceRequest, ConfigTreeChange as Change, + ConfigurationAdoptionReceipt as Receipt, ConfigurationAdoptionRequest as Adoption, + ConfigurationPreviewResponse as Preview, ConfigurationReconciliation as Reconciliation, + ConfigurationSourceResponse as Source, RuntimePolicyApplication, + }; + + const OWNER_SUFFIX: &str = "adoption-http-owner"; + const COOKIE_TOKEN: &str = "adoption-http-browser-session"; + const MARKER: &str = "notes/adoption-http.txt"; + + struct Fixture { + api: WorkspaceApi, + app: Router, + owner: String, + foreign: String, + owner_account: String, + cookie: String, + origin: String, + subject: String, + legacy: crate::subjektiv::SubjektivStore, + legacy_update: SubjectPermissionsUpdate, + legacy_policy: SubjectPermissions, + runtime_identity: RuntimeIdentityMaterial, + _dir: tempfile::TempDir, + } + + impl Fixture { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let owner_account = api + .store + .get_workspace(TEST_WORKSPACE_ID) + .await + .unwrap() + .unwrap() + .owner_account_id; + let owner = seed_test_workspace_owner_token(api.store.as_ref(), OWNER_SUFFIX).await; + let foreign = seed_test_api_token(api.store.as_ref(), "adoption-http-nonowner"); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash(COOKIE_TOKEN), + session_id: "adoption-http-session-id".into(), + user_id: format!("user-{OWNER_SUFFIX}"), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &api.config.auth; + let origin = origin.clone(); + let cookie = format!("{cookie_name}={COOKIE_TOKEN}"); + + // Open the real raw Feature store before adoption, retaining a handle + // to verify that cutover fences already-open legacy writers too. + let legacy = open_legacy_subjektiv_store(&api).unwrap(); + let subject = legacy + .create_subject(crate::subjektiv::SubjectRole::new("adoption-http").unwrap()) + .unwrap() + .id; + let legacy_update = SubjectPermissionsUpdate { + operation_id: "adoption-http-existing-policy".into(), + expected_change_id: legacy + .subject_permissions(&subject) + .unwrap() + .map(|policy| policy.change_id), + execution: vec![workspace_permission("ticket.query")], + delegation: vec![workspace_permission("ticket.create")], + }; + let legacy_policy = legacy + .set_subject_permissions(&subject, &legacy_update) + .unwrap(); + + let runtime_identity = + RuntimeIdentityMaterial::generate("adoption-http-runtime").unwrap(); + configure_runtime_request_auth(&mut api, &runtime_identity, "adoption-http-runtime"); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + // Reuse the fixture API, not a second Runtime with the same store lease. + // Catalog ownership and credential resolution remain production code. + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + Self { + api, + app, + owner, + foreign, + owner_account, + cookie, + origin, + subject, + legacy, + legacy_update, + legacy_policy, + runtime_identity, + _dir: dir, + } + } + + fn path(suffix: &str) -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption{suffix}") + } + + fn bearer( + &self, + token: &str, + method: &str, + suffix: &str, + body: Option, + ) -> Request { + Request::builder() + .method(method) + .uri(Self::path(suffix)) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() + } + + fn browser( + &self, + method: &str, + suffix: &str, + body: Option, + origin: Option<&str>, + ) -> Request { + let mut request = Request::builder() + .method(method) + .uri(Self::path(suffix)) + .header(axum::http::header::COOKIE, &self.cookie) + .header(CONTENT_TYPE, "application/json"); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + request + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() + } + + async fn call(&self, request: Request) -> (StatusCode, Value) { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let body = serde_json::from_slice(&bytes).unwrap_or_else(|_| { + panic!( + "expected JSON response ({status}): {}", + String::from_utf8_lossy(&bytes) + ) + }); + (status, body) + }) + .await + .expect("bounded adoption HTTP request") + } + + async fn owner_call( + &self, + suffix: &str, + request: &Adoption, + expected: StatusCode, + ) -> Value { + let (status, body) = self + .call(self.bearer( + &self.owner, + "POST", + suffix, + Some(serde_json::to_value(request).unwrap()), + )) + .await; + assert_eq!(status, expected, "{suffix}: {body}"); + body + } + + async fn source(&self) -> Source { + let (status, body) = self.call(self.bearer(&self.owner, "GET", "", None)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + serde_json::from_value(body).unwrap() + } + + async fn commit(&self, request: &Adoption) -> Receipt { + serde_json::from_value(self.owner_call("/commit", request, StatusCode::OK).await) + .unwrap() + } + + async fn adopt_draft(&self, id: &str) -> Receipt { + let draft = self.source().await; + self.commit(&request(&draft, id, vec![])).await + } + + // Re-read durable authorities, not cached HTTP responses. The raw export + // API is guarded by FeatureStorage and checks its physical identity. + fn durable(&self) -> Value { + let current = self + .api + .config_store + .configuration_current(TEST_WORKSPACE_ID, &self.owner_account) + .unwrap(); + let identity = self.legacy.database_identity().unwrap(); + let receipts: Vec<(String, String, String)> = self.api.config_store.with_conn(|conn| { + let mut query = conn.prepare( + "SELECT operation_id,request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 ORDER BY operation_id", + )?; + let rows = query.query_map([TEST_WORKSPACE_ID], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + Ok(rows.collect::, _>>()?) + }).unwrap(); + json!({ + "state": current.state, + "history_head": current.history_head, + "canonical": current.canonical, + "policies": current.policies, + "receipts": receipts, + "legacy_export": self.legacy.preview_permission_transfer(&identity).unwrap(), + "legacy_receipt": self.legacy.permission_transfer_receipt(&identity).unwrap(), + }) + } + + fn assert_legacy_writable(&self) { + // Exact replay is accepted by an unsealed writer, but rejected after + // cutover even on an already-open handle. It does not create a change. + assert_eq!( + self.legacy + .set_subject_permissions(&self.subject, &self.legacy_update) + .unwrap(), + self.legacy_policy + ); + } + + fn assert_legacy_sealed(&self) { + assert!(matches!( + self.legacy + .set_subject_permissions(&self.subject, &self.legacy_update), + Err(crate::subjektiv::SubjektivError::PermissionAuthoritySealed), + )); + } + } + + fn core_request(request: &Adoption) -> crate::config_adoption::ConfigurationAdoptionRequest { + crate::config_adoption::ConfigurationAdoptionRequest { + operation_id: request.operation_id.clone(), + expected_head: request.expected_head.clone(), + expected_catalog_digest: request.expected_catalog_digest.clone(), + expected_legacy_export: request.expected_legacy_export.clone(), + source: config_commit_request_from_api(request.source.clone()).unwrap(), + } + } + + fn assignment(operation: &str) -> feature_permissions::PermissionAssignment { + feature_permissions::PermissionAssignment { + grants: vec![feature_permissions::PermissionReference::Namespace( + feature_permissions::NamespacePermissions { + namespace: feature_permissions::NamespaceId::parse("ticket").unwrap(), + keys: vec![feature_permissions::PermissionKey::parse(operation).unwrap()], + }, + )], + exclusions: vec![], + revoked: false, + } + } + + fn workspace_permission(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workspace { + workspace_id: TEST_WORKSPACE_ID.into(), + }, + } + } + + fn request(source: &Source, id: &str, changes: Vec) -> Adoption { + Adoption { + operation_id: id.into(), + expected_head: source.history_head.clone(), + expected_catalog_digest: source.catalog_digest.clone(), + expected_legacy_export: source.expected_legacy_export.clone(), + source: SourceRequest { + base_digest: source.state.snapshot.digest.clone(), + changes, + entrypoints: source.state.contract.entrypoints.clone(), + }, + } + } + + fn update(source: &Source, path: &str, content: &str) -> Change { + Change::Update { + path: path.into(), + expected_digest: source.state.snapshot.entries[path].content_digest.clone(), + content: content.into(), + } + } + + fn create_marker(content: &str) -> Change { + Change::Create { + path: MARKER.into(), + content_type: server_api::ConfigContentType::Text, + content: content.into(), + } + } + + fn permission_path(source: &Source) -> String { + source + .state + .snapshot + .entries + .keys() + .find(|path| path.starts_with("permissions/migrated") && path.ends_with(".dcdl")) + .expect("migration generated editable permission source") + .clone() + } + + fn permission_source(subject: &str, execution: &str, delegation: &str) -> String { + format!( + r#"{{ + subjects = [{{ + subject_id = {subject:?}; + execution = {{ namespaces = [{{ namespace = "ticket"; keys = [{execution:?}]; }}]; }}; + delegation = {{ namespaces = [{{ namespace = "ticket"; keys = [{delegation:?}]; }}]; }}; + }}]; + remove_subjects = []; +}} +"# + ) + } + + #[tokio::test] + async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() { + let fixture = Fixture::new().await; + let before = fixture.durable(); + let draft = fixture.source().await; + assert!(!draft.source_stored); + assert_eq!(draft.history_head, None); + assert!(draft.expected_legacy_export.is_some()); + assert_ne!( + draft.state.snapshot.digest, + before["state"]["snapshot"]["digest"] + ); + assert_eq!(draft.catalog.workspace.as_str(), TEST_WORKSPACE_ID); + assert!(draft.catalog.namespaces.iter().any(|namespace| { + namespace + .declaration + .permissions + .iter() + .any(|permission| permission.key.as_str() == "ticket.query") + })); + assert_eq!(draft.policies.len(), 1); + assert_eq!(draft.policies[0].change_id, fixture.legacy_policy.change_id); + assert_eq!( + fixture.durable(), + before, + "GET is a read, not a save or seal" + ); + fixture.assert_legacy_writable(); + + let path = permission_path(&draft); + let edited = format!( + "{}\n# Owner edited the migration draft before first adoption.\n", + draft.state.snapshot.entries[&path].content + ); + let adoption = request( + &draft, + "edited-migration-draft", + vec![update(&draft, &path, &edited)], + ); + let preview: Preview = serde_json::from_value( + fixture + .owner_call("/preview", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(preview.expected_head, draft.history_head); + assert!(preview.changed_paths.contains(&path)); + assert!( + preview.state.snapshot.entries[&path] + .content + .contains("# Owner edited the migration draft before first adoption.") + ); + assert_eq!( + fixture.durable(), + before, + "preview must not seal or persist the draft" + ); + fixture.assert_legacy_writable(); + + // Exercise the real Browser session and configured Origin on commit. + let (status, wire) = fixture + .call(fixture.browser( + "POST", + "/commit", + Some(serde_json::to_value(&adoption).unwrap()), + Some(&fixture.origin), + )) + .await; + assert_eq!(status, StatusCode::OK, "{wire}"); + assert_eq!(wire["runtime_application"], "next_current_policy_check"); + let receipt: Receipt = serde_json::from_value(wire).unwrap(); + assert_eq!( + receipt.runtime_application, + RuntimePolicyApplication::NextCurrentPolicyCheck + ); + assert!(receipt.source_stored && receipt.policy_committed); + assert_eq!(receipt.workspace_id, TEST_WORKSPACE_ID); + assert_eq!(receipt.operation_id, adoption.operation_id); + assert_eq!(receipt.state, preview.state); + let stored = fixture.source().await; + assert!(stored.source_stored); + assert_eq!(stored.expected_legacy_export, None); + assert_eq!(stored.state, receipt.state); + assert_eq!( + stored.history_head.as_deref(), + Some(receipt.history_head.as_str()) + ); + assert_eq!( + stored.policies, draft.policies, + "source-only edit preserves policy and change IDs" + ); + let current = fixture + .api + .config_store + .configuration_current(TEST_WORKSPACE_ID, &fixture.owner_account) + .unwrap(); + assert!(current.canonical); + assert_eq!(workspace_config_state_to_api(current.state), receipt.state); + assert_eq!( + open_subjektiv_store(&fixture.api) + .unwrap() + .subject_permissions(&fixture.subject) + .unwrap(), + Some(fixture.legacy_policy.clone()) + ); + let identity = fixture.legacy.database_identity().unwrap(); + let transfer = fixture + .legacy + .permission_transfer_receipt(&identity) + .unwrap() + .unwrap(); + assert_eq!( + Some(transfer.snapshot.export_digest), + draft.expected_legacy_export + ); + assert_eq!(transfer.snapshot.identity, identity); + let saved = fixture + .api + .config_store + .configuration_request_result( + TEST_WORKSPACE_ID, + &fixture.owner_account, + &core_request(&adoption), + ) + .unwrap() + .unwrap(); + assert_eq!(saved.history_head, receipt.history_head); + assert_eq!(workspace_config_state_to_api(saved.state), receipt.state); + assert_eq!( + saved.runtime_application, + crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck + ); + assert_eq!( + transfer.transfer_id, + crate::config_adoption::request_fingerprint( + TEST_WORKSPACE_ID, + &fixture.owner_account, + &core_request(&adoption), + ) + .unwrap() + ); + fixture.assert_legacy_sealed(); + + // Then edit actual builtin operation assignments through the same routes. + let changed = permission_source(&fixture.subject, "ticket.create", "ticket.query"); + let policy_edit = request( + &stored, + "owner-policy-edit", + vec![update(&stored, &path, &changed)], + ); + let preview: Preview = serde_json::from_value( + fixture + .owner_call("/preview", &policy_edit, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(preview.changed_subjects, vec![fixture.subject.clone()]); + let next = fixture.commit(&policy_edit).await; + assert_eq!(next.state, preview.state); + assert_ne!(next.history_head, receipt.history_head); + let stored = fixture.source().await; + assert_eq!(stored.state, next.state); + let policy = stored + .policies + .iter() + .find(|policy| policy.subject_id == fixture.subject) + .unwrap(); + assert_eq!(policy.execution, assignment("ticket.create")); + assert_eq!(policy.delegation, assignment("ticket.query")); + assert_ne!(policy.change_id, fixture.legacy_policy.change_id); + let adopted = fixture + .api + .config_store + .adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject) + .unwrap() + .unwrap(); + assert_eq!(adopted.source.execution.assignment(), policy.execution); + assert_eq!(adopted.source.delegation.assignment(), policy.delegation); + assert_eq!(adopted.change_id, policy.change_id); + // New payload-free keys cannot fabricate broader legacy resource scopes. + assert_eq!( + policy.legacy_constraints, + Some(fixture.legacy_policy.clone()) + ); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn competing_http_requests_reject_stale_head_even_after_content_returns_to_a() { + let fixture = Fixture::new().await; + fixture.adopt_draft("aba-migrate").await; + let initial = fixture.source().await; + fixture + .commit(&request(&initial, "aba-a", vec![create_marker("A")])) + .await; + let a = fixture.source().await; + // Two independent requests are built from the same observed head. + let stale = request(&a, "aba-losing-editor", vec![update(&a, MARKER, "loser")]); + let b = request(&a, "aba-b", vec![update(&a, MARKER, "B")]); + fixture.commit(&b).await; + let before = fixture.durable(); + fixture + .owner_call("/commit", &stale, StatusCode::CONFLICT) + .await; + assert_eq!(fixture.durable(), before); + let b = fixture.source().await; + fixture + .commit(&request(&b, "aba-return-a", vec![update(&b, MARKER, "A")])) + .await; + let returned = fixture.source().await; + assert_eq!( + returned.state.snapshot, a.state.snapshot, + "the content really returned to A" + ); + assert_ne!( + returned.history_head, a.history_head, + "editing history did not return to A" + ); + let before = fixture.durable(); + fixture + .owner_call("/preview", &stale, StatusCode::CONFLICT) + .await; + fixture + .owner_call("/commit", &stale, StatusCode::CONFLICT) + .await; + assert_eq!( + fixture.durable(), + before, + "ABA rejection leaves source, policy and receipts intact" + ); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &stale, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(reconciled, Reconciliation::NotObserved); + assert_eq!(fixture.durable(), before); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn full_request_reconcile_recovers_received_commit_result_without_reapplying_old_policy() + { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let adoption = request(&draft, "response-lost-after-commit", vec![]); + let before = fixture.durable(); + let missing: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(missing, Reconciliation::NotObserved); + assert_eq!(fixture.durable(), before); + fixture.assert_legacy_writable(); + + // Receive a committed HTTP result, then model the consumer losing it. + // Reconciliation uses the retained original request, not result-derived input. + let received_result = fixture + .owner_call("/commit", &adoption, StatusCode::OK) + .await; + let after_commit = fixture.durable(); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + let expected: Receipt = serde_json::from_value(received_result).unwrap(); + assert_eq!( + reconciled, + Reconciliation::Adopted { + receipt: expected.clone() + } + ); + assert_eq!(fixture.commit(&adoption).await, expected); + assert_eq!( + fixture.durable(), + after_commit, + "reconcile and exact retry are not new adoptions" + ); + + let current = fixture.source().await; + let path = permission_path(¤t); + // Explicit removal makes old-receipt resurrection observable. + let removal = format!( + "{{ subjects = []; remove_subjects = [{:?}]; }}\n", + fixture.subject + ); + fixture + .commit(&request( + ¤t, + "later-policy-removal", + vec![update(¤t, &path, &removal)], + )) + .await; + assert!( + fixture + .api + .config_store + .adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject) + .unwrap() + .is_none() + ); + let latest = fixture.durable(); + let reconciled: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!( + reconciled, + Reconciliation::Adopted { + receipt: expected.clone() + } + ); + assert_eq!(fixture.commit(&adoption).await, expected); + assert_eq!( + fixture.durable(), + latest, + "historical receipt must not resurrect removed policy or head" + ); + assert!( + open_subjektiv_store(&fixture.api) + .unwrap() + .subject_permissions(&fixture.subject) + .unwrap() + .is_none() + ); + fixture.assert_legacy_sealed(); + } + + #[tokio::test] + async fn reconcile_rejects_same_operation_id_with_any_changed_request_fingerprint() { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let adoption = request(&draft, "fingerprint-migration", vec![]); + let receipt = fixture.commit(&adoption).await; + let before = fixture.durable(); + let mut variants = Vec::new(); + let mut changed = adoption.clone(); + changed.expected_head = Some(receipt.history_head.clone()); + variants.push(("head", changed)); + let mut changed = adoption.clone(); + changed.expected_catalog_digest.push_str("-different"); + variants.push(("catalog", changed)); + let mut changed = adoption.clone(); + changed.expected_legacy_export = None; + variants.push(("legacy export", changed)); + let mut changed = adoption.clone(); + changed.source.base_digest.push_str("-different"); + variants.push(("base content", changed)); + let mut changed = adoption.clone(); + changed + .source + .changes + .push(create_marker("not the same request")); + variants.push(("changes", changed)); + let mut changed = adoption.clone(); + changed.source.entrypoints.push("other.dcdl".into()); + variants.push(("entrypoints", changed)); + for (field, changed) in variants { + fixture + .owner_call("/reconcile", &changed, StatusCode::CONFLICT) + .await; + fixture + .owner_call("/commit", &changed, StatusCode::CONFLICT) + .await; + assert_eq!( + fixture.durable(), + before, + "changed {field} must not adopt or modify a receipt" + ); + } + let exact: Reconciliation = serde_json::from_value( + fixture + .owner_call("/reconcile", &adoption, StatusCode::OK) + .await, + ) + .unwrap(); + assert_eq!(exact, Reconciliation::Adopted { receipt }); + assert_eq!(fixture.durable(), before); + } + + #[tokio::test] + async fn http_owner_and_cookie_origin_denials_leave_all_authorities_and_legacy_writer_intact() { + let fixture = Fixture::new().await; + for canonical in [false, true] { + let source = fixture.source().await; + assert_eq!(source.source_stored, canonical); + let change = if canonical { + update(&source, MARKER, "second authorized value") + } else { + create_marker("first authorized value") + }; + let adoption = request( + &source, + &format!("denied-cutover-{canonical}"), + vec![change], + ); + let wire = serde_json::to_value(&adoption).unwrap(); + let before = fixture.durable(); + for (method, suffix) in [ + ("GET", ""), + ("POST", "/preview"), + ("POST", "/commit"), + ("POST", "/reconcile"), + ] { + let body = (method == "POST").then(|| wire.clone()); + let anonymous = Request::builder() + .method(method) + .uri(Fixture::path(suffix)) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.as_ref().map(Value::to_string).unwrap_or_default(), + )) + .unwrap(); + let runtime = runtime_source_request( + &fixture.runtime_identity, + None, + method, + &Fixture::path(suffix), + body.as_ref() + .map(|value| serde_json::to_vec(value).unwrap()) + .unwrap_or_default(), + ); + let mut runtime_with_owner = runtime_source_request( + &fixture.runtime_identity, + None, + method, + &Fixture::path(suffix), + body.as_ref() + .map(|value| serde_json::to_vec(value).unwrap()) + .unwrap_or_default(), + ); + runtime_with_owner.headers_mut().insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", fixture.owner).parse().unwrap(), + ); + for (principal, request) in [ + ( + "nonowner", + fixture.bearer(&fixture.foreign, method, suffix, body.clone()), + ), + ("unauthenticated", anonymous), + ("runtime proof", runtime), + // A valid owner credential must not hide the Runtime source. + ("runtime proof plus owner bearer", runtime_with_owner), + ] { + let (status, response) = fixture.call(request).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{principal} {method} {suffix}: {response}" + ); + assert_eq!(fixture.durable(), before, "{principal} {suffix}"); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + if method == "POST" { + for origin in [None, Some("https://attacker.invalid")] { + let (status, response) = fixture + .call(fixture.browser(method, suffix, body.clone(), origin)) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "cookie {origin:?} {suffix}: {response}" + ); + assert_eq!(fixture.durable(), before); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + } + } + // A Browser GET does not require a mutation Origin, and the same + // valid adoption remains possible after every denied request. + let (status, response) = fixture.call(fixture.browser("GET", "", None, None)).await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!(serde_json::from_value::(response).unwrap(), source); + fixture.commit(&adoption).await; + fixture.assert_legacy_sealed(); + } + } + + #[tokio::test] + async fn invalid_http_source_and_old_source_tree_writer_cannot_partially_adopt() { + let fixture = Fixture::new().await; + let draft = fixture.source().await; + let path = permission_path(&draft); + let before = fixture.durable(); + let invalid = [ + ("syntax", "{ subjects = [".to_owned()), + ( + "unknown-key", + permission_source( + &fixture.subject, + "ticket.not_a_builtin_operation", + "ticket.query", + ), + ), + ( + "missing-subject", + permission_source( + "nonexistent-adoption-subject", + "ticket.create", + "ticket.query", + ), + ), + ( + "unknown-set", + format!( + "{{ subjects = [{{ subject_id = {:?}; execution = {{ sets = [\"unknown-adoption-set\"]; }}; delegation = {{}}; }}]; remove_subjects = []; }}", + fixture.subject + ), + ), + ]; + for (name, content) in invalid { + let adoption = request( + &draft, + &format!("invalid-{name}"), + vec![update(&draft, &path, &content)], + ); + for suffix in ["/preview", "/commit"] { + let (status, response) = fixture + .call(fixture.bearer( + &fixture.owner, + "POST", + suffix, + Some(serde_json::to_value(&adoption).unwrap()), + )) + .await; + assert!( + status.is_client_error(), + "{name} {suffix}: {status} {response}" + ); + assert_eq!( + fixture.durable(), + before, + "invalid {name} leaves no source, policy, head, receipt or seal" + ); + fixture.assert_legacy_writable(); + } + } + for canonical in [false, true] { + if canonical { + fixture.adopt_draft("old-writer-cutover").await; + } + let current = fixture.source().await; + let old = SourceRequest { + base_digest: current.state.snapshot.digest, + changes: vec![create_marker("old writer must not persist")], + entrypoints: current.state.contract.entrypoints, + }; + let before = fixture.durable(); + let response = Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit" + )) + .header("authorization", format!("Bearer {}", fixture.owner)) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&old).unwrap())) + .unwrap(); + let (status, response) = fixture.call(response).await; + assert_eq!( + status, + StatusCode::CONFLICT, + "old writer canonical={canonical}: {response}" + ); + assert_eq!(fixture.durable(), before); + if canonical { + fixture.assert_legacy_sealed(); + } else { + fixture.assert_legacy_writable(); + } + } + } +} diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 1247e2961..3c54da854 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -619,22 +619,28 @@ pub(super) async fn commit( { return Err(fail(409, "authority_changed")); } - // Preserve the shared helper's ordering and atomic CAS, but never - // run its remote Runtime publication on the sole DB worker. - project_repository_access_candidate( - &*persistence_api.store, - &persistence_api.repository_secrets, - &persistence_api.config.workspace_id, - &candidate, - ) - .map_err(|_| unknown())?; - let state = persistence_api - .config_store - .commit_evaluated_workspace_config( - &persistence_api.config.workspace_id, - &candidate, - ) - .map_err(|_| unknown())?; + // Legacy logical writes share the physical authority gate with + // initial cutover, in Feature -> Server order. A durable seal + // fences this writer even when the Server adoption rolled back. + let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?; + let identity = legacy.database_identity().map_err(|_| unknown())?; + let state = legacy.with_permission_authority_guard(&identity, |authority| { + authority.require_unsealed()?; + Ok((|| { + project_repository_access_candidate( + &*persistence_api.store, + &persistence_api.repository_secrets, + &persistence_api.config.workspace_id, + &candidate, + ).map_err(|_| unknown())?; + persistence_api.config_store.commit_evaluated_workspace_config( + &persistence_api.config.workspace_id, &candidate, + ).map_err(pre_error) + })()) + }).map_err(|error| match error { + subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"), + _ => unknown(), + })??; let publication = persistence_api .prompt_projection_cache .resolve(&persistence_api.config.workspace_id, &state) diff --git a/crates/workspace-server/src/storage_format_tests.rs b/crates/workspace-server/src/storage_format_tests.rs index d523da6a6..3bc3c034b 100644 --- a/crates/workspace-server/src/storage_format_tests.rs +++ b/crates/workspace-server/src/storage_format_tests.rs @@ -112,7 +112,10 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) { let plan = published_plan(conn, current).unwrap().unwrap(); assert_eq!(plan.migrations.len(), 1); assert_eq!(plan.migrations[0].version, 122); - assert_eq!(plan.migrations[0].name, crate::config_adoption::MIGRATION_NAME); + assert_eq!( + plan.migrations[0].name, + crate::config_adoption::MIGRATION_NAME + ); assert_eq!(plan.target_schema_version, 122); } assert_eq!(rows, saved_rows(conn)); @@ -200,12 +203,26 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122); for (table, rows) in &expected { if table != "__yoi_schema_migrations" { - assert_eq!(saved_rows(&conn).into_iter().find(|(name, _)| name == table).unwrap().1, *rows, "{table}, from {current}"); + assert_eq!( + saved_rows(&conn) + .into_iter() + .find(|(name, _)| name == table) + .unwrap() + .1, + *rows, + "{table}, from {current}" + ); } } let mut history = super::super::workspace_schema_migration_history(&conn).unwrap(); - assert_eq!(history.pop(), Some((122, crate::config_adoption::MIGRATION_NAME.into()))); - assert_eq!(history, super::super::workspace_schema_migration_history(&source).unwrap()); + assert_eq!( + history.pop(), + Some((122, crate::config_adoption::MIGRATION_NAME.into())) + ); + assert_eq!( + history, + super::super::workspace_schema_migration_history(&source).unwrap() + ); verify_history(&conn, 122).unwrap(); } } diff --git a/crates/workspace-server/src/storage_formats.rs b/crates/workspace-server/src/storage_formats.rs index 2a797c995..aafe26414 100644 --- a/crates/workspace-server/src/storage_formats.rs +++ b/crates/workspace-server/src/storage_formats.rs @@ -87,8 +87,13 @@ fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String) return Err(invalid("unrecognized configuration adoption receipt")); } let prefix = &rows[..rows.len() - 1]; - let prior = prefix.last().map(|row| row.0).ok_or_else(|| invalid("missing configuration predecessor"))?; - if !(113..=121).contains(&prior) { return Err(invalid("invalid configuration predecessor")); } + let prior = prefix + .last() + .map(|row| row.0) + .ok_or_else(|| invalid("missing configuration predecessor"))?; + if !(113..=121).contains(&prior) { + return Err(invalid("invalid configuration predecessor")); + } return verify_history_records(conn, prior, prefix); } let Some((baseline, name)) = rows.first() else { @@ -217,7 +222,9 @@ pub(super) fn published_plan( version: 122, name: crate::config_adoption::MIGRATION_NAME.to_owned(), }] - } else { vec![] }, + } else { + vec![] + }, })) } @@ -227,9 +234,13 @@ fn verify_configuration_shape(conn: &Connection) -> Result<()> { expected_db.execute_batch(crate::config_adoption::SCHEMA)?; let expected = snapshot(&expected_db)?; let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect(); - let actual: Shape = snapshot(conn)?.into_iter() - .filter(|(_, object)| tables.contains(&object.1)).collect(); - if actual != expected { return Err(invalid("configuration adoption receipt/shape mismatch")); } + let actual: Shape = snapshot(conn)? + .into_iter() + .filter(|(_, object)| tables.contains(&object.1)) + .collect(); + if actual != expected { + return Err(invalid("configuration adoption receipt/shape mismatch")); + } Ok(()) } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 83ea0f1a2..452e7e012 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -16077,13 +16077,18 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> { fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> { let current = current_schema_version(conn)?; if !(113..=121).contains(¤t) { - return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into())); + return Err(Error::Store( + "Configuration adoption migration requires a recognized schema 113..121".into(), + )); } storage_formats::verify_history(conn, current)?; let tx = conn.unchecked_transaction()?; tx.execute_batch(content_history_store::SCHEMA)?; tx.execute_batch(crate::config_adoption::SCHEMA)?; - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", [crate::config_adoption::MIGRATION_NAME])?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", + [crate::config_adoption::MIGRATION_NAME], + )?; tx.commit()?; Ok(()) } @@ -18904,12 +18909,15 @@ mod tests { #[test] fn current_schema_accepts_every_retained_canonical_provenance() { - for baseline_version in (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) { + for baseline_version in + (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) + { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); merge_request::migrate(&conn).unwrap(); - conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;") + .unwrap(); create_latest_workspace_schema(&conn).unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", diff --git a/openapi/configuration-adoption-api.json b/openapi/configuration-adoption-api.json new file mode 100644 index 000000000..4b6064581 --- /dev/null +++ b/openapi/configuration-adoption-api.json @@ -0,0 +1,1776 @@ +{ + "components": { + "schemas": { + "ConfigCommitRequest": { + "additionalProperties": false, + "description": "Compare-and-swap request for applying a complete set of source-tree changes.", + "properties": { + "base_digest": { + "type": "string" + }, + "changes": { + "items": { + "$ref": "#/components/schemas/ConfigTreeChange" + }, + "type": "array" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "base_digest", + "changes", + "entrypoints" + ], + "type": "object" + }, + "ConfigContentType": { + "description": "Source content kinds accepted by the Workspace configuration tree.", + "enum": [ + "decodal", + "text" + ], + "type": "string" + }, + "ConfigEntry": { + "additionalProperties": false, + "description": "One source entry in the Workspace configuration tree.", + "properties": { + "content": { + "type": "string" + }, + "content_digest": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "content_type", + "content", + "content_digest" + ], + "type": "object" + }, + "ConfigProjectionValidator": { + "oneOf": [ + { + "properties": { + "key_aliases": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "const": "static_template_catalog", + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "kind", + "namespace" + ], + "type": "object" + } + ] + }, + "ConfigSchemaContribution": { + "additionalProperties": false, + "properties": { + "authoring_source": { + "description": "Completion-only shape for partial values; never materialized as defaults.", + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": "string" + }, + "projection_validator": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConfigProjectionValidator" + }, + { + "type": "null" + } + ] + }, + "provider_id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "provider_id", + "namespace", + "version", + "source", + "source_digest" + ], + "type": "object" + }, + "ConfigTreeChange": { + "description": "Base-relative change applied by a Workspace configuration commit.", + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "kind": { + "const": "create", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "content_type", + "content" + ], + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "update", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest", + "content" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "from": { + "type": "string" + }, + "kind": { + "const": "rename", + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "kind", + "from", + "to", + "expected_digest" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest" + ], + "type": "object" + } + ] + }, + "ConfigTreeSnapshot": { + "additionalProperties": false, + "description": "Immutable Workspace configuration tree snapshot.", + "properties": { + "digest": { + "type": "string" + }, + "entries": { + "additionalProperties": { + "$ref": "#/components/schemas/ConfigEntry" + }, + "type": "object" + } + }, + "required": [ + "digest", + "entries" + ], + "type": "object" + }, + "ConfigurationAdoptionReceipt": { + "additionalProperties": false, + "properties": { + "history_head": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "policy_committed": { + "type": "boolean" + }, + "runtime_application": { + "$ref": "#/components/schemas/RuntimePolicyApplication" + }, + "source_stored": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "operation_id", + "history_head", + "state", + "source_stored", + "policy_committed", + "runtime_application" + ], + "type": "object" + }, + "ConfigurationAdoptionRequest": { + "additionalProperties": false, + "properties": { + "expected_catalog_digest": { + "type": "string" + }, + "expected_head": { + "type": [ + "string", + "null" + ] + }, + "expected_legacy_export": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/ConfigCommitRequest" + } + }, + "required": [ + "operation_id", + "expected_catalog_digest", + "source" + ], + "type": "object" + }, + "ConfigurationMigrationDiagnostic": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "converted", + "type": "string" + }, + "operation": { + "type": "string" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "operation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "domain_constraint", + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "permission" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "explicit_configuration_required", + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "kind", + "subject_id", + "permission" + ], + "type": "object" + } + ] + }, + "ConfigurationPreviewResponse": { + "additionalProperties": false, + "properties": { + "changed_paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "changed_subjects": { + "items": { + "type": "string" + }, + "type": "array" + }, + "expected_head": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + } + }, + "required": [ + "state", + "changed_paths", + "changed_subjects" + ], + "type": "object" + }, + "ConfigurationReconciliation": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "receipt": { + "$ref": "#/components/schemas/ConfigurationAdoptionReceipt" + }, + "status": { + "const": "adopted", + "type": "string" + } + }, + "required": [ + "status", + "receipt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "export_digest": { + "type": "string" + }, + "status": { + "const": "pending_transfer", + "type": "string" + }, + "transfer_identity": { + "type": "string" + } + }, + "required": [ + "status", + "transfer_identity", + "export_digest" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "No matching evidence was observed. This is not permission to change the ID.", + "properties": { + "status": { + "const": "not_observed", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + ] + }, + "ConfigurationSourceResponse": { + "additionalProperties": false, + "properties": { + "catalog": { + "$ref": "#/components/schemas/PermissionCatalog" + }, + "catalog_digest": { + "type": "string" + }, + "expected_legacy_export": { + "type": [ + "string", + "null" + ] + }, + "history_head": { + "type": [ + "string", + "null" + ] + }, + "migration_diagnostics": { + "items": { + "$ref": "#/components/schemas/ConfigurationMigrationDiagnostic" + }, + "type": "array" + }, + "policies": { + "items": { + "$ref": "#/components/schemas/ConfigurationSubjectPolicy" + }, + "type": "array" + }, + "source_stored": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse", + "description": "Before the first adoption this is an editable, evaluated migration draft.\nsource_stored distinguishes it from the unchanged stored source." + } + }, + "required": [ + "state", + "source_stored", + "catalog", + "catalog_digest", + "policies", + "migration_diagnostics" + ], + "type": "object" + }, + "ConfigurationSubjectPolicy": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "$ref": "#/components/schemas/PermissionAssignment" + }, + "execution": { + "$ref": "#/components/schemas/PermissionAssignment" + }, + "legacy_constraints": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectPermissions" + }, + { + "type": "null" + } + ], + "description": "Exact retained domain restrictions, not editable payloads on permission keys." + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "subject_id", + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "NamespaceCatalog": { + "additionalProperties": false, + "properties": { + "declaration": { + "$ref": "#/components/schemas/NamespaceDeclaration" + }, + "enabled": { + "type": "boolean" + }, + "feature": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "feature", + "enabled", + "declaration" + ], + "type": "object" + }, + "NamespaceDeclaration": { + "additionalProperties": false, + "description": "This input deliberately has no owner field. The Host supplies the registered\nidentity/namespace handle independently of guest-controlled declaration data.", + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/PermissionDefinition" + }, + "type": "array" + }, + "sets": { + "items": { + "$ref": "#/components/schemas/SetContribution" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "sets" + ], + "type": "object" + }, + "NamespacePermissions": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "keys" + ], + "type": "object" + }, + "PermissionAssignment": { + "additionalProperties": false, + "description": "`None` at the consuming policy boundary means unset. An empty assignment is\nexplicit deny-all; this crate never chooses or reinstates a default.", + "properties": { + "exclusions": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "grants": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "grants", + "exclusions", + "revoked" + ], + "type": "object" + }, + "PermissionCatalog": { + "additionalProperties": false, + "properties": { + "namespaces": { + "items": { + "$ref": "#/components/schemas/NamespaceCatalog" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "namespaces" + ], + "type": "object" + }, + "PermissionDefinition": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "key", + "description", + "children" + ], + "type": "object" + }, + "PermissionReference": { + "description": "The discriminant, not an identifier's spelling, selects a reference kind.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "namespace", + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/NamespacePermissions" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionResource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "installation_id": { + "type": "string" + }, + "kind": { + "const": "plugin_installation", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "installation_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "string" + }, + "kind": { + "const": "job", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "job_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ticket", + "type": "string" + }, + "ticket_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "ticket_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "objective", + "type": "string" + }, + "objective_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "objective_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "merge_request", + "type": "string" + }, + "merge_request_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "merge_request_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "subject", + "type": "string" + }, + "subject_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "subject_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "drive_entry", + "type": "string" + }, + "node_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "node_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "drive_request", + "type": "string" + }, + "request_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "request_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "repository", + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "repository_key" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "kind": { + "const": "workdir_attachment", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "connection_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace_config", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "network", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "kind", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "sub_worker", + "type": "string" + }, + "name": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workdir", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Authorizes operation use, not a target Worker or a Workspace collection.\nThe Worker Feature must still enforce its directional target control grant.", + "properties": { + "kind": { + "const": "worker_operation", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + } + ] + }, + "RepositoryApiError": { + "additionalProperties": false, + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupFailureAttachment" + }, + { + "type": "null" + } + ], + "description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience." + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "RuntimePolicyApplication": { + "enum": [ + "next_current_policy_check" + ], + "type": "string" + }, + "ScopedPermission": { + "additionalProperties": false, + "description": "Stored scopes retain their original resources and exact operation names.\n\nA legacy Worker read adapter may use [`canonical_worker_operation`] to project\noperation use, but must retain the original target separately for the Worker\nFeature's control-grant check, including direction, revocation and delegation\nlimits. Replacing an old Worker resource with WorkerOperation and discarding\nthat target would broaden authority. Neither validation nor `covers` performs\nthat conversion.", + "properties": { + "operation": { + "type": "string" + }, + "resource": { + "$ref": "#/components/schemas/PermissionResource" + } + }, + "required": [ + "operation", + "resource" + ], + "type": "object" + }, + "SetContribution": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "set": { + "type": "string" + } + }, + "required": [ + "set", + "children" + ], + "type": "object" + }, + "StartupFailureAttachment": { + "description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.", + "type": "string" + }, + "SubjectPermissions": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "ToolchainContract": { + "additionalProperties": false, + "properties": { + "contract_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "decodal_version": { + "type": "string" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "import_policy_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "schema_bundle": { + "$ref": "#/components/schemas/WorkspaceConfigSchemaBundle" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "contract_version", + "decodal_version", + "schema_version", + "entrypoints", + "import_policy_version", + "schema_bundle", + "fingerprint" + ], + "type": "object" + }, + "WorkspaceConfigSchemaBundle": { + "additionalProperties": false, + "properties": { + "contributions": { + "items": { + "$ref": "#/components/schemas/ConfigSchemaContribution" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "contributions", + "source", + "fingerprint" + ], + "type": "object" + }, + "WorkspaceConfigTreeResponse": { + "additionalProperties": false, + "description": "Workspace configuration tree plus the exact evaluation contract that produced it.", + "properties": { + "contract": { + "$ref": "#/components/schemas/ToolchainContract" + }, + "projection_digest": { + "type": "string" + }, + "snapshot": { + "$ref": "#/components/schemas/ConfigTreeSnapshot" + } + }, + "required": [ + "snapshot", + "contract", + "projection_digest" + ], + "type": "object" + }, + "string": { + "type": "string" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Workspace configuration adoption", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:69090fac404d0e719a6d399a809e4e94d640b85bd646337785b63a5c9b30180a" + }, + "openapi": "3.1.0", + "paths": { + "/api/w/{workspace_id}/config/adoption": { + "get": { + "operationId": "configuration_source", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationSourceResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/commit": { + "post": { + "operationId": "configuration_adopt", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/preview": { + "post": { + "operationId": "configuration_preview", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationPreviewResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/adoption/reconcile": { + "post": { + "operationId": "configuration_reconcile", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationAdoptionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationReconciliation" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + } + } +} diff --git a/openapi/feature-permissions-api.json b/openapi/feature-permissions-api.json index 64af1c82f..c17704c2d 100644 --- a/openapi/feature-permissions-api.json +++ b/openapi/feature-permissions-api.json @@ -420,6 +420,17 @@ "error": { "type": "string" }, + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupFailureAttachment" + }, + { + "type": "null" + } + ], + "description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience." + }, "message": { "type": "string" } @@ -449,6 +460,10 @@ ], "type": "object" }, + "StartupFailureAttachment": { + "description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.", + "type": "string" + }, "ValidatePermissionsRequest": { "additionalProperties": false, "description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", @@ -481,7 +496,7 @@ "info": { "title": "Feature permission declaration contract", "version": "0.1.0", - "x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" + "x-yoi-source-digest": "sha256:9a4f2ec1ab277c73bf8c69b2adef050dbae26cff5430ff2bd1201371feef5a38" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index caa1e9467..c9588effa 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts src/lib/workspace/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/generated/configuration-adoption.generated.ts b/web/workspace/src/lib/generated/configuration-adoption.generated.ts new file mode 100644 index 000000000..762011f98 --- /dev/null +++ b/web/workspace/src/lib/generated/configuration-adoption.generated.ts @@ -0,0 +1,30 @@ +// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`. +// Do not edit manually. + +import type { ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions } from "./legacy-server-api.ts"; +import type { PermissionAssignment, PermissionCatalog } from "./feature-permissions.generated.ts"; + +export type ConfigurationAdoptionRequest = { operation_id: string, expected_head: string | null, expected_catalog_digest: string, expected_legacy_export: string | null, source: ConfigCommitRequest, }; + +export type RuntimePolicyApplication = "next_current_policy_check"; + +export type ConfigurationAdoptionReceipt = { workspace_id: string, operation_id: string, history_head: string, state: WorkspaceConfigTreeResponse, source_stored: boolean, policy_committed: boolean, runtime_application: RuntimePolicyApplication, }; + +export type ConfigurationSubjectPolicy = { subject_id: string, change_id: string, execution: PermissionAssignment, delegation: PermissionAssignment, +/** + * Exact retained domain restrictions, not editable payloads on permission keys. + */ +legacy_constraints: SubjectPermissions | null, }; + +export type ConfigurationMigrationDiagnostic = { "kind": "converted", subject_id: string, operation: string, } | { "kind": "domain_constraint", subject_id: string, permission: ScopedPermission, } | { "kind": "explicit_configuration_required", subject_id: string, permission: ScopedPermission, }; + +export type ConfigurationSourceResponse = { +/** + * Before the first adoption this is an editable, evaluated migration draft. + * source_stored distinguishes it from the unchanged stored source. + */ +state: WorkspaceConfigTreeResponse, history_head: string | null, source_stored: boolean, catalog: PermissionCatalog, catalog_digest: string, expected_legacy_export: string | null, policies: Array, migration_diagnostics: Array, }; + +export type ConfigurationPreviewResponse = { expected_head: string | null, state: WorkspaceConfigTreeResponse, changed_paths: Array, changed_subjects: Array, }; + +export type ConfigurationReconciliation = { "status": "adopted", receipt: ConfigurationAdoptionReceipt, } | { "status": "pending_transfer", transfer_identity: string, export_digest: string, } | { "status": "not_observed" }; diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts index fd5bb856f..30cb349b0 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; +import type { WorkspaceConfigTreeResponse } from "./types.ts"; const toolchain = vi.hoisted(() => ({ construct: vi.fn(), @@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({ })); const schemaSource = "schema body\n".repeat(6000); -const tree = { +const tree: WorkspaceConfigTreeResponse = { snapshot: { digest: "sha256:tree", entries: { @@ -64,7 +65,27 @@ const tree = { projection_digest: "sha256:projection", }; const fetcher = vi.fn(); -const response = () => new Response(JSON.stringify(tree)); +const sourceResponse = (state = tree) => ({ + state, + history_head: "head:observed", + source_stored: true, + catalog: { workspace: "w", namespaces: [] }, + catalog_digest: "sha256:catalog", + expected_legacy_export: "sha256:legacy", + policies: [], + migration_diagnostics: [], +}); +const response = () => new Response(JSON.stringify(sourceResponse())); +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body)); +const previewResponse = (state = tree, expected_head: string | null = "head:observed") => jsonResponse({ + expected_head, state, changed_paths: ["module.dcdl"], changed_subjects: [], +}); +function receipt(operation_id: string, state = tree, history_head = "head:adopted") { + return { + workspace_id: "w", operation_id, history_head, state, + source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check", + }; +} beforeEach(() => { vi.resetAllMocks(); @@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation finishSetup(); await expectReady(view); expect(fetcher).toHaveBeenCalledWith( - "/api/w/w%2Fone/config/source-tree", + "/api/w/w%2Fone/config/adoption", expect.anything(), ); }); @@ -140,7 +161,7 @@ test.each(["http", "invalid"])( fetcher.mockResolvedValueOnce( failure === "http" ? new Response("Backend unavailable", { status: 503 }) - : new Response(JSON.stringify({ ...tree, unexpected: true })), + : new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })), ); const view = renderEditor(); await waitFor(() => @@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)( test("a successfully loaded empty tree still offers source creation", async () => { fetcher.mockResolvedValueOnce( - new Response(JSON.stringify({ + new Response(JSON.stringify(sourceResponse({ ...tree, snapshot: { ...tree.snapshot, entries: {} }, - })), + }))), ); const view = renderEditor(); await waitFor(() => @@ -328,49 +349,186 @@ test("diagnostics from another tree digest are not displayed", async () => { expect(view.container.textContent).not.toContain("Wrong tree diagnostic"); }); -test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => { - const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" }; - const candidate = { - digest: "sha256:local-draft", - entries: { - ...tree.snapshot.entries, - "module.dcdl": { - path: change.path, - content_type: change.content_type, - content: change.content, - content_digest: "sha256:module", - }, +const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" } as const; +const candidate = { + digest: "sha256:local-draft", + entries: { + ...tree.snapshot.entries, + "module.dcdl": { + path: change.path, content_type: change.content_type, content: change.content, + content_digest: "sha256:module", }, - }; + }, +}; +const candidateState = { ...tree, snapshot: candidate }; + +async function prepareDraft() { toolchain.applyChanges.mockResolvedValue(candidate); toolchain.changesBetween.mockResolvedValue([change]); toolchain.format.mockImplementation(async (source: string) => source); const view = renderEditor(); await expectReady(view); await fireEvent.click(view.getByRole("button", { name: "Create local source" })); + return view; +} + +const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body); + +function commitReceiptResponse(state = candidateState, historyHead = "head:adopted") { + return async (_input: string, init: RequestInit) => + jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead)); +} + +test.each([409, 422])("preview rejection %s preserves the draft and observed editing metadata", async (status) => { + const view = await prepareDraft(); const commit = view.getByRole("button", { name: "Commit" }); - expect(commit).toBeInstanceOf(HTMLButtonElement); fetcher.mockResolvedValueOnce(new Response( - status === 409 ? "base content digest mismatch" : "candidate evaluation failed", - { status }, + status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status }, )); await fireEvent.click(commit); await waitFor(() => expect(view.container.textContent).toContain( - status === 409 ? "base content digest mismatch" : "candidate evaluation failed", + status === 409 ? "editing head mismatch" : "candidate evaluation failed", )); - const expectedRequest = { - base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"], - }; - expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest); + const first = requestAt(1); + expect(first).toEqual({ + operation_id: expect.any(String), expected_head: "head:observed", + expected_catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy", + source: { base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"] }, + }); + expect(fetcher.mock.calls[1][0]).toBe("/api/w/w/config/adoption/preview"); expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); expect(commit).toHaveProperty("disabled", false); expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate"); - fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate }))); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockImplementationOnce(commitReceiptResponse()); await fireEvent.click(commit); await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); - expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest); + const second = requestAt(2); + expect(second).toEqual({ ...first, operation_id: expect.any(String) }); + expect(second.operation_id).not.toBe(first.operation_id); + expect(requestAt(3)).toEqual(second); expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle); expect(commit).toHaveProperty("disabled", true); }); + +test("initial editable migration draft can be adopted without manually changing a file", async () => { + fetcher.mockResolvedValueOnce(jsonResponse({ + ...sourceResponse(), history_head: null, source_stored: false, + })); + toolchain.changesBetween.mockResolvedValue([]); + const view = renderEditor(); + await expectReady(view); + const commit = view.getByRole("button", { name: "Commit" }); + expect(commit).toHaveProperty("disabled", false); + fetcher.mockResolvedValueOnce(previewResponse(tree, null)); + fetcher.mockImplementationOnce(commitReceiptResponse(tree)); + await fireEvent.click(commit); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(requestAt(1).expected_head).toBeNull(); + expect(requestAt(1).source.changes).toEqual([]); + expect(requestAt(2)).toEqual(requestAt(1)); + expect(commit).toHaveProperty("disabled", true); +}); + +test("commit rejection does not refresh the head or discard local source", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockResolvedValueOnce(new Response("editing head mismatch", { status: 409 })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Discard local candidate")); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockImplementationOnce(commitReceiptResponse()); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(requestAt(3).expected_head).toBe(requestAt(1).expected_head); + expect(requestAt(3).operation_id).not.toBe(requestAt(1).operation_id); +}); + +test.each(["not_observed", "pending_transfer"])("lost commit followed by %s retains the exact request for explicit resume", async (status) => { + const view = await prepareDraft(); + const unknown = status === "not_observed" ? { status } : { + status, transfer_identity: "transfer:one", export_digest: "sha256:legacy", + }; + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockRejectedValueOnce(new TypeError("response lost")); + fetcher.mockResolvedValueOnce(jsonResponse(unknown)); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain( + status === "not_observed" ? "not observed does not mean failed" : "transfer pending", + )); + expect(fetcher).toHaveBeenCalledTimes(4); + const request = requestAt(1); + expect(requestAt(2)).toEqual(request); + expect(requestAt(3)).toEqual(request); + expect(fetcher.mock.calls[3][0]).toBe("/api/w/w/config/adoption/reconcile"); + expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); + + fetcher.mockResolvedValueOnce(jsonResponse(unknown)); + fetcher.mockImplementationOnce(commitReceiptResponse()); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(fetcher).toHaveBeenCalledTimes(6); + expect(requestAt(4)).toEqual(request); + expect(requestAt(5)).toEqual(request); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); +}); + +test("adopted reconciliation updates head and clears the legacy export for the next explicit adoption", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockResolvedValueOnce(new Response("invalid receipt")); + fetcher.mockImplementationOnce(async (_input: string, init: RequestInit) => jsonResponse({ + status: "adopted", receipt: receipt(JSON.parse(String(init.body)).operation_id, candidateState), + })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + const first = requestAt(2); + expect(requestAt(3)).toEqual(first); + // A local deletion is another explicit adoption, not a retry of the lost reply. + const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" }; + toolchain.applyChanges.mockResolvedValue(tree.snapshot); + toolchain.changesBetween.mockResolvedValue([deletion]); + await fireEvent.click(view.getByRole("button", { name: "Delete" })); + fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted")); + fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second")); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(6)); + const second = requestAt(4); + expect(second.expected_head).toBe("head:adopted"); + expect(second.expected_legacy_export).toBeNull(); + expect(second.expected_catalog_digest).toBe(first.expected_catalog_digest); + expect(second.operation_id).not.toBe(first.operation_id); + expect(second.source.base_digest).toBe(candidate.digest); +}); + +test("edited draft requires reconciliation and cannot replace or resume an unknown request", async () => { + const view = await prepareDraft(); + fetcher.mockResolvedValueOnce(previewResponse(candidateState)); + fetcher.mockRejectedValueOnce(new TypeError("response lost")); + fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("not observed does not mean failed")); + const original = requestAt(2); + toolchain.format.mockResolvedValue("{ changed: true }\n"); + await fireEvent.click(view.getByRole("button", { name: "Format" })); + await waitFor(() => expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed")); + fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" })); + await fireEvent.click(view.getByRole("button", { name: "Commit" })); + await waitFor(() => expect(view.container.textContent).toContain("Draft changed; reconcile the original request")); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(requestAt(4)).toEqual(original); + expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); + + fetcher.mockResolvedValueOnce(jsonResponse({ status: "adopted", receipt: receipt(original.operation_id, candidateState) })); + await fireEvent.click(view.getByRole("button", { name: "Reconcile adoption" })); + await waitFor(() => expect(view.container.textContent).toContain("Later local edits remain unsaved")); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed"); + expect(requestAt(5)).toEqual(original); + expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index de04b71d7..588391e10 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -1,15 +1,22 @@