diff --git a/Cargo.lock b/Cargo.lock index ffec5d235..ac342bd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7579,6 +7579,7 @@ dependencies = [ "content-history", "content-history-adapters", "content-history-store", + "feature-permissions", "feature-storage", "flate2", "flow", diff --git a/crates/protocol/src/session_failure.rs b/crates/protocol/src/session_failure.rs index 26104d755..0d87500aa 100644 --- a/crates/protocol/src/session_failure.rs +++ b/crates/protocol/src/session_failure.rs @@ -3,6 +3,48 @@ //! Causes contain only fixed enum values. Stored or transferred attachments are //! observations, never permission to disclose: Browser/Tool projections require //! the caller's current visibility decision and fail closed without it. +//! +//! # Projection boundary +//! +//! The same closed cause is mapped explicitly into the shared error-contract codec. +//! The transport boundary does not convert an internal Session denial into an HTTP +//! verdict about a Browser operator. Public consumers must resolve current viewer +//! authority before projecting; a saved attachment cannot carry that authority. +//! +//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool | +//! | --- | --- | --- | +//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose | +//! | Explanation | Fixed local template | Fixed local template, not received text | +//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references | +//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause | +//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority | +//! +//! A denied current visibility decision fails projection instead of substituting +//! an invented cause. Unknown codec versions and corrupt attachments stay distinct +//! from known causes. Missing legacy attachments do not certify legacy diagnostics. +//! +//! # Failure investigation +//! +//! A known denial identifies the operation and principal class, not an instruction +//! to grant rights. Check the current Subject execution policy and the authenticated +//! Runtime/Worker binding at the Session-record authorization boundary. Compare +//! existing Session attribution and saved Worker metadata before deciding whether +//! any state repair is needed. Operator authority and Subject execution policy are +//! different decisions; neither can be derived from an internal HTTP status alone. +//! +//! Transport and unknown outcomes do not establish non-execution. Reconcile the +//! existing Session attribution, Worker aggregate and Runtime operation result; +//! a lost response may coexist with a committed write. A conflict invalidates the +//! old metadata expectation. Do not retry the startup, create another Worker, or +//! enlarge permissions solely because a failure was displayed. Inspect primary, +//! secondary, persistence and resource facts independently: rollback failure must +//! not replace the refusal, and persistence uncertainty must not be called saved. +//! +//! The real authorization/factory fixture is owned by workspace-server's normal +//! `session_registration_connected` tests. Its signed Session callback revokes the +//! real policy at a synchronization point; a real retention pin forces independent +//! cleanup failure. Leaf transport/projection tests do not substitute for that +//! product connection, and the fixture claims neither a process crash nor restore. use error_contract::{ Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity, diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 533b657ae..cdb819085 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -105,10 +105,25 @@ pub struct RuntimeApiError { status: u16, } +/// Transient, allowlisted startup observations copied from the execution owner. +/// This is not an operation result, lifecycle record, or permission to retry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerStartupFailure { + pub primary: StartupFailureAttachment, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secondary: Vec, + pub resources: Option, + pub persistence: Option, + pub io_kind: Option, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RuntimeApiErrorDetail { pub code: String, pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub startup: Option, } impl RuntimeApiError { @@ -117,6 +132,7 @@ impl RuntimeApiError { error: RuntimeApiErrorDetail { code: code.into(), message: message.into(), + startup: None, }, status, } 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 new file mode 100644 index 000000000..882f50fb4 --- /dev/null +++ b/crates/server-api/examples/generate_configuration_adoption_openapi.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), Box> { + print!( + "{}", + server_api::configuration_adoption::configuration_contract_openapi()?.to_json()? + ); + Ok(()) +} diff --git a/crates/server-api/examples/generate_configuration_adoption_types.rs b/crates/server-api/examples/generate_configuration_adoption_types.rs new file mode 100644 index 000000000..b1e136cb8 --- /dev/null +++ b/crates/server-api/examples/generate_configuration_adoption_types.rs @@ -0,0 +1,14 @@ +#[cfg(feature = "typescript")] +fn main() { + print!( + "{}", + server_api::normalized_typescript( + server_api::configuration_adoption::configuration_typescript() + ) + ); +} +#[cfg(not(feature = "typescript"))] +fn main() { + eprintln!("generate_configuration_adoption_types requires --features typescript"); + std::process::exit(2); +} diff --git a/crates/server-api/src/configuration_adoption.rs b/crates/server-api/src/configuration_adoption.rs new file mode 100644 index 000000000..5fb8c0325 --- /dev/null +++ b/crates/server-api/src/configuration_adoption.rs @@ -0,0 +1,192 @@ +//! Workspace-owner source adoption transport. The Server authenticates the owner +//! before reading source, previewing, sealing migration data, adopting or reconciling. +//! A result lookup is not a new adoption and absence is not proof of failure. +use crate::{ + ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse, +}; +use feature_permissions::{PermissionAssignment, PermissionCatalog}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionRequest { + pub operation_id: String, + pub expected_head: Option, + pub expected_catalog_digest: String, + pub expected_legacy_export: Option, + pub source: ConfigCommitRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimePolicyApplication { + NextCurrentPolicyCheck, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionReceipt { + pub workspace_id: String, + pub operation_id: String, + pub history_head: String, + pub state: WorkspaceConfigTreeResponse, + pub source_stored: bool, + pub policy_committed: bool, + pub runtime_application: RuntimePolicyApplication, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationSubjectPolicy { + pub subject_id: String, + pub change_id: String, + pub execution: PermissionAssignment, + pub delegation: PermissionAssignment, + /// Exact retained domain restrictions, not editable payloads on permission keys. + pub legacy_constraints: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ConfigurationMigrationDiagnostic { + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: crate::authorization::ScopedPermission, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationSourceResponse { + /// Before the first adoption this is an editable, evaluated migration draft. + /// source_stored distinguishes it from the unchanged stored source. + pub state: WorkspaceConfigTreeResponse, + pub history_head: Option, + pub source_stored: bool, + pub catalog: PermissionCatalog, + pub catalog_digest: String, + pub expected_legacy_export: Option, + pub policies: Vec, + pub migration_diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigurationPreviewResponse { + pub expected_head: Option, + pub state: WorkspaceConfigTreeResponse, + pub changed_paths: Vec, + pub changed_subjects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum ConfigurationReconciliation { + Adopted { + receipt: ConfigurationAdoptionReceipt, + }, + PendingTransfer { + transfer_identity: String, + export_digest: String, + }, + /// No matching evidence was observed. This is not permission to change the ID. + NotObserved, +} + +#[api_macros::api(reqwest, axum, openapi)] +pub trait ConfigurationAdoptionApi { + #[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)] + async fn configuration_source( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_preview( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_adopt( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)] + async fn configuration_reconcile( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: ConfigurationAdoptionRequest, + ) -> Result; +} + +impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {} +impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionRequest {} +impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {} +impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {} +impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {} + +pub fn configuration_contract_openapi() +-> Result { + use sha2::{Digest, Sha256}; + let document = |source_digest: &str| { + configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Workspace configuration adoption", + version: env!("CARGO_PKG_VERSION"), + source_digest, + }) + }; + let normalized = + document("sha256:0000000000000000000000000000000000000000000000000000000000000000")? + .to_json()?; + let digest: String = Sha256::digest(normalized.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + document(&format!("sha256:{digest}")) +} + +#[cfg(feature = "typescript")] +pub fn configuration_typescript() -> String { + use ts_rs::TS; + let config = ts_rs::Config::default(); + let declarations = [ + ConfigurationAdoptionRequest::decl(&config), + RuntimePolicyApplication::decl(&config), + ConfigurationAdoptionReceipt::decl(&config), + ConfigurationSubjectPolicy::decl(&config), + ConfigurationMigrationDiagnostic::decl(&config), + ConfigurationSourceResponse::decl(&config), + ConfigurationPreviewResponse::decl(&config), + ConfigurationReconciliation::decl(&config), + ]; + format!( + "// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index f7e83d0d9..fcc212361 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,8 +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 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 817203882..5b964f9e8 100644 --- a/crates/subjektiv/src/deletion.rs +++ b/crates/subjektiv/src/deletion.rs @@ -319,6 +319,11 @@ impl SubjektivStore { UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1 UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1 UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?; + if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { + tx.execute("INSERT OR IGNORE INTO subject_deleted_workers(subject_id,runtime_id,worker_id) + SELECT subject_id,runtime_id,worker_id FROM canonical_worker_execution_grants WHERE subject_id=?1 + UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; + } tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id) SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1 UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1 @@ -401,6 +406,12 @@ impl SubjektivStore { [subject], )?; } + if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { + tx.execute( + "DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", + [subject], + )?; + } for table in DIRECT { tx.execute( &format!("DELETE FROM {table} WHERE subject_id=?1"), diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 631d11c58..4fbe7e2b1 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -8,6 +8,10 @@ pub use deletion::{DeletionSubjectJob, SubjectDeletion}; pub mod journal; mod legacy_migrations; pub mod permissions; +pub use permissions::{ + CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt, + PermissionTransferSnapshot, +}; mod schema; mod subject_creation; use legacy_migrations::{ @@ -95,6 +99,10 @@ pub enum SubjektivError { SubjectPermissionsConflict(String), #[error("permission operation `{0}` was reused with different input")] PermissionOperationConflict(String), + #[error("legacy permission authority is permanently sealed")] + PermissionAuthoritySealed, + #[error("permission transfer conflicts with identity, export or retained receipt")] + PermissionTransferConflict, #[error("authorization denied: {0}")] AuthorizationDenied(String), #[error("Worker execution grant binding conflicts for `{0}`")] @@ -689,6 +697,7 @@ pub const REGISTRATION: FeatureRegistration = #[derive(Clone)] pub struct SubjektivStore { database: FeatureDatabase, + canonical_permission_source: Option>, } impl SubjektivStore { @@ -746,7 +755,10 @@ impl SubjektivStore { } } })?; - Ok(Self { database }) + Ok(Self { + database, + canonical_permission_source: None, + }) } pub fn scope_id(&self) -> &str { diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs index bd878eda7..007fbc9ee 100644 --- a/crates/subjektiv/src/permissions.rs +++ b/crates/subjektiv/src/permissions.rs @@ -12,6 +12,12 @@ use server_api::authorization::{ }; use sha2::{Digest, Sha256}; +pub(crate) mod transfer; +pub use transfer::{ + CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport, + PermissionTransferReceipt, PermissionTransferSnapshot, +}; + pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000; pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { @@ -23,9 +29,9 @@ impl SubjektivStore { /// Unset sources are `None` (deny), including migrated Subjects. This is an admin read, /// not an effective-authorization decision; retired Subjects retain their source evidence. pub fn subject_permissions(&self, subject_id: &str) -> Result> { - self.database.try_with_connection(|connection| { + self.database.try_transaction(|connection| { require_subject_in_connection(connection, subject_id)?; - source_in_connection(connection, subject_id) + self.current_permission_source(connection, subject_id) }) } @@ -43,7 +49,8 @@ impl SubjektivStore { /// Management replacement rejects new unsupported operation/resource pairs while /// retaining exact legacy entries in their original execution/delegation lane. /// Validation shares the existing CAS/receipt transaction so a stale editor cannot - /// retain a removed legacy grant. Exact replay remains historical evidence. + /// retain a removed legacy grant. Before sealing, exact replay remains historical evidence; + /// once sealed, replacements reject even an otherwise exact historical receipt replay. pub fn set_managed_subject_permissions( &self, subject_id: &str, @@ -71,6 +78,7 @@ impl SubjektivStore { .map(|byte| format!("{byte:02x}")) .collect(); self.database.try_transaction(|tx| { + transfer::require_unsealed(tx)?; require_subject(tx, subject_id)?; let receipt: Option<(String, String)> = tx.query_row( "SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1", @@ -129,7 +137,7 @@ impl SubjektivStore { }; } require_active_subject(tx, &grant.subject_id)?; - let source = source_in_connection(tx, &grant.subject_id)? + let source = self.current_permission_source(tx, &grant.subject_id)? .ok_or_else(|| denied("Subject permissions are unset"))?; if source.change_id != grant.source_change_id { return Err(denied("Subject permission source is no longer current")); @@ -148,7 +156,12 @@ impl SubjektivStore { require_covered(&origin.redelegation, &grant.permissions)?; require_covered(&origin.redelegation, &grant.redelegation)?; } - tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + let table = if transfer::is_sealed(tx)? { + // Canonical IDs must not be mirrored into legacy source history to satisfy its FK. + crate::storage_formats::install_permission_transfer(tx)?; + "canonical_worker_execution_grants" + } else { "worker_execution_grants" }; + tx.execute(&format!("INSERT INTO {table}(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)"), params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?; Ok(()) }) @@ -164,9 +177,12 @@ impl SubjektivStore { validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; self.database.try_with_connection(|connection| { require_subject_in_connection(connection, subject_id)?; - let mut statement = connection.prepare( - "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2", - )?; + let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? { + "SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2" + } else { + "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2" + }; + let mut statement = connection.prepare(sql)?; let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?; let mut grants = Vec::new(); while let Some(row) = rows.next()? { @@ -216,12 +232,13 @@ fn grant_in_connection( // Absence must never become non-Subject fallback authority after purge. deletion::require_live_worker(connection, runtime, worker)?; + let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? { + "SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) g WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=g.subject_id)" + } else { + "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)" + }; let raw: Option = connection - .query_row( - "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)", - 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 new file mode 100644 index 000000000..aa2ce06c5 --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer.rs @@ -0,0 +1,417 @@ +//! 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. +/// Implementations must resolve the exact Workspace/Subject pair and propagate failures. +/// Calls are synchronous under the FeatureDatabase guard; do not reenter this store. +pub trait CanonicalPermissionSource: Send + Sync { + fn subject_permissions( + &self, + workspace_id: &str, + subject_id: &str, + ) -> Result>; +} + +/// Serializable transfer DTO for FeatureStorage's typed identity, never a physical path. +/// FeatureStorage owns identity validation; this DTO does not interpret storage metadata. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DatabaseIdentity { + pub scope_id: String, + pub feature_id: String, + pub physical_id: String, +} + +impl From<&feature_storage::DatabaseIdentity> for DatabaseIdentity { + fn from(identity: &feature_storage::DatabaseIdentity) -> Self { + Self { + scope_id: identity.scope.clone(), + feature_id: identity.feature.clone(), + physical_id: identity.physical.clone(), + } + } +} + +impl DatabaseIdentity { + fn storage_identity(&self) -> feature_storage::DatabaseIdentity { + feature_storage::DatabaseIdentity { + scope: self.scope_id.clone(), + feature: self.feature_id.clone(), + physical: self.physical_id.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionSourceExport { + pub subject_id: String, + /// `None` is unset; `Some` with empty vectors is an explicit revocation. + pub permissions: Option, +} + +/// Complete, Subject-ID-sorted policy snapshot. History and grants stay in the old database. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionTransferSnapshot { + pub identity: DatabaseIdentity, + pub sources: Vec, + /// SHA-256 of the versioned identity and sorted policy vector, excluding this field. + pub export_digest: String, +} + +/// Immutable preparation evidence, not evidence that Server settings have been adopted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionTransferReceipt { + pub transfer_id: String, + pub snapshot: PermissionTransferSnapshot, +} + +/// 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 { + /// 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 + } + + /// Reads physical identity under the FeatureStorage connection guard and scope check. + pub fn database_identity(&self) -> Result { + let identity = DatabaseIdentity::from(self.database.identity()); + self.permission_transfer_transaction(&identity, |_| Ok(identity.clone())) + } + + /// Reads one coherent pre-seal snapshot without installing schema or changing authority. + /// After a transfer, returns the retained exact snapshot, never a changed/deleted live set. + pub fn preview_permission_transfer( + &self, + expected: &DatabaseIdentity, + ) -> Result { + self.permission_transfer_transaction(expected, |tx| { + if let Some(receipt) = retained_receipt(tx)? { + require_receipt_identity(&receipt, expected)?; + return Ok(receipt.snapshot); + } + require_unsealed(tx)?; + export_in_connection(tx, expected.clone()) + }) + } + + /// 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. + /// A successful seal does not adopt any Server configuration. Never call two commits atomic. + pub fn seal_permission_sources( + &self, + expected: &DatabaseIdentity, + transfer_id: &str, + expected_export_digest: &str, + ) -> Result { + self.permission_transfer_transaction(expected, |tx| { + seal_in_transaction(tx, expected, transfer_id, expected_export_digest) + }) + } + + fn permission_transfer_transaction( + &self, + expected: &DatabaseIdentity, + action: impl FnOnce(&Transaction<'_>) -> Result, + ) -> Result { + if *expected != DatabaseIdentity::from(self.database.identity()) { + return Err(SubjektivError::PermissionTransferConflict); + } + // Validate the caller's native expected identity without holding a separate guard + // across transaction entry. try_transaction rechecks the same immutable bound identity + // before/after the operation and owns commit, including OutcomeUnknown errors. + drop(self.database.guard(&expected.storage_identity())?); + self.database.try_transaction(|tx| { + require_store_scope(tx, self.scope_id())?; + action(tx) + }) + } + + pub(super) fn current_permission_source( + &self, + connection: &Connection, + subject: &str, + ) -> Result> { + if !is_sealed(connection)? { + return source_in_connection(connection, subject); + } + let provider = self.canonical_permission_source.as_ref() + .ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?; + let source = provider.subject_permissions(self.scope_id(), subject)?; + if let Some(source) = &source { + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; + validate_scope(self.scope_id(), &source.execution)?; + validate_scope(self.scope_id(), &source.delegation)?; + } + Ok(source) + } +} + +/// 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), + )?) +} + +pub(crate) fn is_sealed(connection: &Connection) -> Result { + if !has_table(connection, "permission_authority_seal")? { + return Ok(false); + } + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", + [], + |row| row.get(0), + )?) +} + +pub(super) fn require_unsealed(connection: &Connection) -> Result<()> { + if is_sealed(connection)? { + return Err(SubjektivError::PermissionAuthoritySealed); + } + Ok(()) +} + +fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> { + let stored: String = connection.query_row( + "SELECT workspace_id FROM store_scope WHERE singleton=1", + [], + |row| row.get(0), + )?; + if stored != scope { + return Err(SubjektivError::PermissionTransferConflict); + } + Ok(()) +} + +fn require_receipt_identity( + receipt: &PermissionTransferReceipt, + expected: &DatabaseIdentity, +) -> Result<()> { + if receipt.snapshot.identity != *expected { + return Err(SubjektivError::PermissionTransferConflict); + } + Ok(()) +} + +fn retained_receipt(connection: &Connection) -> Result> { + if !has_table(connection, "permission_source_transfer_receipt")? { + return Ok(None); + } + let raw: Option = connection.query_row( + "SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1", + [], |row| row.get(0), + ).optional()?; + let receipt: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if receipt.is_some() && !is_sealed(connection)? { + return Err(SubjektivError::PermissionTransferConflict); + } + Ok(receipt) +} + +fn export_in_connection( + connection: &Connection, + identity: DatabaseIdentity, +) -> Result { + let orphaned: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)", + [], |row| row.get(0), + )?; + if orphaned { + return Err(SubjektivError::PermissionTransferConflict); + } + let mut statement = connection.prepare( + "SELECT s.subject_id,p.change_id,c.record_json FROM subjects s + LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id + LEFT JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id + ORDER BY s.subject_id", + )?; + let mut rows = statement.query([])?; + let mut sources = Vec::new(); + while let Some(row) = rows.next()? { + let subject_id: String = row.get(0)?; + validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?; + let change: Option = row.get(1)?; + let raw: Option = row.get(2)?; + let permissions: Option = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() { + return Err(SubjektivError::PermissionTransferConflict); + } + if let Some(source) = &permissions { + validate_authorization_identifier(&source.change_id) + .map_err(SubjektivError::InvalidRecord)?; + validate_scope(&identity.scope_id, &source.execution)?; + validate_scope(&identity.scope_id, &source.delegation)?; + } + sources.push(PermissionSourceExport { + subject_id, + permissions, + }); + } + let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(PermissionTransferSnapshot { + identity, + sources, + export_digest, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/permissions/transfer.sql b/crates/subjektiv/src/permissions/transfer.sql new file mode 100644 index 000000000..a2a370a2b --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer.sql @@ -0,0 +1,49 @@ +-- Permission transfer domain v1, independent of published common migration numbers. +-- This receipt is preparation evidence only, never Server configuration adoption. +CREATE TABLE permission_source_transfer_receipt ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), + schema_version INTEGER NOT NULL CHECK(schema_version=1), + receipt_json TEXT NOT NULL +); +CREATE TRIGGER permission_source_transfer_no_replace BEFORE INSERT ON permission_source_transfer_receipt +WHEN EXISTS(SELECT 1 FROM permission_source_transfer_receipt) +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt already exists'); END; +CREATE TRIGGER permission_source_transfer_no_update BEFORE UPDATE ON permission_source_transfer_receipt +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is immutable'); END; +CREATE TRIGGER permission_source_transfer_no_delete BEFORE DELETE ON permission_source_transfer_receipt +BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is retained'); END; +-- Published Permission18's UPDATE/DELETE guards alone do not fence INSERT OR REPLACE. +CREATE TRIGGER permission_authority_seal_no_replace BEFORE INSERT ON permission_authority_seal +WHEN EXISTS(SELECT 1 FROM permission_authority_seal) +BEGIN SELECT RAISE(ABORT, 'permission authority seal already exists'); END; +CREATE TRIGGER subject_creation_receipts_no_replace BEFORE INSERT ON subject_creation_receipts +WHEN EXISTS(SELECT 1 FROM subject_creation_receipts WHERE operation_id=NEW.operation_id OR subject_id=NEW.subject_id) +BEGIN SELECT RAISE(ABORT, 'Subject creation receipt already exists'); END; + +-- Execution binding evidence only, not a policy mirror. Canonical source IDs cannot reference +-- legacy subject_permission_changes. Preserve that table/FK and all old grant rows unchanged. +CREATE TABLE canonical_worker_execution_grants ( + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY(runtime_id,worker_id) +); +CREATE TRIGGER canonical_worker_execution_grants_no_replace BEFORE INSERT ON canonical_worker_execution_grants +WHEN EXISTS(SELECT 1 FROM canonical_worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) + OR EXISTS(SELECT 1 FROM worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker execution grant already exists'); END; +CREATE TRIGGER canonical_worker_execution_grants_no_update BEFORE UPDATE ON canonical_worker_execution_grants +BEGIN SELECT RAISE(ABORT, 'Worker execution grants are immutable'); END; +CREATE TRIGGER canonical_worker_execution_grants_no_delete BEFORE DELETE ON canonical_worker_execution_grants +WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id) +BEGIN SELECT RAISE(ABORT, 'Worker execution grants are retained'); END; +CREATE TRIGGER canonical_worker_execution_grants_insert_guard BEFORE INSERT ON canonical_worker_execution_grants +WHEN NOT EXISTS(SELECT 1 FROM permission_authority_seal) + OR EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id) + OR EXISTS(SELECT 1 FROM subject_deleted_workers WHERE + (runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id) + OR (runtime_id=json_extract(NEW.record_json,'$.origin_runtime_id') + AND worker_id=json_extract(NEW.record_json,'$.origin_worker_id'))) +BEGIN SELECT RAISE(ABORT, 'canonical grant requires sealed authority and live Subject execution identities'); END; diff --git a/crates/subjektiv/src/permissions/transfer/tests.rs b/crates/subjektiv/src/permissions/transfer/tests.rs new file mode 100644 index 000000000..e237136c0 --- /dev/null +++ b/crates/subjektiv/src/permissions/transfer/tests.rs @@ -0,0 +1,935 @@ +//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim. +use super::*; +use crate::tests::{open_store, role}; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant, +}; +use std::collections::BTreeMap; +use std::sync::{Barrier, Mutex}; + +fn read_permission() -> ScopedPermission { + ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: ".".into(), + recursive: true, + }, + } +} + +fn replace( + store: &SubjektivStore, + subject: &str, + operation: &str, + delegation: Vec, +) -> (SubjectPermissionsUpdate, SubjectPermissions) { + let update = SubjectPermissionsUpdate { + operation_id: operation.into(), + expected_change_id: store + .subject_permissions(subject) + .unwrap() + .map(|source| source.change_id), + execution: vec![], + delegation, + }; + let source = store.set_subject_permissions(subject, &update).unwrap(); + (update, source) +} + +fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), + source_change_id: source.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "host-authenticated-root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![read_permission()], + redelegation: vec![], + } +} + +fn counts(store: &SubjektivStore) -> Vec { + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", + ] + .iter() + .map(|table| { + Ok( + connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + })?, + ) + }) + .collect() + }) + .unwrap() +} + +fn seal(store: &SubjektivStore) -> PermissionTransferReceipt { + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store + .seal_permission_sources( + &identity, + "server-request-fingerprint", + &preview.export_digest, + ) + .unwrap() +} + +#[test] +fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_component() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let identity = store.database_identity().unwrap(); + let native = store.database.identity(); + assert_eq!(identity.scope_id, native.scope); + assert_eq!(identity.feature_id, native.feature); + assert_eq!(identity.physical_id, native.physical); + let encoded = serde_json::to_value(&identity).unwrap(); + assert_eq!( + encoded, + serde_json::json!({ + "scope_id": native.scope, + "feature_id": native.feature, + "physical_id": native.physical, + }) + ); + let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap(); + let restored = decoded.storage_identity(); + assert_eq!(restored.scope, native.scope); + assert_eq!(restored.feature, native.feature); + assert_eq!(restored.physical, native.physical); + drop(store.database.guard(&restored).unwrap()); + let preview = store.preview_permission_transfer(&decoded).unwrap(); + for component in ["scope", "feature", "physical"] { + let mut wrong = decoded.clone(); + match component { + "scope" => wrong.scope_id.push_str("-different"), + "feature" => wrong.feature_id.push_str("-different"), + "physical" => wrong.physical_id.push_str("-different"), + _ => unreachable!(), + } + assert!(matches!( + store.preview_permission_transfer(&wrong), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.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() + ); +} + +#[test] +fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_after_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (manager, scope, store) = open_store(temp.path(), "workspace-a"); + let unset = store.create_subject(role()).unwrap().id; + let empty = store.create_subject(role()).unwrap().id; + let limited = store.create_subject(role()).unwrap().id; + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset], + )?; + Ok(()) + }) + .unwrap(); + let (_, empty_source) = replace(&store, &empty, "empty", vec![]); + let (old_update, limited_source) = + replace(&store, &limited, "limited", vec![read_permission()]); + let grant = child(&limited, &limited_source.change_id, "historical-child"); + store.bind_worker_execution_grant(&grant).unwrap(); + let before = counts(&store); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + assert_eq!(preview.sources.len(), 3); + assert!( + preview + .sources + .windows(2) + .all(|pair| pair[0].subject_id < pair[1].subject_id) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == unset) + .unwrap() + .permissions, + None + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == empty) + .unwrap() + .permissions, + Some(empty_source) + ); + assert_eq!( + preview + .sources + .iter() + .find(|row| row.subject_id == limited) + .unwrap() + .permissions, + Some(limited_source.clone()) + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); + // Preview is physically read-only, including the domain schema. + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); + let receipt = store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap(); + assert_eq!(receipt.snapshot, preview); + assert_eq!(counts(&store), before); + assert_eq!( + store + .worker_execution_grant("runtime", "historical-child") + .unwrap(), + Some(grant.clone()) + ); + store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority. + assert!(matches!( + store.set_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.set_managed_subject_permissions(&limited, &old_update), + Err(SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "different", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", "different-export"), + Err(SubjektivError::PermissionTransferConflict) + )); + let mut wrong_identity = identity.clone(); + wrong_identity.scope_id = "workspace-b".into(); + assert!(matches!( + store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store.preview_permission_transfer(&wrong_identity), + Err(SubjektivError::PermissionTransferConflict) + )); + drop(store); + scope.shutdown().unwrap(); + drop(scope); + drop(manager); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert_eq!(identity, store.database_identity().unwrap()); + assert_eq!( + receipt, + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .unwrap() + ); + assert_eq!( + preview, + store.preview_permission_transfer(&identity).unwrap() + ); + assert_eq!(counts(&store), before); + assert!(matches!( + store.subject_permissions(&limited), + Err(SubjektivError::AuthorizationDenied(_)) + )); +} + +#[test] +fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + replace(&store, &subject, "old-writer-after-preview", vec![]); + assert!(matches!( + store.seal_permission_sources(&identity, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| has_table( + c, + "permission_source_transfer_receipt" + )) + .unwrap() + ); + replace(&store, &subject, "still-writable", vec![]); + let fresh = store.preview_permission_transfer(&identity).unwrap(); + assert_ne!(fresh.export_digest, preview.export_digest); + store + .seal_permission_sources(&identity, "new-request", &fresh.export_digest) + .unwrap(); +} + +#[test] +fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() { + let temp = tempfile::tempdir().unwrap(); + let (manager, scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + store + .database + .transaction(crate::storage_formats::install_permission_transfer) + .unwrap(); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; + Ok(()) + }).unwrap(); + assert!( + store + .seal_permission_sources(&identity, "transfer", &preview.export_digest) + .is_err() + ); + assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(|c| is_sealed(c)) + .unwrap() + ); + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)) + .unwrap() + .is_none() + ); + replace(&store, &subject, "writer-after-rollback", vec![]); + drop(store); + scope.shutdown().unwrap(); + drop(scope); + drop(manager); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert!(store.subject_permissions(&subject).unwrap().is_some()); + seal(&store); +} + +struct Canonical { + sources: BTreeMap>, + calls: Mutex>, +} +impl CanonicalPermissionSource for Canonical { + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> Result> { + self.calls + .lock() + .unwrap() + .push((workspace.into(), subject.into())); + if workspace != "workspace-a" { + return Err(denied("foreign Workspace")); + } + self.sources + .get(subject) + .cloned() + .ok_or_else(|| denied("canonical source unavailable")) + } +} + +#[test] +fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() + { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let (_, old) = replace(&store, &subject, "old", vec![read_permission()]); + let receipt = seal(&store); + assert!(matches!( + store.subject_permissions(&subject), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!(matches!( + store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let before = counts(&store); + let created = store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap(); + assert_eq!( + created, + store + .create_subject_idempotent(role(), "new behavior".into(), "create-after-seal") + .unwrap() + ); + let non_idempotent = store.create_subject(role()).unwrap(); + assert!( + store + .subject_permission_enrollment(&created.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&non_idempotent.id) + .unwrap() + .is_some() + ); + assert!( + store + .subject_permission_enrollment(&subject) + .unwrap() + .is_none() + ); + assert_eq!(counts(&store), before); + store.database.try_with_connection::<_, SubjektivError>(|connection| { + for id in [&created.id, &non_idempotent.id] { + let raw: String = connection.query_row("SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", [id], |row| row.get(0))?; + let enrollment: SubjectPermissions = serde_json::from_str(&raw)?; + assert!(!enrollment.execution.is_empty()); + assert!(source_in_connection(connection, id)?.is_none()); + } + Ok(()) + }).unwrap(); + let canonical = SubjectPermissions { + change_id: "canonical-change".into(), + execution: vec![], + delegation: vec![read_permission()], + }; + let provider = Arc::new(Canonical { + sources: BTreeMap::from([ + (subject.clone(), Some(canonical.clone())), + (created.id.clone(), None), + ]), + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider.clone()); + assert_eq!( + store.subject_permissions(&subject).unwrap(), + Some(canonical.clone()) + ); + assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default. + assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates. + assert!( + store + .bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")) + .is_err() + ); + store + .bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")) + .unwrap(); + assert_eq!( + store + .list_subject_worker_execution_grants(&subject) + .unwrap(), + vec![child(&subject, &canonical.change_id, "canonical-child")] + ); + assert_eq!( + store + .worker_execution_grant("runtime", "canonical-child") + .unwrap(), + Some(child(&subject, &canonical.change_id, "canonical-child")) + ); + for sql in [ + "INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants", + "UPDATE canonical_worker_execution_grants SET source_change_id='forged'", + "DELETE FROM canonical_worker_execution_grants", + ] { + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err() + ); + } + assert!( + provider + .calls + .lock() + .unwrap() + .iter() + .all(|(workspace, id)| workspace == "workspace-a" + && [ + subject.as_str(), + created.id.as_str(), + non_idempotent.id.as_str() + ] + .contains(&id.as_str())) + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + store + .begin_subject_deletion(&subject, "delete-canonical-subject") + .unwrap(); + let workers = store.deletion_subject_workers(&subject).unwrap(); + assert!(workers.contains(&("runtime".into(), "canonical-child".into()))); + assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into()))); + assert!( + store + .finish_subject_deletion(&subject, "delete-canonical-subject") + .unwrap() + .completed + ); + assert!( + store + .worker_execution_grant("runtime", "canonical-child") + .is_err() + ); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); +} + +#[test] +fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_remains_possible() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + replace(&store, &subject, "old-receipt", vec![]); + let receipt = seal(&store); + // Reacquire a managed store handle; tests must not infer/open physical storage paths. + let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap(); + assert!( + old_writer + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", + [&subject], + )?; + Ok(()) + }) + .is_err() + ); + drop(old_writer); + for sql in [ + "INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions", + "UPDATE subject_permissions SET change_id=change_id", + "DELETE FROM subject_permissions", + "INSERT OR REPLACE INTO subject_permission_changes SELECT * FROM subject_permission_changes", + "UPDATE subject_permission_changes SET record_json=record_json", + "DELETE FROM subject_permission_changes", + "INSERT OR REPLACE INTO subject_permission_receipts SELECT * FROM subject_permission_receipts", + "UPDATE subject_permission_receipts SET request_fingerprint=request_fingerprint", + "DELETE FROM subject_permission_receipts", + "INSERT INTO worker_execution_grants SELECT 'runtime','late-writer',subject_id,change_id,'{}' FROM subject_permissions LIMIT 1", + "INSERT OR REPLACE INTO permission_authority_seal SELECT * FROM permission_authority_seal", + "UPDATE permission_authority_seal SET transfer_id='changed'", + "DELETE FROM permission_authority_seal", + "INSERT OR REPLACE INTO permission_source_transfer_receipt SELECT * FROM permission_source_transfer_receipt", + "UPDATE permission_source_transfer_receipt SET receipt_json='{}'", + "DELETE FROM permission_source_transfer_receipt", + ] { + assert!( + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch(sql)?; + Ok(()) + }) + .is_err(), + "{sql}" + ); + } + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?; + tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&subject])?; + tx.execute("DELETE FROM subject_permission_receipts WHERE subject_id=?1", [&subject])?; + tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?; + Ok(()) + }).unwrap(); + assert_eq!( + store + .preview_permission_transfer(&receipt.snapshot.identity) + .unwrap(), + receipt.snapshot + ); + assert_eq!( + store + .seal_permission_sources( + &receipt.snapshot.identity, + &receipt.transfer_id, + &receipt.snapshot.export_digest + ) + .unwrap(), + receipt + ); +} + +#[test] +fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_export() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); + let identity = store.database_identity().unwrap(); + let preview = store.preview_permission_transfer(&identity).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let writer = store.clone(); + let start = barrier.clone(); + let thread = std::thread::spawn(move || { + start.wait(); + writer.set_subject_permissions( + &subject, + &SubjectPermissionsUpdate { + operation_id: "racing-old-writer".into(), + expected_change_id: Some(initial.change_id), + execution: vec![], + delegation: vec![], + }, + ) + }); + barrier.wait(); + let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest); + let written = thread.join().unwrap(); + match (sealed, written) { + (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => { + assert_eq!(receipt.snapshot, preview) + } + (Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!( + !store + .database + .try_with_connection::<_, SubjektivError>(is_sealed) + .unwrap() + ), + other => panic!("unexpected serialized outcomes: {other:?}"), + } +} + +#[test] +fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + seal(&store); + let provider = Arc::new(Canonical { + sources: BTreeMap::from([( + subject.clone(), + Some(SubjectPermissions { + change_id: "foreign-source".into(), + execution: vec![ScopedPermission { + operation: "file.read".into(), + resource: PermissionResource::Workspace { + workspace_id: "workspace-b".into(), + }, + }], + delegation: vec![read_permission()], + }), + )]), + calls: Mutex::new(vec![]), + }); + let store = store.with_canonical_permission_source(provider); + assert!(store.subject_permissions(&subject).is_err()); + assert!( + store + .bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")) + .is_err() + ); + assert!( + store + .worker_execution_grant("runtime", "foreign-child") + .unwrap() + .is_none() + ); +} + +#[test] +fn 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] +fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_workspace() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let (_manager_a, _scope_a, store_a) = open_store(first.path(), "workspace-a"); + let (_manager_b, _scope_b, store_b) = open_store(second.path(), "workspace-a"); + let a = store_a.database_identity().unwrap(); + let b = store_b.database_identity().unwrap(); + assert_ne!(a, b); + let preview = store_a.preview_permission_transfer(&a).unwrap(); + assert!(matches!( + store_b.preview_permission_transfer(&a), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(matches!( + store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), + Err(SubjektivError::PermissionTransferConflict) + )); + assert!(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 fad671e2f..e46933b2c 100644 --- a/crates/subjektiv/src/storage_format_tests.rs +++ b/crates/subjektiv/src/storage_format_tests.rs @@ -579,3 +579,159 @@ fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds( drop(db); scope.shutdown().unwrap(); } + +#[test] +fn permission_transfer_extension_reopens_on_every_published_family_without_relabelling_receipts() { + for format in PUBLISHED { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), format); + let (scope, db) = open(temp.path(), REGISTRATION); + let saved = db + .with_connection(|connection| Ok(receipts(connection))) + .unwrap(); + let old_seal = db + .with_connection(|connection| { + if format == Format::Permission18 { + Ok(Some(connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0), + )?)) + } else { + Ok(None) + } + }) + .unwrap(); + db.transaction(install_permission_transfer).unwrap(); + db.with_connection(|connection| { + assert_eq!(receipts(connection), saved); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); + if let Some(raw) = &old_seal { + assert_eq!( + connection.query_row( + "SELECT export_json FROM permission_authority_seal", + [], + |row| row.get::<_, String>(0) + )?, + *raw + ); + } + assert_foreign_keys(connection); + Ok(()) + }) + .unwrap(); + let store = SubjektivStore::from_database(db.clone()).unwrap(); + if old_seal.is_some() { + let identity = store.database_identity().unwrap(); + assert!(matches!( + store.preview_permission_transfer(&identity), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); + assert!(matches!( + store.seal_permission_sources(&identity, "new-transfer", "export"), + Err(crate::SubjektivError::PermissionAuthoritySealed) + )); + struct CanonicalEmpty; + impl crate::CanonicalPermissionSource for CanonicalEmpty { + fn subject_permissions( + &self, + workspace: &str, + _subject: &str, + ) -> crate::Result> + { + assert_eq!(workspace, SCOPE); + Ok(Some(server_api::authorization::SubjectPermissions { + change_id: "canonical-source".into(), + execution: vec![], + delegation: vec![], + })) + } + } + let connected = store + .clone() + .with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); + let subject = connected.list_subjects(1).unwrap().items[0].id.clone(); + assert_eq!( + connected + .subject_permissions(&subject) + .unwrap() + .unwrap() + .change_id, + "canonical-source" + ); + connected + .bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { + subject_id: subject, + source_change_id: "canonical-source".into(), + runtime_id: "runtime".into(), + worker_id: "canonical-child".into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "root".into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile".into(), + permissions: vec![], + redelegation: vec![], + }) + .unwrap(); + } + drop(store); + drop(db); + scope.shutdown().unwrap(); + let (scope, db) = open(temp.path(), REGISTRATION); + db.with_connection(|connection| { + assert_eq!(receipts(connection), saved); + assert_eq!( + domain_shape(connection)?, + reference_shape_with_transfer(format, true)? + ); + assert_foreign_keys(connection); + Ok(()) + }) + .unwrap(); + drop(db); + scope.shutdown().unwrap(); + } +} + +#[test] +fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (scope, db) = open(temp.path(), REGISTRATION); + let before_shape = db.with_connection(domain_shape).unwrap(); + let before_rows = db + .with_connection(|connection| Ok(all_rows(connection))) + .unwrap(); + let result = db.transaction(|tx| { + install_permission_transfer(tx)?; + tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?; + Ok(()) + }); + assert!(result.is_err()); + db.with_connection(|connection| { + assert_eq!(domain_shape(connection)?, before_shape); + assert_eq!(all_rows(connection), before_rows); + Ok(()) + }) + .unwrap(); + db.transaction(install_permission_transfer).unwrap(); + db.with_connection(|connection| { + connection + .execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; + Ok(()) + }) + .unwrap(); + drop(db); + scope.shutdown().unwrap(); + let scope = feature_storage::FeatureStorage::new(temp.path()) + .scope(SCOPE) + .unwrap(); + let registered = scope.register(REGISTRATION).unwrap(); + assert!(matches!( + scope.open(®istered), + Err(FeatureStorageError::MigrationHistory { .. }) + )); + scope.shutdown().unwrap(); +} diff --git a/crates/subjektiv/src/storage_formats.rs b/crates/subjektiv/src/storage_formats.rs index 4e2d29dbd..f6991a15f 100644 --- a/crates/subjektiv/src/storage_formats.rs +++ b/crates/subjektiv/src/storage_formats.rs @@ -81,7 +81,16 @@ pub(super) fn validate( applied: &[(u32, String)], ) -> feature_storage::Result<()> { let format = recognize(applied)?; - let expected = reference_shape(format)?; + let transfer = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='permission_source_transfer_receipt')", + [], |row| row.get::<_, bool>(0), + )?; + if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) { + return Err(incompatible( + "permission transfer requires complete common 1..17", + )); + } + let expected = reference_shape_with_transfer(format, transfer)?; let actual = domain_shape(tx)?; if actual != expected { let difference = actual @@ -115,9 +124,17 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result feature_storage::Result> { + reference_shape_with_transfer(format, false) +} + +fn reference_shape_with_transfer( + format: Format, + transfer: bool, +) -> feature_storage::Result> { // Cache reference shapes only; every target is reread under its caller's transaction. - static SHAPES: [OnceLock>; 25] = [const { OnceLock::new() }; 25]; + static SHAPES: [OnceLock>; 50] = [const { OnceLock::new() }; 50]; let index = match format { Format::Common(count) => count, Format::Permission18 => 18, @@ -127,7 +144,7 @@ fn reference_shape(format: Format) -> feature_storage::Result> Format::Consolidation20 => 22, Format::Fixed20 => 23, Format::Cancellation20 => 24, - }; + } + if transfer { 25 } else { 0 }; if let Some(shape) = SHAPES[index].get() { return Ok(shape.clone()); } @@ -184,11 +201,66 @@ fn reference_shape(format: Format) -> feature_storage::Result> tx.execute_batch(CONSOLIDATION_SQL)?; } } + if transfer { + install_transfer_shape(&tx, format == Format::Permission18)?; + } let shape = domain_shape(&tx)?; let _ = SHAPES[index].set(shape.clone()); Ok(shape) } +/// Installs a separate permission-domain v1 shape without changing common/published receipts. +/// Existing published seals and their serialized exports are never rewritten or relabelled. +/// The FeatureDatabase guard and caller's transaction cover validation and installation. +pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> { + let mut statement = tx.prepare(&format!( + "SELECT version,name FROM {HISTORY_TABLE} ORDER BY version" + ))?; + let applied = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + let format = recognize(&applied)?; + if matches!(format, Format::Common(count) if count < COMMON_COUNT) { + return Ok(()); + } + validate(tx, &applied)?; + let installed: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='permission_source_transfer_receipt')", + [], |row| row.get(0), + )?; + if !installed { + install_transfer_shape(tx, format == Format::Permission18)?; + } + validate(tx, &applied) +} + +fn install_transfer_shape( + tx: &Transaction<'_>, + published_permission: bool, +) -> feature_storage::Result<()> { + if !published_permission { + tx.execute_batch(PERMISSION_SEAL_SQL)?; + } + tx.execute_batch(include_str!("permissions/transfer.sql"))?; + // Seal every legacy writer. New execution binding evidence has no legacy-source FK and + // lives in the extension table, leaving published grants and their triggers intact. + for table in PERMISSION_FENCE_TABLES { + for action in ["INSERT", "UPDATE", "DELETE"] { + let deletion_exception = if action == "DELETE" { + " AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)" + } else { + "" + }; + tx.execute_batch(&format!( + "CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception} + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + ))?; + } + } + Ok(()) +} + const PERMISSION_FENCE_TABLES: [&str; 4] = [ "subject_permissions", "subject_permission_changes", @@ -196,7 +268,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [ "worker_execution_grants", ]; -// Frozen physical shape evidence, not target migrations. Permission SQL is from +// Published DDL is retained byte-for-byte as physical shape evidence. Only the permission +// fragment is also reused by the explicit separate-domain initializer above, never as migration 18. +// Permission SQL is from // 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs. // Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03: // crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql, diff --git a/crates/subjektiv/src/subject_creation.rs b/crates/subjektiv/src/subject_creation.rs index 38f169a7b..388eb1fed 100644 --- a/crates/subjektiv/src/subject_creation.rs +++ b/crates/subjektiv/src/subject_creation.rs @@ -1,6 +1,7 @@ -//! New Subjects receive their self-scoped conversation source in the creation transaction. -//! This path is never called by migration, launch, restore or permission reads. Receipts -//! retain only the issued identity and input fingerprint across erasure, preventing replay +//! Before sealing, new Subjects receive their self-scoped source in the creation transaction. +//! After sealing, creation retains enrollment evidence only; current reads never revive defaults. +//! This path is never called by migration, launch, restore or permission reads. Receipts retain +//! identity, input fingerprint and any post-seal enrollment across erasure, preventing replay //! from recreating an erased Subject or restoring a revoked policy. use super::*; use server_api::authorization::{SubjectPermissions, subject_conversation_permissions}; @@ -18,6 +19,25 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { } impl SubjektivStore { + /// Creation-only enrollment evidence for the canonical domain. Never a current permission + /// source or a read-time default; the Host must not apply it to an existing/unset policy. + pub fn subject_permission_enrollment( + &self, + subject_id: &str, + ) -> Result> { + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + if !permissions::transfer::has_table(tx, "permission_authority_seal")? { + return Ok(None); + } + let raw: Option = tx.query_row( + "SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", + [subject_id], |row| row.get::<_, Option>(0), + ).optional()?.flatten(); + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)).transpose() + }) + } + pub(super) fn create_subject_request( &self, role: SubjectRole, @@ -60,10 +80,20 @@ impl SubjektivStore { execution: subject_conversation_permissions(self.scope_id(), &record.id), delegation: vec![], }; - tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", - params![record.id,source.change_id,serde_json::to_string(&source)?])?; - tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; - if let Some(operation) = operation_id { + let sealed = permissions::transfer::is_sealed(tx)?; + if !sealed { + tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", + params![record.id,source.change_id,serde_json::to_string(&source)?])?; + tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; + } + if sealed { + // Retain creation-only enrollment for the canonical domain, never a legacy + // source write or a read-time default. Even non-idempotent creation has evidence. + let enrollment_operation = operation_id.map(str::to_owned) + .unwrap_or_else(|| format!("subject-enrollment:{}", record.id)); + tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint,permission_enrollment_json) VALUES(?1,?2,?3,?4)", + params![enrollment_operation,record.id,fingerprint,serde_json::to_string(&source)?])?; + } else if let Some(operation) = operation_id { tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?; } Ok(record) diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index d591008a4..5d04e4c6d 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -542,6 +542,8 @@ pub struct RuntimeHttpErrorResponse { pub struct RuntimeHttpErrorDetail { pub code: String, pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub startup: Option, } #[cfg(feature = "ws-server")] @@ -2325,6 +2327,7 @@ struct RuntimeHttpRestError { status: StatusCode, code: String, message: String, + startup: Option, } impl RuntimeHttpRestError { @@ -2333,13 +2336,28 @@ impl RuntimeHttpRestError { status, code: code.into(), message: message.into(), + startup: None, } } fn runtime(error: RuntimeError) -> Self { let status = status_for_runtime_error(&error); let code = code_for_runtime_error(&error); - Self::new(status, code, error.to_string()) + let startup = match &error { + RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result), + _ => None, + }; + let message = if startup.is_some() { + "Worker startup failed".to_owned() + } else { + error.to_string() + }; + Self { + status, + code, + message, + startup, + } } fn working_directory_join(_error: tokio::task::JoinError) -> Self { @@ -2375,12 +2393,31 @@ impl IntoResponse for RuntimeHttpRestError { error: RuntimeHttpErrorDetail { code: self.code, message: self.message, + startup: self.startup, }, }; (self.status, Json(body)).into_response() } } +/// Explicit transfer mapping, not another execution/lifecycle result. HTTP request +/// authentication has already established this internal Runtime caller's scope. +fn startup_transfer( + result: &crate::execution::WorkerExecutionResult, +) -> Option { + let primary = worker::startup_failure::reproject_internal_startup_failure( + result.startup_failure.as_ref()?, + ) + .ok()?; + Some(runtime_api::WorkerStartupFailure { + primary, + secondary: result.secondary_failures.clone(), + resources: result.startup_resources, + persistence: result.startup_failure_persistence, + io_kind: result.startup_io_kind, + }) +} + fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { match error { RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => { diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs index 860a8f266..cac820371 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -224,7 +224,10 @@ where } fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError { - runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message) + let mut response = + runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message); + response.error.startup = error.startup; + response } impl runtime_api::RuntimeApi for RuntimeManagementApi { diff --git a/crates/worker/src/feature/builtin/permission_catalog.rs b/crates/worker/src/feature/builtin/permission_catalog.rs new file mode 100644 index 000000000..16d952cbb --- /dev/null +++ b/crates/worker/src/feature/builtin/permission_catalog.rs @@ -0,0 +1,134 @@ +//! Server-side registration of the compiled builtin operation declarations. +//! +//! This is a trusted builtin loader, not a wire-catalog importer. Namespace +//! ownership is listed here explicitly; neither a permission spelling nor a +//! management request chooses an owner. The existing operation descriptors own +//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes +//! and actual operation authorization remain independent of this metadata load. + +use 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"), + ("notification", "builtin:notification"), +]; + +pub fn builtin_permission_host( + workspace: WorkspaceId, +) -> Result { + let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string()); + let mut host = PermissionHost::new(workspace); + let mut features = BTreeMap::new(); + // 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))?; + features.entry(owner).or_insert(feature) + } + }; + let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?; + let handle = host + .bind_namespace(feature, id.clone()) + .map_err(|e| invalid(&e))?; + let permissions = operations + .iter() + .filter(|operation| operation.operation.split('.').next() == Some(namespace)) + .map(|operation| { + Ok(PermissionDefinition { + key: PermissionKey::parse(operation.operation.clone()) + .map_err(|e| invalid(&e))?, + description: operation.description.clone(), + children: vec![], + }) + }) + .collect::, FeatureInstallError>>()?; + let keys = permissions + .iter() + .map(|definition| definition.key.clone()) + .collect(); + host.declare( + &handle, + NamespaceDeclaration { + permissions, + sets: vec![SetContribution { + set: PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: id, + keys, + })], + }], + }, + ) + .map_err(|e| invalid(&e))?; + } + // A new builtin operation needs an intentional namespace ownership decision. + for operation in operations { + if !OWNERS + .iter() + .any(|(namespace, _)| operation.operation.split('.').next() == Some(*namespace)) + { + return Err(FeatureInstallError::InvalidDescriptor(format!( + "builtin operation has no namespace owner: {}", + operation.operation + ))); + } + } + Ok(host) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn builtin_keys_are_complete_exact_and_have_no_inferred_children() { + let host = builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); + let catalog = host.catalog(); + let mut keys = std::collections::BTreeSet::new(); + for namespace in catalog.namespaces { + for permission in namespace.declaration.permissions { + assert!(permission.children.is_empty()); + assert!(keys.insert(permission.key.as_str().to_owned())); + } + } + assert!(!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/worker/src/feature/permissions.rs b/crates/worker/src/feature/permissions.rs index ec9bdbd58..e19adc2d6 100644 --- a/crates/worker/src/feature/permissions.rs +++ b/crates/worker/src/feature/permissions.rs @@ -146,3 +146,7 @@ impl FeaturePermissionRegistry { #[cfg(test)] mod tests; + +#[path = "builtin/permission_catalog.rs"] +mod builtin_catalog; +pub use builtin_catalog::builtin_permission_host; diff --git a/crates/worker/src/startup_failure.rs b/crates/worker/src/startup_failure.rs index 2c48c7138..b0c4c59fa 100644 --- a/crates/worker/src/startup_failure.rs +++ b/crates/worker/src/startup_failure.rs @@ -106,6 +106,20 @@ pub fn runtime_startup_attachment( StartupFailureAttachment::from_projected(&projected) } +/// The internal HTTP/Host transfer admits only implemented startup domains. +/// Unknown/corrupt envelopes cannot be blindly forwarded or classified from text. +pub fn reproject_internal_startup_failure( + failure: &StartupFailureAttachment, +) -> Result { + if let Ok(projected) = failure + .project_for::(&()) + { + return StartupFailureAttachment::from_projected(&projected); + } + let projected = failure.project_for::(&())?; + StartupFailureAttachment::from_projected(&projected) +} + /// Dispatch by the domain attested in process, not by parsing attachment fields. pub(crate) fn persist_binding_startup_failure( store: &S, diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 3e7c9b62d..82c34fde5 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -897,6 +897,10 @@ pub(crate) fn test_subject_authorization_response( }) } +#[cfg(test)] +#[path = "subjektiv_session_registration_tests.rs"] +mod session_registration_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/worker/src/subjektiv_session_registration_tests.rs b/crates/worker/src/subjektiv_session_registration_tests.rs new file mode 100644 index 000000000..70611ea6f --- /dev/null +++ b/crates/worker/src/subjektiv_session_registration_tests.rs @@ -0,0 +1,159 @@ +//! The real authorization/factory fixture lives in workspace-server. This leaf +//! exercises only WorkspaceClient transfer failures and response validation; it +//! must not substitute simulated policy decisions for that connected fixture. +use super::*; +use crate::{WorkspaceClientError, WorkspaceResponse}; +use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause as Cause}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug)] +struct Client { + reply: Reply, + calls: AtomicUsize, +} +#[derive(Debug)] +enum Reply { + Response(u16, String), + Unavailable, + Request, +} +impl WorkspaceClient for Client { + fn kind(&self) -> &str { + "registration-transfer-test" + } + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.method, WorkspaceRequestMethod::Post); + assert_eq!(request.path, "/api/w/workspace/subjektiv/sessions"); + match &self.reply { + Reply::Response(status, body) => Ok(WorkspaceResponse { + status: *status, + body: body.clone(), + }), + Reply::Unavailable => Err(WorkspaceClientError::Unavailable( + "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(), + )), + Reply::Request => Err(WorkspaceClientError::Request( + "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(), + )), + } + } +} +fn client(reply: Reply) -> Client { + Client { + reply, + calls: AtomicUsize::new(0), + } +} +fn failure_body(cause: Cause) -> String { + let mut error = RepositoryApiError::new( + 403, + "ignored", + "TEST_ONLY_CREDENTIAL /private/path raw_http_body", + vec![], + ); + error.failure = + Some(protocol::session_failure::session_registration_attachment(cause).unwrap()); + serde_json::to_string(&error).unwrap() +} +fn assert_failure(client: Client, expected: Cause) -> SubjektivHostError { + let error = record_backend_session(&client, "session", true).unwrap_err(); + assert_eq!( + client.calls.load(Ordering::SeqCst), + 1, + "classification grants no replay" + ); + assert_eq!(error.session_registration_cause(), expected); + let attachment = protocol::session_failure::session_registration_attachment( + error.session_registration_cause(), + ) + .unwrap(); + let encoded = serde_json::to_string(&attachment).unwrap(); + for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] { + assert!(!encoded.contains(secret)); + assert!(!error.to_string().contains(secret)); + } + error +} + +#[test] +fn session_registration_http_reuses_typed_causes_not_status_or_prose() { + for cause in [ + Cause::denied(SessionPrincipal::Operator), + Cause::denied(SessionPrincipal::Subject), + Cause::attribution_missing(), + Cause::binding_mismatch(), + Cause::outcome_unknown(), + ] { + for status in [403, 409, 500] { + assert_failure(client(Reply::Response(status, failure_body(cause))), cause); + } + } + // A legacy 403 without a domain attestation is not a certified denial. + let body = serde_json::to_string(&RepositoryApiError::new( + 403, + "permission_denied", + "session.record denied", + vec![], + )) + .unwrap(); + assert_failure(client(Reply::Response(403, body)), Cause::outcome_unknown()); +} + +#[test] +fn session_registration_transport_and_unknown_keep_error_sources_without_disclosure_or_retry() { + for (reply, cause) in [ + (Reply::Unavailable, Cause::transport()), + (Reply::Request, Cause::outcome_unknown()), + ] { + let error = assert_failure(client(reply), cause); + assert!( + std::error::Error::source(&error) + .unwrap() + .downcast_ref::() + .is_some() + ); + } + let legacy = + SubjektivHostError::PermissionDenied("session.record denied for secret subject".into()); + assert_eq!( + legacy.session_registration_cause(), + Cause::outcome_unknown() + ); +} + +#[test] +fn session_registration_rejects_malformed_incomplete_and_mismatched_responses() { + for (status, body) in [ + (403, "not json"), + (200, "not json"), + (200, "{}"), + (200, r#"{"session_id":"session","subject_id":""}"#), + ( + 200, + r#"{"session_id":"other-session","subject_id":"subject"}"#, + ), + ] { + assert_failure( + client(Reply::Response(status, body.into())), + Cause::invalid_response(), + ); + } + let allowed = client(Reply::Response( + 200, + r#"{"session_id":"session","subject_id":"subject"}"#.into(), + )); + let output = record_backend_session(&allowed, "session", true).unwrap(); + assert_eq!(output.session_id, "session"); + assert_eq!(output.subject_id, "subject"); + assert_eq!(allowed.calls.load(Ordering::SeqCst), 1); +} diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 9a828dd41..388ef0447 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -21,6 +21,10 @@ axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } futures.workspace = true config-source.workspace = true +content-history.workspace = true +content-history-store.workspace = true +content-history-adapters.workspace = true +feature-permissions.workspace = true feature-storage.workspace = true workspace-drive.workspace = true subjektiv.workspace = true @@ -66,9 +70,6 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] } webauthn-rs-proto = "0.5.5" [dev-dependencies] -content-history.workspace = true -content-history-store.workspace = true -content-history-adapters.workspace = true rusqlite = { workspace = true, features = ["hooks"] } feature-storage = { workspace = true, features = ["test-support"] } native-tls = "0.2" diff --git a/crates/workspace-server/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 new file mode 100644 index 000000000..018bec136 --- /dev/null +++ b/crates/workspace-server/src/config_adoption.rs @@ -0,0 +1,544 @@ +//! Owner-only adoption of Workspace source and Subject policy in the Server transaction. +//! +//! The old Feature database is exported and permanently sealed before its immutable +//! transfer is adopted here. That preparation is not a distributed commit: failure +//! after seal leaves an explicit, resumable transfer, never a legacy fallback. +//! Normal saves never touch the old database. Object preparation, source, +//! projection, policy, editing head and receipt commit in one caller-owned transaction. +//! +//! # 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}; + +use config_source::{ConfigTreeSnapshot, WorkspaceConfigSchemaBundle}; +use content_history::HistoryId; +use content_history_store::{Access, Authority, ScopeKey, ScopedStore}; +use feature_permissions::PermissionHost; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{Deserialize, Serialize}; + +use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState}; +use crate::policy_config::{self, AdoptedSubjectPolicy, PermissionSource}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +pub(crate) const SCHEMA: &str = include_str!("config_adoption.sql"); +pub(crate) const MIGRATION_NAME: &str = "Atomic Workspace source and Subject policy adoption"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionRequest { + pub operation_id: String, + /// Editing history identity, not a content digest. Null is only the initial head. + pub expected_head: Option, + pub expected_catalog_digest: String, + pub expected_legacy_export: Option, + pub source: ConfigCommitRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimePolicyApplication { + NextCurrentPolicyCheck, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationAdoptionResult { + pub workspace_id: String, + pub operation_id: String, + pub history_head: String, + pub state: WorkspaceConfigState, + pub source_stored: bool, + pub policy_committed: bool, + /// Consumers must reload current policy before their next protected operation. + /// This is not confirmation that a running Worker has stopped or reloaded. + pub runtime_application: RuntimePolicyApplication, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationPreview { + pub expected_head: Option, + pub state: WorkspaceConfigState, + pub changed_paths: Vec, + pub changed_subjects: Vec, +} + +/// Only the trusted migration boundary constructs this from a verified Feature +/// export. The transfer identity binds Workspace, physical DB, export and the full +/// adoption request. It is not an HTTP request or an authority credential. +pub(crate) struct ConfigurationPolicyTransfer { + pub transfer_identity: String, + pub physical_identity: String, + pub export_digest: String, + pub policies: BTreeMap, +} + +pub(crate) struct CanonicalConfigurationSource { + pub store: SqliteWorkspaceStore, + pub host: PermissionHost, +} + +impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource { + fn subject_permissions( + &self, + workspace: &str, + subject: &str, + ) -> subjektiv::Result> { + if self.host.catalog().workspace.as_str() != workspace { + return Err(subjektiv::SubjektivError::PermissionTransferConflict); + } + self.store + .adopted_subject_policy(workspace, subject) + .and_then(|policy| { + policy + .map(|policy| policy.legacy_read(&self.host)) + .transpose() + }) + .map_err(|error| { + subjektiv::SubjektivError::InvalidRecord(format!( + "canonical configuration policy unavailable: {error}" + )) + }) + } +} + +pub(crate) struct ConfigurationCurrent { + pub state: WorkspaceConfigState, + pub history_head: Option, + pub canonical: bool, + pub policies: BTreeMap, +} + +pub(crate) struct ConfigurationMigrationBaseline<'a> { + pub original: &'a WorkspaceConfigState, + pub state: &'a WorkspaceConfigState, + pub policies: &'a BTreeMap, + pub diagnostics: &'a [policy_config::MigrationDiagnostic], + pub export_digest: &'a str, +} + +struct OwnerScope<'a>(&'a str); +impl Authority for OwnerScope<'_> { + fn allows(&self, key: &ScopeKey, access: Access) -> bool { + key.workspace == self.0 + && key.target == "configuration" + && matches!(access, Access::Read | Access::Write) + } +} + +fn content_error(e: impl std::fmt::Display) -> Error { + Error::Store(format!("configuration content: {e}")) +} +fn conflict(message: &str) -> Error { + Error::WorkspaceConfigConflict(message.into()) +} +fn json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| Error::Store(e.to_string())) +} +fn decode(value: &str) -> Result { + serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string())) +} +fn scope(workspace: &str) -> ScopeKey { + ScopeKey { + workspace: workspace.into(), + target: "configuration".into(), + } +} +fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> { + let allowed: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", + params![workspace, account], |r| r.get(0), + )?; + if !allowed { + return Err(Error::WorkspacePermissionDenied( + "configuration management requires the Workspace owner".into(), + )); + } + Ok(()) +} + +pub(crate) fn head(conn: &Connection, workspace: &str) -> Result> { + let bytes: Option> = conn + .query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", + [workspace], + |r| r.get(0), + ) + .optional()? + .flatten(); + bytes + .map(|bytes| { + bytes.try_into().map(HistoryId::from_bytes).map_err(|_| { + Error::RegistryInconsistency("invalid configuration editing head".into()) + }) + }) + .transpose() +} + +pub(crate) fn request_fingerprint( + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, +) -> Result { + server_api::authorization::validate_authorization_identifier(&request.operation_id) + .map_err(Error::InvalidInput)?; + Ok(config_source::digest_bytes( + json(&(workspace, account, request))?.as_bytes(), + )) +} + +pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", + [workspace], + |row| row.get(0), + )?) +} + +pub fn catalog_digest(host: &PermissionHost) -> Result { + Ok(config_source::digest_bytes( + json(&host.catalog())?.as_bytes(), + )) +} + +fn policies(conn: &Connection, workspace: &str) -> Result> { + let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?; + let mut result = BTreeMap::new(); + for row in query.query_map([workspace], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? { + let (subject, raw) = row?; + let policy: AdoptedSubjectPolicy = decode(&raw)?; + if subject != policy.source.subject_id { + return Err(Error::RegistryInconsistency( + "Subject policy identity mismatch".into(), + )); + } + result.insert(subject, policy); + } + Ok(result) +} + +fn receipt( + conn: &Connection, + workspace: &str, + operation: &str, +) -> Result> { + let row: Option<(String,String)> = conn.query_row( + "SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2", + params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)), + ).optional()?; + row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?))) + .transpose() +} + +fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec { + before + .entries + .keys() + .chain(after.entries.keys()) + .collect::>() + .into_iter() + .filter(|path| before.entries.get(*path) != after.entries.get(*path)) + .map(|path| path.as_str().to_owned()) + .collect() +} +fn changed_subjects( + before: &BTreeMap, + after: &BTreeMap, +) -> Vec { + before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter(|subject| before.get(*subject) != after.get(*subject)) + .cloned() + .collect() +} + +impl SqliteWorkspaceStore { + pub(crate) fn configuration_current( + &self, + workspace: &str, + account: &str, + ) -> Result { + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + require_owner(&tx, workspace, account)?; + Ok(ConfigurationCurrent { + state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| { + Error::InvalidInput("Workspace source not materialized".into()) + })?, + history_head: head(&tx, workspace)?.map(|id| id.to_string()), + canonical: canonical(&tx, workspace)?, + policies: policies(&tx, workspace)?, + }) + }) + } + + pub(crate) fn configuration_request_result( + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + ) -> Result> { + let fingerprint = request_fingerprint(workspace, account, request)?; + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + match receipt(conn, workspace, &request.operation_id)? { + Some((saved, result)) if saved == fingerprint => Ok(Some(result)), + Some(_) => Err(conflict("operation ID reused with another request")), + None => Ok(None), + } + }) + } + + /// Authentication supplies `account`; it is never accepted from an adoption DTO. + pub fn configuration_head(&self, workspace: &str, account: &str) -> Result> { + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(head(conn, workspace)?.map(|id| id.to_string())) + }) + } + + /// Result lookup is a read. A historical receipt never reactivates policy. + pub fn configuration_adoption_result( + &self, + workspace: &str, + account: &str, + operation: &str, + ) -> Result> { + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result)) + }) + } + + pub(crate) fn preview_configuration_adoption( + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + mut require_subject: impl FnMut(&str) -> Result<()>, + ) -> Result { + self.with_conn(|conn| { + require_owner(conn, workspace, account)?; + let tx = conn.unchecked_transaction()?; + let (current, state, source) = prepare( + &tx, + workspace, + request, + schema, + host, + baseline, + &mut require_subject, + )?; + let before = match baseline { + Some(baseline) => baseline.policies.clone(), + None => policies(&tx, workspace)?, + }; + let mut after = before.clone(); + policy_config::apply(&mut after, &source, "preview"); + Ok(ConfigurationPreview { + expected_head: request.expected_head.clone(), + changed_paths: changed_paths(¤t.snapshot, &state.snapshot), + changed_subjects: changed_subjects(&before, &after), + state, + }) + }) + } + + /// The transfer callback may seal the old DB only after all validation and + /// current-head checks. If subsequent Server persistence fails, that specific + /// transfer remains pending; callers reconcile/retry the same request, not a new ID. + pub(crate) fn adopt_configuration( + &self, + workspace: &str, + account: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + mut require_subject: impl FnMut(&str) -> Result<()>, + mut prepare_transfer: impl FnMut(&str) -> Result, + ) -> Result { + let fingerprint = request_fingerprint(workspace, account, request)?; + self.with_immediate_transaction(|tx| { + require_owner(tx,workspace,account)?; + if let Some((saved,result)) = receipt(tx,workspace,&request.operation_id)? { + if saved != fingerprint { return Err(conflict("operation ID reused with another request")); } + return Ok(result); + } + let (_,state,source) = prepare(tx,workspace,request,schema,host,baseline,&mut require_subject)?; + let authority_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |r|r.get(0))?; + if authority_exists && (baseline.is_some() || request.expected_legacy_export.is_some()) { + return Err(conflict("legacy transfer is already adopted; reload current source")); + } + if !authority_exists && request.expected_legacy_export.is_none() { + return Err(conflict("legacy export identity must be observed before transfer")); + } + let mut current_policies = if authority_exists { policies(tx,workspace)? } else { + let transfer = prepare_transfer(&fingerprint)?; + if transfer.transfer_identity != fingerprint || request.expected_legacy_export.as_deref() != Some(transfer.export_digest.as_str()) { + return Err(conflict("transfer does not bind the complete adoption request and export")); + } + tx.execute("INSERT INTO workspace_configuration_authorities VALUES(?1,?2,?3,?4)",params![workspace,transfer.transfer_identity,transfer.physical_identity,transfer.export_digest])?; + transfer.policies + }; + // Client-selected operation IDs are not policy change identities: reusing + // an old legacy change ID must never make a stale child grant current. + policy_config::apply(&mut current_policies,&source,&format!("configuration:{fingerprint}")); + crate::config_source::sync_adopted_config_references(tx, workspace, &state)?; + let authority = OwnerScope(workspace); + let content = ScopedStore::new(tx,scope(workspace),&authority); + let prepared = content_history_adapters::settings::prepare(&state.snapshot).map_err(content_error)?; + for (id,object) in &prepared.objects { content.put(*id,&object.encode()).map_err(content_error)?; } + tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id=?1",[workspace])?; + crate::config_source::insert_materialized_state(tx,workspace,&state,&chrono::Utc::now().to_rfc3339())?; + tx.execute("DELETE FROM workspace_configuration_policies WHERE workspace_id=?1",[workspace])?; + for (subject,policy) in ¤t_policies { + tx.execute("INSERT INTO workspace_configuration_policies VALUES(?1,?2,?3)", params![workspace,subject,json(policy)?])?; + } + let new_head = content.adopt(prepared.root,head(tx,workspace)?,account.into(),chrono::Utc::now().timestamp_micros()).map_err(content_error)?; + let result = ConfigurationAdoptionResult { + workspace_id: workspace.into(), operation_id: request.operation_id.clone(), + history_head: new_head.to_string(), state, source_stored: true, policy_committed: true, + runtime_application: RuntimePolicyApplication::NextCurrentPolicyCheck, + }; + tx.execute("INSERT INTO workspace_configuration_receipts VALUES(?1,?2,?3,?4)",params![workspace,request.operation_id,fingerprint,json(&result)?])?; + Ok(result) + }) + } + + pub fn adopted_subject_policy( + &self, + workspace: &str, + subject: &str, + ) -> Result> { + self.with_conn(|conn| { + let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?; + if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); } + let raw: Option = conn.query_row("SELECT policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 AND subject_id=?2",params![workspace,subject],|r|r.get(0)).optional()?; + raw.map(|raw|decode(&raw)).transpose() + }) + } +} + +fn prepare( + tx: &Transaction<'_>, + workspace: &str, + request: &ConfigurationAdoptionRequest, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, + baseline: Option<&ConfigurationMigrationBaseline<'_>>, + require_subject: &mut impl FnMut(&str) -> Result<()>, +) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> { + if let Some(baseline) = baseline { + if baseline.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic, + policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. } + ) + }) { + return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into())); + } + } + if host.catalog().workspace.as_str() != workspace { + return Err(Error::WorkspacePermissionDenied( + "permission registry belongs to another Workspace".into(), + )); + } + if catalog_digest(host)? != request.expected_catalog_digest { + return Err(conflict("Feature catalog changed")); + } + if head(tx, workspace)?.map(|id| id.to_string()) != request.expected_head { + return Err(conflict("editing history head changed")); + } + let current = crate::config_source::load_state(tx, workspace)? + .ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; + let editing_base = if let Some(baseline) = baseline { + if *baseline.original != current + || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) + { + return Err(conflict("migration source or export changed")); + } + baseline.state + } else { + ¤t + }; + if editing_base.snapshot.digest != request.source.base_digest { + return Err(conflict("source content changed")); + } + crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?; + let candidate = crate::config_source::evaluate_candidate_in_transaction( + tx, + workspace, + editing_base.clone(), + crate::config_source::load_evaluation_identity(tx, workspace)?, + &request.source.changes, + schema, + )?; + crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?; + let source = policy_config::source(&candidate.evaluation)?; + policy_config::validate_source(host, &source, require_subject)?; + let state = WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }; + Ok((current, state, source)) +} + +#[cfg(test)] +#[path = "config_adoption_tests.rs"] +mod tests; diff --git a/crates/workspace-server/src/config_adoption.sql b/crates/workspace-server/src/config_adoption.sql new file mode 100644 index 000000000..dfe967c8c --- /dev/null +++ b/crates/workspace-server/src/config_adoption.sql @@ -0,0 +1,31 @@ +CREATE TABLE workspace_configuration_authorities ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + transfer_identity TEXT NOT NULL, + legacy_physical_identity TEXT NOT NULL, + legacy_export_digest TEXT NOT NULL +); +CREATE TABLE workspace_configuration_policies ( + workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT, + subject_id TEXT NOT NULL, + policy_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, subject_id) +); +CREATE TABLE workspace_configuration_receipts ( + workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, operation_id) +); +CREATE TRIGGER workspace_configuration_authorities_no_update BEFORE UPDATE ON workspace_configuration_authorities BEGIN + SELECT RAISE(ABORT, 'configuration authority transfers are immutable'); +END; +CREATE TRIGGER workspace_configuration_authorities_no_delete BEFORE DELETE ON workspace_configuration_authorities BEGIN + SELECT RAISE(ABORT, 'configuration authority transfers are retained'); +END; +CREATE TRIGGER workspace_configuration_receipts_no_update BEFORE UPDATE ON workspace_configuration_receipts BEGIN + SELECT RAISE(ABORT, 'configuration adoption receipts are immutable'); +END; +CREATE TRIGGER workspace_configuration_receipts_no_delete BEFORE DELETE ON workspace_configuration_receipts BEGIN + SELECT RAISE(ABORT, 'configuration adoption receipts are retained'); +END; diff --git a/crates/workspace-server/src/config_adoption_tests.rs b/crates/workspace-server/src/config_adoption_tests.rs new file mode 100644 index 000000000..379be9446 --- /dev/null +++ b/crates/workspace-server/src/config_adoption_tests.rs @@ -0,0 +1,417 @@ +//! Adoption component tests use the real Server SQLite transaction and content +//! store. The legacy transfer callback is empty in this fixture; physical Feature +//! transfer and authenticated HTTP are separate integration coverage. +use super::*; +use crate::config_source::{MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider}; +use crate::policy_config::PermissionConfigSchemaProvider; +use config_source::{ConfigTreeChange, VirtualPath}; +use feature_permissions::*; + +const WORKSPACE: &str = "adoption-ws"; +const OWNER: &str = "adoption-owner"; + +fn host(children: bool) -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("ticket").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![SetContribution { + set: PermissionSetId::parse("ticket").unwrap(), + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse("ticket").unwrap(), + keys: vec![PermissionKey::parse("ticket.hoge").unwrap()], + })], + }], + }, + ) + .unwrap(); + host +} +fn schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ + PermissionConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} +fn open(path: &std::path::Path) -> SqliteWorkspaceStore { + SqliteWorkspaceStore::open_for_test(path).unwrap() +} +fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir.path().join("server.sqlite")); + db.with_immediate_transaction(|tx| { + tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?; + Ok(()) + }).unwrap(); + db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema()) + .unwrap(); + (dir, db) +} +fn request( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + id: &str, + source: Option<&str>, +) -> ConfigurationAdoptionRequest { + let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap(); + let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap(); + let changes = source + .map(|source| { + vec![ConfigTreeChange::Update { + path: path.clone(), + expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), + content: source.into(), + }] + }) + .unwrap_or_default(); + ConfigurationAdoptionRequest { + operation_id: id.into(), + expected_legacy_export: db + .configuration_head(WORKSPACE, OWNER) + .unwrap() + .is_none() + .then(|| "fixture-empty".into()), + expected_head: db.configuration_head(WORKSPACE, OWNER).unwrap(), + expected_catalog_digest: catalog_digest(host).unwrap(), + source: ConfigCommitRequest { + base_digest: current.snapshot.digest, + changes, + entrypoints: vec![path], + }, + } +} +fn adopt( + db: &SqliteWorkspaceStore, + host: &PermissionHost, + request: &ConfigurationAdoptionRequest, +) -> Result { + db.adopt_configuration( + WORKSPACE, + OWNER, + request, + schema(), + host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |fingerprint| { + Ok(ConfigurationPolicyTransfer { + transfer_identity: fingerprint.into(), + physical_identity: "fixture-physical".into(), + export_digest: "fixture-empty".into(), + policies: BTreeMap::new(), + }) + }, + ) +} +const A: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; +const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#; + +#[test] +fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() { + let (_dir, db) = fixture(); + let host = host(false); + let initial = request(&db, &host, "initial", Some(A)); + let first = adopt(&db, &host, &initial).unwrap(); + let stale = request(&db, &host, "stale-a", Some(B)); + adopt(&db, &host, &request(&db, &host, "to-b", Some(B))).unwrap(); + let final_a = adopt(&db, &host, &request(&db, &host, "back-a", Some(A))).unwrap(); + assert_eq!(first.state.snapshot, final_a.state.snapshot); + assert_ne!(first.history_head, final_a.history_head); + assert!(adopt(&db, &host, &stale).is_err()); + // Exact replay precedes stale checks and returns only the saved old result. + assert_eq!(adopt(&db, &host, &initial).unwrap(), first); + assert_eq!( + db.configuration_adoption_result(WORKSPACE, OWNER, "initial") + .unwrap(), + Some(first) + ); + assert_eq!( + db.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(final_a.history_head) + ); + let mut changed = initial; + changed.source.changes.clear(); + assert!(adopt(&db, &host, &changed).is_err()); +} + +#[test] +fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() { + for (table, event) in [ + ("content_objects", "INSERT"), + ("workspace_config_entries", "INSERT"), + ("workspace_configuration_policies", "INSERT"), + ("content_scopes", "UPDATE"), + ("workspace_configuration_receipts", "INSERT"), + ] { + let (dir, db) = fixture(); + let host = host(false); + let before = adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let candidate = request(&db, &host, "failing", Some(B)); + db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap(); + assert!(adopt(&db, &host, &candidate).is_err(), "{table}"); + drop(db); + let reopened = open(&dir.path().join("server.sqlite")); + assert_eq!( + reopened.load_workspace_config(WORKSPACE).unwrap(), + Some(before.state), + "{table}" + ); + assert_eq!( + reopened.configuration_head(WORKSPACE, OWNER).unwrap(), + Some(before.history_head), + "{table}" + ); + assert_eq!( + reopened + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap(), + policy, + "{table}" + ); + assert!( + reopened + .configuration_adoption_result(WORKSPACE, OWNER, "failing") + .unwrap() + .is_none(), + "{table}" + ); + adopt(&reopened, &host, &candidate).unwrap(); + } +} + +#[test] +fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() { + let (_dir, db) = fixture(); + let host = host(false); + let initial = db.load_workspace_config(WORKSPACE).unwrap(); + for source in [ + "not valid Decodal {", + r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#, + r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#, + ] { + let request = request(&db, &host, "bad", Some(source)); + let result = db.adopt_configuration( + WORKSPACE, + OWNER, + &request, + schema(), + &host, + None, + |s| { + if s == "subject-a" { + Ok(()) + } else { + Err(Error::InvalidInput("unknown Subject".into())) + } + }, + |_| panic!("validation failure must precede seal"), + ); + assert!(result.is_err(), "{source}"); + assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(), initial); + assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None); + } + let request = request(&db, &host, "other-owner", None); + assert!( + db.adopt_configuration( + WORKSPACE, + "non-owner", + &request, + schema(), + &host, + None, + |_| Ok(()), + |_| panic!("owner refusal must precede seal") + ) + .is_err() + ); +} + +#[test] +fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() { + let (_dir, db) = fixture(); + let host = host(false); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let original = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(); + let preview = db + .preview_configuration_adoption( + WORKSPACE, + OWNER, + &request(&db, &host, "noop", None), + schema(), + &host, + None, + |_| Ok(()), + ) + .unwrap(); + assert!(preview.changed_paths.is_empty()); + assert!(preview.changed_subjects.is_empty()); + adopt(&db, &host, &request(&db, &host, "noop", None)).unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request(&db, &host, "omit", Some("{} as WorkspaceConfigSchema")), + ) + .unwrap(); + assert_eq!( + db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(), + original + ); + adopt( + &db, + &host, + &request( + &db, + &host, + "remove", + Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#), + ), + ) + .unwrap(); + assert!( + db.adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .is_none() + ); +} + +#[test] +fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() { + for children in [false, true] { + let (_dir, db) = fixture(); + let host = host(children); + adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + let resolution = host.resolve(Some(&policy.source.execution.assignment())); + assert_eq!( + resolution + .projection() + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()), + children + ); + assert!( + host.resolve(Some(&policy.source.delegation.assignment())) + .projection() + .allowed + .is_empty() + ); + adopt(&db, &host, &request(&db, &host, "multi", Some(B))).unwrap(); + let policy = db + .adopted_subject_policy(WORKSPACE, "subject-a") + .unwrap() + .unwrap(); + assert_eq!( + policy.source.execution.namespaces[0].keys[0].as_str(), + "ticket.hoge.fuga" + ); + } +} + +#[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 b3de09449..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, @@ -340,6 +347,9 @@ impl SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if crate::config_adoption::canonical(&tx, workspace_id)? { + return Err(config_conflict("configuration authority is adopted; use owner adoption with the observed editing head and operation ID")); + } let workspace_exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", [workspace_id], @@ -490,7 +500,7 @@ fn format_candidate_sources( } // Read persisted provenance, not load_state's upgraded in-memory contract. -fn load_evaluation_identity( +pub(crate) fn load_evaluation_identity( conn: &rusqlite::Connection, workspace_id: &str, ) -> Result<(String, String)> { @@ -509,7 +519,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool { .any(|provider| provider.provider_id == "builtin:plugins") } -fn evaluate_candidate_in_transaction( +pub(crate) fn evaluate_candidate_in_transaction( tx: &rusqlite::Transaction<'_>, workspace_id: &str, current: WorkspaceConfigState, @@ -566,6 +576,28 @@ fn evaluate_candidate( }) } +pub(crate) fn sync_adopted_config_references( + tx: &rusqlite::Transaction<'_>, + workspace: &str, + state: &WorkspaceConfigState, +) -> Result<()> { + if has_plugin_provider(&state.contract.schema_bundle) { + let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config( + tx, + workspace, + &state.snapshot, + state.contract.schema_bundle.clone(), + )?; + if contract != state.contract || evaluation.projection_digest != state.projection_digest { + return Err(config_conflict( + "Plugin catalog/schema changed during adoption", + )); + } + crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?; + } + Ok(()) +} + pub(crate) fn load_state( conn: &rusqlite::Connection, workspace_id: &str, @@ -792,7 +824,7 @@ fn config_not_materialized() -> Error { Error::RegistryInconsistency("workspace config tree is not materialized".to_string()) } -fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> { +pub(crate) fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> { if entrypoints == [main_config_path()] { Ok(()) } else { diff --git a/crates/workspace-server/src/configuration_migration.rs b/crates/workspace-server/src/configuration_migration.rs new file mode 100644 index 000000000..c0fb37625 --- /dev/null +++ b/crates/workspace-server/src/configuration_migration.rs @@ -0,0 +1,595 @@ +//! Read-only preparation of an editable Workspace permission migration draft. +//! +//! The authenticated owner boundary lives in Server/core. This helper checks the +//! guarded stores and exact Workspace attribution, but neither saves the draft nor +//! seals the legacy writer. Adoption must check `original`, the actual editing +//! head and export identity/digest again, and reject blocking diagnostics before +//! sealing. A successful preview alone is not permission authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use config_source::{ + ConfigContentType, ConfigTreeChange, ConfigTreeSnapshot, SnapshotEnvironment, + ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, +}; +use feature_permissions::{NamespacePermissions, PermissionHost}; +use subjektiv::{PermissionTransferSnapshot, SubjektivStore}; + +use crate::config_source::{ + ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigState, + validate_workspace_config_candidate_projections, +}; +use crate::policy_config::{ + self, AdoptedSubjectPolicy, AssignmentSource, MigrationDiagnostic, PermissionSource, +}; +use crate::{Error, Result, SqliteWorkspaceStore}; + +pub(crate) struct MigrationSeed { + pub original: WorkspaceConfigState, + pub state: WorkspaceConfigState, + pub snapshot: PermissionTransferSnapshot, + pub policies: BTreeMap, + pub diagnostics: Vec, +} + +pub(crate) fn preview_migration( + store: &SqliteWorkspaceStore, + legacy: &SubjektivStore, + workspace: &str, + schema: WorkspaceConfigSchemaBundle, + host: &PermissionHost, +) -> Result { + if legacy.scope_id() != workspace || host.catalog().workspace.as_str() != workspace { + return Err(Error::WorkspaceIdMismatch); + } + // This API validates native physical identity under the FeatureDatabase guard. + // Do not replace it with path inspection or SQL against the legacy database. + let identity = legacy.database_identity().map_err(legacy_error)?; + if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID { + return Err(Error::WorkspaceIdMismatch); + } + let original = store.with_conn(|conn| { + // with_conn also owns the managed Server catalog identity/retention guard. + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND state='active')", + [workspace], + |row| row.get(0), + )?; + if !exists { + return Err(Error::WorkspaceIdMismatch); + } + crate::config_source::load_state(conn, workspace)?.ok_or_else(|| { + Error::RegistryInconsistency("workspace config tree is not materialized".into()) + }) + })?; + let snapshot = legacy + .preview_permission_transfer(&identity) + .map_err(legacy_error)?; + if snapshot.identity != identity { + return Err(Error::WorkspaceConfigConflict( + "legacy export identity changed".into(), + )); + } + let (policies, diagnostics) = migrate_policies(host, &snapshot)?; + let source = PermissionSource { + subjects: policies + .values() + .map(|policy| policy.source.clone()) + .collect(), + remove_subjects: vec![], + }; + let changes = draft_changes(&original, &source)?; + // Caller's real application provider bundle (including PromptSchema) is + // required: a migration-specific substitute cannot validate other settings. + let candidate = store.evaluate_workspace_config_candidate_with_schema( + workspace, + &ConfigCommitRequest { + base_digest: original.snapshot.digest.clone(), + changes, + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)?], + }, + schema, + )?; + validate_workspace_config_candidate_projections(workspace, &candidate)?; + let evaluated_source = policy_config::source(&candidate.evaluation)?; + if evaluated_source != source { + return Err(Error::InvalidInput( + "migration import did not produce the exact generated Subject assignments".into(), + )); + } + let subjects: BTreeSet<_> = snapshot + .sources + .iter() + .map(|item| item.subject_id.as_str()) + .collect(); + policy_config::validate_source(host, &evaluated_source, |subject| { + if subjects.contains(subject) { + Ok(()) + } else { + Err(Error::SubjektivSubjectNotFound(subject.into())) + } + })?; + let state = WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }; + Ok(MigrationSeed { + original, + state, + snapshot, + policies, + diagnostics, + }) +} + +fn legacy_error(error: subjektiv::SubjektivError) -> Error { + Error::Store(format!("legacy permission migration preview: {error}")) +} + +fn path(value: &str) -> Result { + VirtualPath::parse(value).map_err(|error| Error::InvalidInput(error.to_string())) +} + +fn migrate_policies( + host: &PermissionHost, + snapshot: &PermissionTransferSnapshot, +) -> Result<( + BTreeMap, + Vec, +)> { + let mut policies = BTreeMap::new(); + let mut diagnostics = vec![]; + let mut seen = BTreeSet::new(); + for item in &snapshot.sources { + if !seen.insert(&item.subject_id) { + return Err(Error::RegistryInconsistency( + "duplicate Subject in legacy export".into(), + )); + } + // None stays unset. In particular, never enroll it in a default Set. + let Some(legacy) = &item.permissions else { + continue; + }; + let (policy, mut converted) = + policy_config::migrate_subject(host, &item.subject_id, legacy)?; + policies.insert(item.subject_id.clone(), policy); + // ExplicitConfigurationRequired must survive preview, even though the + // unsupported operation is absent from the editable assignment. Core must + // block adoption on it; this is not a silently successful conversion. + diagnostics.append(&mut converted); + } + Ok((policies, diagnostics)) +} + +fn draft_changes( + original: &WorkspaceConfigState, + source: &PermissionSource, +) -> Result> { + // Evaluate with the existing open schema, not application defaults. This + // distinguishes an explicit `permissions = {}` (including imports/dotted + // fields) from an omitted field without introducing another Decodal parser. + let open_contract = ToolchainContract::with_schema_bundle( + original.contract.schema_version, + original.contract.entrypoints.clone(), + original.contract.import_policy_version, + WorkspaceConfigSchemaBundle::empty(), + ); + let raw = SnapshotEnvironment::new(original.snapshot.clone()) + .evaluate_contract(&open_contract) + .map_err(|diagnostics| { + Error::InvalidInput(format!( + "cannot inspect current configuration before permission migration: {diagnostics:?}" + )) + })?; + if raw + .projections + .iter() + .any(|projection| projection.data_json.get("permissions").is_some()) + { + return Err(Error::InvalidInput( + "current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(), + )); + } + let generated_path = unused_source_path(&original.snapshot)?; + let main_path = path(MAIN_CONFIG_ENTRYPOINT)?; + let main = original.snapshot.get(&main_path).ok_or_else(|| { + Error::RegistryInconsistency("workspace config tree has no main.dcdl".into()) + })?; + if main.content_type != ConfigContentType::Decodal { + return Err(Error::InvalidInput( + "main.dcdl is not Decodal source".into(), + )); + } + // Use the existing syntax formatter to supply a final field separator when + // needed. It preserves comments; no local lexer or alternate language rules. + let formatted_main = SnapshotEnvironment::new(original.snapshot.clone()) + .format(&main.content) + .map_err(|error| { + Error::InvalidInput(format!("cannot format migration entrypoint: {error}")) + })?; + Ok(vec![ + ConfigTreeChange::Create { + path: generated_path.clone(), + content_type: ConfigContentType::Decodal, + content: render_permission_source(source), + }, + ConfigTreeChange::Update { + path: main_path, + expected_digest: main.content_digest.clone(), + content: append_import(&formatted_main, &generated_path)?, + }, + ]) +} + +fn unused_source_path(snapshot: &ConfigTreeSnapshot) -> Result { + // With N entries, one of these N+1 names is unused. Never overwrite even an + // unreferenced preexisting migration file. + for suffix in 0..=snapshot.entries.len() { + let name = if suffix == 0 { + "permissions/migrated.dcdl".to_owned() + } else { + format!("permissions/migrated-{suffix}.dcdl") + }; + let candidate = path(&name)?; + if snapshot.get(&candidate).is_none() { + return Ok(candidate); + } + } + unreachable!("N entries cannot occupy N+1 distinct migration paths") +} + +fn append_import(source: &str, imported: &VirtualPath) -> Result { + // Materialized main.dcdl is a literal outer object with a trailing schema + // assertion. Limit this edit to that existing representation: no rewrites of + // arbitrary expressions, no JSON round-trip, no replacement of other fields. + let trimmed = source.trim_end(); + let object = trimmed + .strip_suffix("as WorkspaceConfigSchema") + .map(str::trim_end) + .unwrap_or(trimmed); + if !object.starts_with('{') || !object.ends_with('}') { + return Err(Error::InvalidInput( + "permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(), + )); + } + let close = object.len() - 1; + let import = quote(&format!("./{}", imported.as_str())); + Ok(format!( + "{}\n permissions = import {import};\n{}", + &source[..close], + &source[close..] + )) +} + +// A deliberately specific, payload-free renderer. JSON quoting is used only for +// string literals, never as a renderer for the configuration/assignment objects. +fn quote(value: &str) -> String { + serde_json::to_string(value).expect("a string serializes") +} + +fn render_strings<'a>(values: impl IntoIterator) -> String { + format!( + "[{}]", + values.into_iter().map(quote).collect::>().join(", ") + ) +} + +fn render_namespaces(values: &[NamespacePermissions]) -> String { + let items = values + .iter() + .map(|value| { + format!( + "{{ namespace = {}; keys = {}; }}", + quote(value.namespace.as_str()), + render_strings(value.keys.iter().map(|key| key.as_str())), + ) + }) + .collect::>(); + format!("[{}]", items.join(", ")) +} + +fn render_assignment(source: &AssignmentSource) -> String { + format!( + "{{ sets = {}; namespaces = {}; exclude_sets = {}; exclude_namespaces = {}; revoked = {}; }}", + render_strings(source.sets.iter().map(|set| set.as_str())), + render_namespaces(&source.namespaces), + render_strings(source.exclude_sets.iter().map(|set| set.as_str())), + render_namespaces(&source.exclude_namespaces), + source.revoked, + ) +} + +fn render_permission_source(source: &PermissionSource) -> String { + let subjects = source + .subjects + .iter() + .map(|item| { + format!( + " {{ subject_id = {}; execution = {}; delegation = {}; }}", + quote(&item.subject_id), + render_assignment(&item.execution), + render_assignment(&item.delegation), + ) + }) + .collect::>() + .join(",\n"); + format!( + "{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n", + render_strings(source.remove_subjects.iter().map(String::as_str)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use config_source::{ConfigEntry, EvaluationResult}; + use feature_permissions::{ + FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionKey, + PermissionSetId, WorkspaceId, + }; + use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions}; + use subjektiv::{DatabaseIdentity, PermissionSourceExport}; + + const WORKSPACE: &str = "migration-workspace"; + + fn host(children: bool) -> PermissionHost { + let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); + let feature = host + .register_feature(FeatureIdentity::parse("builtin:file").unwrap()) + .unwrap(); + let namespace = host + .bind_namespace(&feature, NamespaceId::parse("file").unwrap()) + .unwrap(); + host.declare( + &namespace, + NamespaceDeclaration { + permissions: vec![ + PermissionDefinition { + key: PermissionKey::parse("file.read").unwrap(), + description: String::new(), + children: if children { + vec![PermissionKey::parse("file.read.text").unwrap()] + } else { + vec![] + }, + }, + PermissionDefinition { + key: PermissionKey::parse("file.read.text").unwrap(), + description: String::new(), + children: vec![], + }, + ], + sets: vec![], + }, + ) + .unwrap(); + host + } + + fn limited(operation: &str) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "one-workdir".into(), + path: "only-this.txt".into(), + recursive: false, + }, + } + } + + fn export(sources: Vec) -> PermissionTransferSnapshot { + PermissionTransferSnapshot { + identity: DatabaseIdentity { + scope_id: WORKSPACE.into(), + feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), + physical_id: "typed-test-identity".into(), + }, + sources, + export_digest: "not-used-by-pure-conversion-tests".into(), + } + } + + fn current(main: &str, extra: Vec) -> WorkspaceConfigState { + let mut current = crate::config_source::initial_state().unwrap(); + let mut entries = vec![ + ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + main, + ) + .unwrap(), + ]; + entries.extend(extra); + current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap(); + current + } + + fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult { + let contract = crate::config_source::initial_state().unwrap().contract; + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap() + } + + #[test] + fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() { + let empty = SubjectPermissions { + change_id: "explicit-empty".into(), + execution: vec![], + delegation: vec![], + }; + let limited = SubjectPermissions { + change_id: "keep-limited-change".into(), + execution: vec![limited("file.read")], + delegation: vec![limited("file.read.text")], + }; + let snapshot = export(vec![ + PermissionSourceExport { + subject_id: "unset".into(), + permissions: None, + }, + PermissionSourceExport { + subject_id: "empty".into(), + permissions: Some(empty.clone()), + }, + PermissionSourceExport { + subject_id: "limited".into(), + permissions: Some(limited.clone()), + }, + ]); + let host = host(false); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert!(!policies.contains_key("unset")); + assert_eq!(policies["empty"].change_id, empty.change_id); + assert_eq!( + policies["empty"].source.execution, + AssignmentSource::default() + ); + assert_eq!( + policies["empty"].source.delegation, + AssignmentSource::default() + ); + assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited); + assert_eq!(diagnostics.len(), 2); + assert!( + diagnostics + .iter() + .all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. })) + ); + } + + #[test] + fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() { + for (host, permission) in [ + (host(false), limited("file.unknown")), + (host(true), limited("file.read")), + ] { + let legacy = SubjectPermissions { + change_id: "keep-change".into(), + execution: vec![permission.clone()], + delegation: vec![], + }; + let snapshot = export(vec![PermissionSourceExport { + subject_id: "blocked".into(), + permissions: Some(legacy.clone()), + }]); + let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); + assert_eq!( + diagnostics, + vec![MigrationDiagnostic::ExplicitConfigurationRequired { + subject_id: "blocked".into(), + permission, + }] + ); + assert_eq!(policies["blocked"].legacy_constraints, Some(legacy)); + assert!(policies["blocked"].source.execution.namespaces.is_empty()); + assert!(policies["blocked"].source.execution.sets.is_empty()); + } + } + + #[test] + fn renderer_round_trips_sets_namespaces_exclusions_revocation_and_quoted_strings() { + let assignment = AssignmentSource { + sets: vec![PermissionSetId::parse("shared").unwrap()], + namespaces: vec![NamespacePermissions { + namespace: NamespaceId::parse("file").unwrap(), + keys: vec![PermissionKey::parse("file.read.text").unwrap()], + }], + exclude_sets: vec![PermissionSetId::parse("excluded").unwrap()], + exclude_namespaces: vec![NamespacePermissions { + namespace: NamespaceId::parse("file").unwrap(), + keys: vec![PermissionKey::parse("file.read").unwrap()], + }], + revoked: true, + }; + let source = PermissionSource { + subjects: vec![policy_config::SubjectAssignmentSource { + subject_id: "subject-\"quoted\\日本語".into(), + execution: assignment, + delegation: AssignmentSource::default(), + }], + remove_subjects: vec!["removed".into()], + }; + let rendered = render_permission_source(&source); + assert!(rendered.contains("sets = [\"shared\"]")); + assert!(rendered.contains("keys = [\"file.read.text\"]")); + assert!(!rendered.contains("legacy_constraints")); + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT).unwrap(), + ConfigContentType::Decodal, + format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema"), + ) + .unwrap()]) + .unwrap(); + assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source); + } + + #[test] + fn draft_preserves_main_comments_other_settings_and_existing_migration_files() { + let main = "{\n # permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n"; + let occupied = ConfigEntry::new( + path("permissions/migrated.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ keep = true; }\n", + ) + .unwrap(); + let original = current(main, vec![occupied.clone()]); + let source = PermissionSource::default(); + let changes = draft_changes(&original, &source).unwrap(); + let draft = original.snapshot.apply(&changes).unwrap(); + assert_eq!(draft.get(&occupied.path), Some(&occupied)); + assert!( + draft + .get(&path("permissions/migrated-1.dcdl").unwrap()) + .is_some() + ); + let updated_main = &draft + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content; + assert!(updated_main.contains("# permissions in a comment are not a field")); + assert!(updated_main.contains("brace } and permissions")); + assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); + assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n")); + let before = evaluate(original.snapshot.clone()); + let after = evaluate(draft); + assert_eq!( + after.projections[0].data_json["other"], + before.projections[0].data_json["other"] + ); + assert_eq!(policy_config::source(&after).unwrap(), source); + assert_eq!( + original + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()) + .unwrap() + .content, + main + ); + } + + #[test] + fn existing_explicit_permissions_are_not_merged_or_discarded_even_when_empty() { + for main in [ + "{ permissions = {}; } as WorkspaceConfigSchema", + "{ permissions.subjects = []; } as WorkspaceConfigSchema", + "{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema", + ] { + let existing = ConfigEntry::new( + path("permissions/existing.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ subjects = []; }", + ) + .unwrap(); + let original = current(main, vec![existing]); + let before = original.clone(); + let error = draft_changes(&original, &PermissionSource::default()).unwrap_err(); + assert!( + error.to_string().contains("explicitly defines permissions"), + "{error}" + ); + assert_eq!(original, before); + } + } +} diff --git a/crates/workspace-server/src/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/hosts.rs b/crates/workspace-server/src/hosts.rs index 7f4d9a847..2c1739cba 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -117,6 +117,9 @@ where #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeDiagnostic { + /// Internal startup observations; public consumers must separately authorize projection. + #[serde(skip)] + pub startup: Option, pub code: String, pub severity: HostDiagnosticSeverity, pub message: String, @@ -6649,7 +6652,7 @@ fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String { } fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic { - match error { + let mut projection = match error { EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic( "restore_admission_busy", HostDiagnosticSeverity::Warning, @@ -6767,7 +6770,22 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti HostDiagnosticSeverity::Error, "Embedded Runtime state is unavailable".to_string(), ), + }; + if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error { + if result.startup_failure.is_some() { + projection.message = "Embedded Runtime Worker startup failed".into(); + } + projection.startup = result.startup_failure.as_ref().and_then(|primary| { + internal_startup_transfer(runtime_api::WorkerStartupFailure { + primary: primary.clone(), + secondary: result.secondary_failures.clone(), + resources: result.startup_resources, + persistence: result.startup_failure_persistence, + io_kind: result.startup_io_kind, + }) + }); } + projection } fn host_id_for_embedded_workspace(workspace_id: &str) -> String { @@ -6846,6 +6864,7 @@ fn runtime_api_diagnostic( ) -> RuntimeDiagnostic { match error { runtime_api::client_support::ClientError::Public { status, error } => { + let startup = error.error.startup.and_then(internal_startup_transfer); let remote_code = error.error.code; let message = sanitize_remote_runtime_message(&remote_code, &error.error.message); let (code, severity) = match status { @@ -6862,13 +6881,15 @@ fn runtime_api_diagnostic( }, ), }; - diagnostic( + let mut projection = diagnostic( code, severity, format!( "Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}" ), - ) + ); + projection.startup = startup; + projection } runtime_api::client_support::ClientError::Failure(failure) => { let code = match failure { @@ -6945,11 +6966,25 @@ fn remote_http_status_diagnostic( } _ => (remote_code, HostDiagnosticSeverity::Warning), }; - diagnostic( + let mut projection = diagnostic( code, severity, format!("Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {remote_message}"), - ) + ); + projection.startup = error + .and_then(|error| error.error.startup) + .and_then(internal_startup_transfer); + projection +} + +/// Treat transferred details as untrusted observations and reproject through the +/// same domain allowlist; HTTP status and text never supply a missing cause. +fn internal_startup_transfer( + mut startup: runtime_api::WorkerStartupFailure, +) -> Option { + startup.primary = + worker::startup_failure::reproject_internal_startup_failure(&startup.primary).ok()?; + Some(startup) } fn diagnostic( @@ -6958,6 +6993,7 @@ fn diagnostic( message: impl Into, ) -> RuntimeDiagnostic { RuntimeDiagnostic { + startup: None, code: code.into(), severity, message: message.into(), @@ -7145,6 +7181,79 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[test] + fn session_registration_embedded_and_remote_host_projection_is_internal_only() { + use protocol::session_failure::{ + SessionPrincipal, SessionRegistrationCause, session_registration_attachment, + }; + use worker_runtime::execution::{ + WorkerExecutionOperation, WorkerExecutionOutcome, WorkerExecutionResult, + }; + let mut result = WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn); + result.outcome = WorkerExecutionOutcome::Errored; + result.startup_failure = Some( + session_registration_attachment(SessionRegistrationCause::denied( + SessionPrincipal::Subject, + )) + .unwrap(), + ); + result.startup_failure_persistence = + Some(protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown); + result.secondary_failures.push( + protocol::startup_failure::StartupSecondaryFailure::Persistence { + phase: protocol::startup_failure::StartupFailurePhase::MetadataSave, + outcome: protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown, + }, + ); + let error = EmbeddedRuntimeError::WorkerExecutionRejected { + worker_id: EmbeddedWorkerId::now_v7(), + operation: result.operation, + outcome: result.outcome, + message: "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(), + result, + }; + let embedded = embedded_runtime_diagnostic(&error); + let mut wire = runtime_api::RuntimeApiError::new( + 500, + "worker_execution_rejected", + "unused remote prose", + ); + wire.error.startup = embedded.startup.clone(); + let received = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap(); + let remote = runtime_api_diagnostic( + "remote", + runtime_api::client_support::ClientError::Public { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + error: received, + }, + ); + assert!(embedded.startup.is_some()); + assert_eq!(embedded.startup, remote.startup); + let public = serde_json::to_value(&remote).unwrap(); + assert!( + public.get("startup").is_none(), + "no blind public/storage attachment forwarding" + ); + for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] { + assert!(!serde_json::to_string(&embedded).unwrap().contains(secret)); + } + + let legacy = runtime_api_diagnostic( + "remote", + runtime_api::client_support::ClientError::Public { + status: reqwest::StatusCode::FORBIDDEN, + error: runtime_api::RuntimeApiError::new(403, "forbidden", "session.record denied"), + }, + ); + assert!( + legacy.startup.is_none(), + "HTTP status and prose cannot attest a Session cause" + ); + let mut corrupt = embedded.startup.unwrap(); + corrupt.primary = serde_json::from_value(json!("not an error-contract envelope")).unwrap(); + assert!(internal_startup_transfer(corrupt).is_none()); + } + #[tokio::test] async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() { use crate::db_executor::{DbExecutor, DbExecutorLimits}; diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 26e411c0b..e013be2f0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -5,7 +5,11 @@ pub mod authority; pub mod backend_job; pub mod companion; pub mod config; +pub mod config_adoption; pub mod config_source; +mod configuration_migration; +#[cfg(test)] +mod configuration_transfer_tests; #[cfg(test)] mod content_history_tests; pub mod db_executor; @@ -24,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 new file mode 100644 index 000000000..b57f53ea9 --- /dev/null +++ b/crates/workspace-server/src/policy_config.rs @@ -0,0 +1,377 @@ +//! Subject assignments in the existing Workspace virtual configuration. +//! +//! An omitted Subject is unchanged; removal is explicit. Assignments contain only +//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions +//! remain separate domain constraints, never payloads on permission keys. +//! +//! 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}; + +use config_source::{ConfigSchemaContribution, EvaluationResult}; +use feature_permissions::{ + NamespaceId, NamespacePermissions, PermissionAssignment, PermissionDiagnostic, PermissionHost, + PermissionKey, PermissionReference, PermissionSetId, +}; +use serde::{Deserialize, Serialize}; +use server_api::authorization::{ScopedPermission, SubjectPermissions}; + +use crate::config_source::WorkspaceConfigSchemaProvider; +use crate::{Error, Result}; + +const SCHEMA: &str = r#"{ + permissions = { + subjects = [...{ + subject_id = String; + execution = { + sets = [...String] default []; + namespaces = [...{ namespace = String; keys = [...String]; }] default []; + exclude_sets = [...String] default []; + exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default []; + revoked = Bool default false; + } default {}; + delegation = { + sets = [...String] default []; + namespaces = [...{ namespace = String; keys = [...String]; }] default []; + exclude_sets = [...String] default []; + exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default []; + revoked = Bool default false; + } default {}; + }] default []; + remove_subjects = [...String] default []; + }; +}"#; + +#[derive(Debug, Default)] +pub struct PermissionConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for PermissionConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new("builtin:permissions", "permissions", "1", SCHEMA) + .map_err(|e| Error::Config(e.to_string())) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AssignmentSource { + #[serde(default)] + pub sets: Vec, + #[serde(default)] + pub namespaces: Vec, + #[serde(default)] + pub exclude_sets: Vec, + #[serde(default)] + pub exclude_namespaces: Vec, + #[serde(default)] + pub revoked: bool, +} + +impl AssignmentSource { + pub fn assignment(&self) -> PermissionAssignment { + let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| { + sets.iter() + .cloned() + .map(PermissionReference::Set) + .chain( + namespaces + .iter() + .cloned() + .map(PermissionReference::Namespace), + ) + .collect() + }; + PermissionAssignment { + grants: references(&self.sets, &self.namespaces), + exclusions: references(&self.exclude_sets, &self.exclude_namespaces), + revoked: self.revoked, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectAssignmentSource { + pub subject_id: String, + #[serde(default)] + pub execution: AssignmentSource, + #[serde(default)] + pub delegation: AssignmentSource, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PermissionSource { + #[serde(default)] + pub subjects: Vec, + #[serde(default)] + pub remove_subjects: Vec, +} + +/// Retained exact legacy restrictions belong to the domain, not the editable source. +/// Keeping the original change identity preserves the distinction between live and +/// already stale child grants during representation-only migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdoptedSubjectPolicy { + pub source: SubjectAssignmentSource, + pub change_id: String, + pub legacy_constraints: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)] +pub enum MigrationDiagnostic { + Converted { + subject_id: String, + operation: String, + }, + DomainConstraint { + subject_id: String, + permission: ScopedPermission, + }, + ExplicitConfigurationRequired { + subject_id: String, + permission: ScopedPermission, + }, +} + +pub fn source(evaluation: &EvaluationResult) -> Result { + let value = evaluation + .projections + .first() + .and_then(|p| p.data_json.get("permissions")) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + serde_json::from_value(value) + .map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) +} + +fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> { + // Validate even revoked assignments. A revocation must not hide a misspelled + // reference which would become active on a later edit. + let mut assignment = source.assignment(); + assignment.revoked = false; + let resolution = host.resolve(Some(&assignment)); + if resolution.projection().diagnostics.iter().any(|d| { + matches!( + d, + PermissionDiagnostic::UnknownKey(_) + | PermissionDiagnostic::UnknownNamespace(_) + | PermissionDiagnostic::UnknownSet(_) + ) + }) { + return Err(Error::InvalidInput(format!( + "invalid permission references: {:?}", + resolution.projection().diagnostics + ))); + } + Ok(()) +} + +pub fn validate_source( + host: &PermissionHost, + source: &PermissionSource, + mut require_subject: impl FnMut(&str) -> Result<()>, +) -> Result<()> { + let mut subjects = BTreeSet::new(); + for item in &source.subjects { + server_api::authorization::validate_authorization_identifier(&item.subject_id) + .map_err(Error::InvalidInput)?; + if !subjects.insert(&item.subject_id) { + return Err(Error::InvalidInput("duplicate Subject assignment".into())); + } + require_subject(&item.subject_id)?; + validate_assignment(host, &item.execution)?; + validate_assignment(host, &item.delegation)?; + } + for subject in &source.remove_subjects { + server_api::authorization::validate_authorization_identifier(subject) + .map_err(Error::InvalidInput)?; + if !subjects.insert(subject) { + return Err(Error::InvalidInput( + "Subject both assigned and removed, or removed twice".into(), + )); + } + require_subject(subject)?; + } + Ok(()) +} + +/// A patch deliberately does not replace the entire policy map. In particular a +/// schema default empty list never deletes omitted policies or reinstates defaults. +pub fn apply( + policies: &mut BTreeMap, + source: &PermissionSource, + change_id: &str, +) { + for subject in &source.remove_subjects { + policies.remove(subject); + } + for item in &source.subjects { + match policies.get_mut(&item.subject_id) { + Some(previous) if previous.source == *item => {} + Some(previous) => { + previous.source = item.clone(); + previous.change_id = change_id.to_owned(); + } + None => { + policies.insert( + item.subject_id.clone(), + AdoptedSubjectPolicy { + source: item.clone(), + change_id: change_id.to_owned(), + legacy_constraints: None, + }, + ); + } + } + } +} + +/// Convert exact legacy operations without expanding explicit children. Restrictions +/// which cannot be represented by the owning domain must be diagnosed before seal. +/// This routine does not issue a grant or install a policy. +pub fn migrate_subject( + host: &PermissionHost, + subject_id: &str, + legacy: &SubjectPermissions, +) -> Result<(AdoptedSubjectPolicy, Vec)> { + let mut diagnostics = vec![]; + let mut lane = |permissions: &[ScopedPermission]| -> Result { + let mut by_namespace: BTreeMap> = BTreeMap::new(); + for permission in permissions { + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); + let 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(), + }); + continue; + } + by_namespace.entry(key.namespace()).or_default().insert(key); + diagnostics.push(MigrationDiagnostic::DomainConstraint { + subject_id: subject_id.into(), + permission: permission.clone(), + }); + } + Ok(AssignmentSource { + namespaces: by_namespace + .into_iter() + .map(|(namespace, keys)| NamespacePermissions { + namespace, + keys: keys.into_iter().collect(), + }) + .collect(), + ..Default::default() + }) + }; + let execution = lane(&legacy.execution)?; + let delegation = lane(&legacy.delegation)?; + Ok(( + AdoptedSubjectPolicy { + source: SubjectAssignmentSource { + subject_id: subject_id.into(), + execution, + delegation, + }, + change_id: legacy.change_id.clone(), + legacy_constraints: Some(legacy.clone()), + }, + diagnostics, + )) +} + +impl AdoptedSubjectPolicy { + /// Read bridge for the old scoped-operation consumer. New payload-free + /// consumers resolve `source` through the current Feature Host and also enforce + /// retained domain constraints. This bridge cannot turn a key into an all-target + /// legacy resource: an operation without a representable constraint is unavailable. + pub fn legacy_read(&self, host: &PermissionHost) -> Result { + let execution = host.resolve(Some(&self.source.execution.assignment())); + let delegation = host.resolve(Some(&self.source.delegation.assignment())); + let filter = |allowed: &feature_permissions::PermissionResolution, + constraints: &[ScopedPermission]| + -> Result> { + let mut represented = BTreeSet::new(); + let mut output = vec![]; + for permission in constraints { + let operation = + server_api::authorization::canonical_worker_operation(&permission.operation) + .unwrap_or(&permission.operation); + if let Ok(key) = PermissionKey::parse(operation.to_owned()) { + if allowed.allowed.contains_key(&key) { + represented.insert(key); + output.push(permission.clone()); + } + } + } + if allowed.allowed.keys().any(|key| !represented.contains(key)) { + return Err(Error::InvalidInput("current key policy requires the payload-free operation consumer; no legacy all-target fallback is available".into())); + } + Ok(output) + }; + let empty = SubjectPermissions { + change_id: self.change_id.clone(), + execution: vec![], + delegation: vec![], + }; + let retained = self.legacy_constraints.as_ref().unwrap_or(&empty); + Ok(SubjectPermissions { + change_id: self.change_id.clone(), + execution: filter(execution.projection(), &retained.execution)?, + delegation: filter(delegation.projection(), &retained.delegation)?, + }) + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ccfa2c8b1..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)) @@ -8612,6 +8620,7 @@ fn worker_input_result_to_api( // distinguishable without collapsing it into a definite refusal or replaying input. if result.disposition == WorkerInputDisposition::Unknown { result.diagnostics.push(RuntimeDiagnostic { + startup: None, code: "worker_input_outcome_unknown".into(), severity: HostDiagnosticSeverity::Warning, message: "Worker input delivery outcome is unknown; inspect the current session before retrying".into(), @@ -16485,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. @@ -24197,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, @@ -27020,6 +27036,7 @@ fn skill_api_error(error: skills::SkillError) -> ApiError { message: format!("unknown Skill `{name}`"), }, vec![RuntimeDiagnostic { + startup: None, code: "skill_not_found".to_string(), severity: HostDiagnosticSeverity::Error, message: format!("unknown Skill `{name}`"), @@ -30426,6 +30443,7 @@ fn prepare_workspace_workdir_create( .to_string(), ), vec![RuntimeDiagnostic { + startup: None, code: "default_runtime_not_configured".to_string(), severity: HostDiagnosticSeverity::Error, message: "Workspace default Runtime is not configured".to_string(), @@ -30580,6 +30598,7 @@ async fn create_workspace_working_directory( return Err(ApiError::with_diagnostics( Error::UnknownRuntime(reserved.resolved_runtime_id), vec![RuntimeDiagnostic { + startup: None, code: "runtime_unavailable".to_string(), severity: HostDiagnosticSeverity::Error, message: "Selected Runtime is not available".to_string(), @@ -30603,6 +30622,7 @@ async fn create_workspace_working_directory( message: "Selected Runtime is not connected".to_string(), }, vec![RuntimeDiagnostic { + startup: None, code: "runtime_unavailable".to_string(), severity: HostDiagnosticSeverity::Error, message: "Selected Runtime is not available".to_string(), @@ -30659,6 +30679,7 @@ async fn create_workspace_working_directory( return Err(ApiError::with_diagnostics( error.into_error(), vec![RuntimeDiagnostic { + startup: None, code: failure_code, severity: HostDiagnosticSeverity::Error, message: "Selected Runtime could not inspect the reserved Workdir".to_string(), @@ -30697,6 +30718,7 @@ async fn create_workspace_working_directory( return Err(ApiError::with_diagnostics( error.into_error(), vec![RuntimeDiagnostic { + startup: None, code: failure_code, severity: HostDiagnosticSeverity::Error, message: "Selected Runtime rejected Workdir creation".to_string(), @@ -30736,6 +30758,7 @@ async fn create_workspace_working_directory( diagnostics.insert( 0, RuntimeDiagnostic { + startup: None, code: failure_code.clone(), severity: HostDiagnosticSeverity::Error, message: "Runtime did not create the reserved Workdir".to_string(), @@ -31490,6 +31513,7 @@ fn build_runtime_cleanup_plan( let mut diagnostics = error.diagnostics; if diagnostics.is_empty() { diagnostics.push(RuntimeDiagnostic { + startup: None, code: "workspace_cleanup_runtime_observation_unavailable".to_string(), severity: HostDiagnosticSeverity::Warning, message: sanitize_backend_error(&error.error.to_string()), @@ -35599,6 +35623,7 @@ fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> A message: "The embedded Runtime does not accept working directories".to_string(), }, vec![RuntimeDiagnostic { + startup: None, code: "embedded_worker_workdir_unsupported".to_string(), severity: HostDiagnosticSeverity::Error, message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(), @@ -35689,6 +35714,7 @@ fn finalize_worker_spawn_stage( .map(|assignment| assignment.operation_id.as_str()) .unwrap_or("none"); let mut diagnostics = vec![RuntimeDiagnostic { + startup: None, code: format!("worker_spawn_finalize_{}_failed", stage.as_str()), severity: HostDiagnosticSeverity::Error, message: format!( @@ -35704,6 +35730,7 @@ fn finalize_worker_spawn_stage( diagnostics.extend(compensation_errors); if !compensation_failed { diagnostics.push(RuntimeDiagnostic { + startup: None, code: "worker_spawn_compensated".to_string(), severity: HostDiagnosticSeverity::Info, message: format!( @@ -36039,6 +36066,7 @@ fn lifecycle_failure_detail( fn spawn_compensation_diagnostic(code: &str, message: String) -> RuntimeDiagnostic { RuntimeDiagnostic { + startup: None, code: code.to_string(), severity: HostDiagnosticSeverity::Error, message, @@ -37044,6 +37072,7 @@ fn project_workspace_worker_with_attachments( summary.state = "unavailable".to_string(); summary.worker_state = None; summary.diagnostics.push(RuntimeDiagnostic { + startup: None, code: "worker_singleton_fenced".to_string(), severity: HostDiagnosticSeverity::Warning, message: "This historical singleton generation is fenced by Backend ownership." @@ -37749,6 +37778,7 @@ fn working_directory_summaries( } if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? { diagnostics.push(RuntimeDiagnostic { + startup: None, code: "external_workdir_legacy_permissions".to_string(), severity: HostDiagnosticSeverity::Warning, message: format!( @@ -37955,6 +37985,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> InternalWorker }, workdir_attachments: Vec::new(), diagnostics: vec![RuntimeDiagnostic { + startup: None, code: "backend_worker_missing".to_string(), severity: HostDiagnosticSeverity::Info, message: @@ -37999,6 +38030,7 @@ fn worker_summary_from_projection( Vec::new() } else { vec![RuntimeDiagnostic { + startup: None, code: "worker_observation_unavailable".to_string(), severity: HostDiagnosticSeverity::Info, message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(), @@ -38645,6 +38677,7 @@ fn validate_working_directory_claims_for_browser( message: "working directory relative_cwd must stay inside the Runtime-owned working directory".to_string(), }, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_relative_cwd_invalid".to_string(), severity: HostDiagnosticSeverity::Error, message: "relative_cwd must be a relative path without parent traversal".to_string(), @@ -39239,6 +39272,7 @@ fn settings_diagnostic( message: impl Into, ) -> RuntimeDiagnostic { RuntimeDiagnostic { + startup: None, code: code.into(), severity, message: message.into(), @@ -39260,6 +39294,7 @@ fn repository_lookup(result: std::result::Result) - message: message.clone(), }, vec![RuntimeDiagnostic { + startup: None, code: "repository_not_configured".to_string(), severity: HostDiagnosticSeverity::Error, message, @@ -39277,6 +39312,7 @@ fn repository_lookup(result: std::result::Result) - message: message.clone(), }, vec![RuntimeDiagnostic { + startup: None, code: "repository_provider_unsupported".to_string(), severity: HostDiagnosticSeverity::Error, message, @@ -39292,6 +39328,7 @@ fn repository_lookup(result: std::result::Result) - message: message.clone(), }, vec![RuntimeDiagnostic { + startup: None, code: "repository_evidence_invalid".to_string(), severity: HostDiagnosticSeverity::Error, message, @@ -39452,6 +39489,7 @@ fn workspace_id_mismatch_error() -> ApiError { ApiError::with_diagnostics( Error::WorkspaceIdMismatch, vec![RuntimeDiagnostic { + startup: None, code: "workspace_id_mismatch".to_string(), severity: HostDiagnosticSeverity::Error, message, @@ -39561,6 +39599,7 @@ impl ApiErrorLog { .diagnostics .iter() .map(|diagnostic| RuntimeDiagnostic { + startup: None, code: diagnostic.code.clone(), severity: match diagnostic.severity { server_api::DiagnosticSeverity::Info => HostDiagnosticSeverity::Info, @@ -39598,6 +39637,7 @@ impl From for ApiError { Error::DbCapacityExceeded | Error::DbExecutorUnavailable | Error::DbOperationOutcomeUnknown => vec![RuntimeDiagnostic { + startup: None, code: match &error { Error::DbCapacityExceeded => "db_capacity_exceeded", Error::DbExecutorUnavailable => "db_executor_unavailable", @@ -39608,21 +39648,25 @@ impl From for ApiError { message: error.to_string(), }], Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic { + startup: None, code: "workdir_session_open_failed".to_string(), severity: HostDiagnosticSeverity::Error, message: transport.message.clone(), }], Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { + startup: None, code: code.clone(), severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(message), }], Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic { + startup: None, code: code.clone(), severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(message), }], Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { + startup: None, code: match ticket_error { ticket::TicketError::NotFound(_) => "ticket_not_found", ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", @@ -39669,6 +39713,7 @@ impl From for ApiError { subjektiv_diagnostic_code(message) .map(|code| { vec![RuntimeDiagnostic { + startup: None, code: code.to_string(), severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(message), @@ -40358,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; @@ -43074,6 +43120,7 @@ mod tests { worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![RuntimeDiagnostic { + startup: None, code: "fixture_runtime_rejected".to_string(), severity: HostDiagnosticSeverity::Error, message: "fixture rejected Worker create".to_string(), @@ -44981,6 +45028,7 @@ mod tests { kind: "ticket_backend_error".to_string(), message: "sqlite error: FOREIGN KEY constraint failed".to_string(), diagnostics: vec![RuntimeDiagnostic { + startup: None, code: "ticket_backend_error".to_string(), severity: HostDiagnosticSeverity::Error, message: "FOREIGN KEY constraint failed".to_string(), @@ -51633,6 +51681,7 @@ mod tests { #[test] fn unrecoverable_pending_workspace_restore_is_typed_for_replacement() { let diagnostics = [RuntimeDiagnostic { + startup: None, code: "embedded_worker_execution_rejected".to_string(), severity: HostDiagnosticSeverity::Error, message: "Restore Errored: pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config".to_string(), @@ -51644,6 +51693,7 @@ mod tests { #[test] fn workdir_runtime_miss_uses_exact_typed_code() { let typed_not_found = [RuntimeDiagnostic { + startup: None, code: "working_directory_not_found".to_string(), severity: HostDiagnosticSeverity::Warning, message: "missing".to_string(), @@ -51655,6 +51705,7 @@ mod tests { assert!(workdir_runtime_miss_is_not_found(&typed_not_found)); let unrelated = [RuntimeDiagnostic { + startup: None, code: "some_other_not_found".to_string(), severity: HostDiagnosticSeverity::Warning, message: "not a typed workdir miss".to_string(), @@ -63358,6 +63409,7 @@ mod tests { &api, deleted, &[RuntimeDiagnostic { + startup: None, code: "working_directory_not_found".to_string(), severity: HostDiagnosticSeverity::Warning, message: "missing".to_string(), @@ -63384,6 +63436,7 @@ mod tests { &api, unknown, &[RuntimeDiagnostic { + startup: None, code: "runtime_unavailable".to_string(), severity: HostDiagnosticSeverity::Warning, message: "temporarily unavailable".to_string(), @@ -65080,6 +65133,7 @@ mod tests { InternalWorkerOperationState::Rejected, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_not_found".into(), severity: HostDiagnosticSeverity::Warning, message: "authoritatively absent".into(), @@ -65131,6 +65185,7 @@ mod tests { InternalWorkerOperationState::Rejected, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_cleanup_resource_busy".into(), severity: HostDiagnosticSeverity::Warning, message: "busy".into(), @@ -65360,6 +65415,7 @@ mod tests { let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found"); let not_found = RuntimeDiagnostic { + startup: None, code: "working_directory_not_found".to_string(), severity: HostDiagnosticSeverity::Error, message: "missing".to_string(), @@ -65395,6 +65451,7 @@ mod tests { InternalWorkerOperationState::Accepted, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_provider_timeout".to_string(), severity: HostDiagnosticSeverity::Error, message: "timeout".to_string(), @@ -65511,6 +65568,7 @@ mod tests { InternalWorkerOperationState::Unsupported, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_unsupported".to_string(), severity: HostDiagnosticSeverity::Error, message: "unsupported".to_string(), @@ -65571,6 +65629,7 @@ mod tests { InternalWorkerOperationState::Rejected, None, vec![RuntimeDiagnostic { + startup: None, code: code.into(), severity: HostDiagnosticSeverity::Error, message: "secret /private/path".into(), @@ -65602,6 +65661,7 @@ mod tests { InternalWorkerOperationState::Rejected, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_cleanup_mount_present".into(), severity: HostDiagnosticSeverity::Error, message: "/private/mount credentials must not escape".into(), @@ -65752,6 +65812,7 @@ mod tests { InternalWorkerOperationState::Rejected, None, vec![RuntimeDiagnostic { + startup: None, code: "working_directory_cleanup_permission_denied".into(), severity: HostDiagnosticSeverity::Error, message: "permission denied /private/path".into(), @@ -65938,6 +65999,7 @@ mod tests { state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![RuntimeDiagnostic { + startup: None, code: "working_directory_not_found".to_string(), severity: HostDiagnosticSeverity::Error, message: "missing".to_string(), @@ -65949,6 +66011,7 @@ mod tests { state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![RuntimeDiagnostic { + startup: None, code: "working_directory_provider_timeout".to_string(), severity: HostDiagnosticSeverity::Error, message: "timeout".to_string(), @@ -67641,11 +67704,13 @@ mod tests { fn provider_rejection_classification_prefers_stable_error_diagnostic() { let diagnostics = vec![ RuntimeDiagnostic { + startup: None, code: "runtime_capacity_warning".to_string(), severity: HostDiagnosticSeverity::Warning, message: "capacity is low".to_string(), }, RuntimeDiagnostic { + startup: None, code: "runtime_capacity_unavailable".to_string(), severity: HostDiagnosticSeverity::Error, message: "capacity is exhausted".to_string(), @@ -67705,6 +67770,7 @@ mod tests { }) .unwrap(); let diagnostics = vec![RuntimeDiagnostic { + startup: None, code: "runtime_capacity_unavailable".to_string(), severity: HostDiagnosticSeverity::Error, message: "capacity is exhausted".to_string(), diff --git a/crates/workspace-server/src/server/tests/session_registration_connected_tests.rs b/crates/workspace-server/src/server/tests/session_registration_connected_tests.rs new file mode 100644 index 000000000..7dbb1a929 --- /dev/null +++ b/crates/workspace-server/src/server/tests/session_registration_connected_tests.rs @@ -0,0 +1,458 @@ +//! Focused T840 connection: real Subject policy -> signed Session POST -> real +//! ProfileRuntimeWorkerFactory startup -> RuntimeHTTP/Host diagnostic -> saved +//! Worker metadata. Only the unused external model is replaced. No restore, +//! operation replay, singleton-release, or Browser rendering contract is asserted. +use super::*; +use protocol::session_failure::{ + SessionPrincipal, SessionRegistrationCause, session_registration_attachment, +}; +use session_store::WorkerMetadataStore as _; +use std::sync::atomic::{AtomicUsize, Ordering}; +use worker_runtime::worker_backend::RuntimeWorkerFactory as _; + +#[derive(Clone, Default)] +struct UnusedModel(Arc); + +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for UnusedModel { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + async fn stream( + &self, + _: agen::llm_client::Request, + ) -> std::result::Result + { + self.0.fetch_add(1, Ordering::SeqCst); + panic!("Session registration does not require a model turn") + } +} + +struct RegistrationFixture { + connected: ConnectedWorkerHarness, + subject: String, + callbacks: Arc>>, + model: UnusedModel, +} + +impl RegistrationFixture { + async fn new(revoke_on_registration: bool) -> Self { + // Armed only after real Subject creation with its normal default policy. + // The callback is a deterministic synchronization point, not a fake Host + // rejection: the original signed request and real handler remain intact. + let revoke = Arc::new(Mutex::new(None::<(WorkspaceApi, String, PathBuf)>)); + let hook = revoke.clone(); + let callbacks = Arc::new(Mutex::new(Vec::new())); + let observed = callbacks.clone(); + let model = UnusedModel::default(); + let connected = ConnectedWorkerHarness::start_with_layer(model.clone(), move |router| { + router.layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let hook = hook.clone(); + let observed = observed.clone(); + async move { + if request.method() != axum::http::Method::POST + || request.uri().path() + != format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions") + { + return next.run(request).await; + } + assert!(request.headers().contains_key( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER + )); + let armed = hook.lock().unwrap().take(); + let pin_root = armed.as_ref().map(|(_, _, root)| root.clone()); + if let Some((api, subject, _)) = armed { + let store = open_subjektiv_store(&api).unwrap(); + let policy = store.subject_permissions(&subject).unwrap().unwrap(); + assert!( + policy + .execution + .iter() + .any(|p| p.operation == "session.record") + ); + store + .set_subject_permissions( + &subject, + &SubjectPermissionsUpdate { + operation_id: "session-registration-revoke-at-callback" + .into(), + expected_change_id: Some(policy.change_id), + execution: policy + .execution + .into_iter() + .filter(|p| p.operation != "session.record") + .collect(), + delegation: policy.delegation, + }, + ) + .unwrap(); + } + let (parts, body) = request.into_parts(); + let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); + let input: Value = serde_json::from_slice(&bytes).unwrap(); + if let Some(root) = pin_root { + let request: server_api::SubjektivRecordSessionRequest = serde_json::from_slice(&bytes).unwrap(); + let session = request.session_id.parse().unwrap(); + let retention = worker_runtime::retention::runtime_session_retention(&root); + retention.pin(session, session_store::retention::SessionRetentionPin(uuid::Uuid::now_v7()), || { + let entries = std::fs::read_dir(root.join("workers"))?; + for entry in entries { + let entry = entry?; + let name = format!("worker-runtime-{}", entry.file_name().to_string_lossy()); + let store = session_store::WorkerAggregateStore::open_read_only(&entry.path(), &name) + .map_err(|error| session_store::StoreError::Io(std::io::Error::other(error)))?; + if store.read_by_name(&name).unwrap().and_then(|m| m.active).is_some_and(|a| a.session_id == session) { return Ok(true); } + } + Ok(false) + }).unwrap(); + } + let response = next + .run(Request::from_parts(parts, Body::from(bytes))) + .await; + observed.lock().unwrap().push((response.status(), input)); + response + } + }, + )) + }) + .await; + let subject = open_subjektiv_store(&connected.api) + .unwrap() + .create_subject( + crate::subjektiv::SubjectRole::new("session-registration-connected").unwrap(), + ) + .unwrap() + .id; + let initial = open_subjektiv_store(&connected.api) + .unwrap() + .subject_permissions(&subject) + .unwrap() + .unwrap(); + assert!( + initial + .execution + .iter() + .any(|p| p.operation == "session.record") + ); + if revoke_on_registration { + *revoke.lock().unwrap() = Some(( + connected.api.clone(), + subject.clone(), + connected.runtime_root.clone(), + )); + } + Self { + connected, + subject, + callbacks, + model, + } + } + + fn request(&self) -> CreateWorkspaceWorkerRequest { + CreateWorkspaceWorkerRequest { + runtime_id: CONNECTED_RUNTIME.into(), + display_name: "Connected Session registration".into(), + singleton_key: None, + profile: Some("builtin:companion".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: self.subject.clone(), + }), + }, + control_operation_id: None, + execution_grant: None, + } + } + + fn assert_callback(&self, expected: StatusCode) -> String { + let callbacks = self.callbacks.lock().unwrap(); + assert!(!callbacks.is_empty(), "real signed startup callback"); + assert!(callbacks.iter().all(|(status, body)| *status == expected + && body["session_id"] == callbacks[0].1["session_id"])); + let (status, input) = &callbacks[0]; + assert_eq!(*status, expected); + assert_eq!(input["create_if_missing"], true); + assert_eq!(self.model.0.load(Ordering::SeqCst), 0); + input["session_id"].as_str().unwrap().to_owned() + } +} + +// This deliberately inspects only persisted serde shape, not rendered error text +// or an error-contract dependency. The expected value is a typed live attachment. +fn contains_saved_value(saved: &Value, expected: &Value) -> bool { + saved == expected + || match saved { + Value::Object(fields) => fields + .values() + .any(|value| contains_saved_value(value, expected)), + Value::Array(values) => values + .iter() + .any(|value| contains_saved_value(value, expected)), + _ => false, + } +} + +fn read_metadata(root: &std::path::Path, worker: &str) -> Value { + // Reopen the canonical metadata store, not the separate Runtime catalog. + let name = format!("worker-runtime-{worker}"); + let store = session_store::WorkerAggregateStore::open_read_only( + &root.join("workers").join(worker), + &name, + ) + .unwrap(); + let metadata = store.read_by_name(&name).unwrap().unwrap(); + assert!(matches!( + metadata.decode_startup_failure::(), + session_store::worker_metadata::StartupFailureRead::Present(_) + )); + serde_json::to_value(metadata).unwrap() +} + +#[tokio::test] +async fn session_registration_real_judgments_separate_operator_subject_and_attribution() { + // This normal boundary fixture uses the real router, Runtime signature, + // authority store and attribution store; its Runtime does not run a model. + let f = Fixture::new().await; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"); + let missing_session = session_store::new_session_id().to_string(); + let request = json!({"session_id": missing_session, "create_if_missing": false}); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &path, Some(request.clone())), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_registration_cause( + body, + SessionRegistrationCause::denied(SessionPrincipal::Operator), + ); + + let signed = |request: &Value| { + runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &path, + serde_json::to_vec(request).unwrap(), + ) + }; + let (status, body) = response(&f.api, signed(&request)).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_registration_cause(body, SessionRegistrationCause::attribution_missing()); + + let store = open_subjektiv_store(&f.api).unwrap(); + let foreign_session = session_store::new_session_id().to_string(); + store + .record_session_attribution( + crate::subjektiv::SubjectSessionAttribution::new( + &f.subject, + &f.source.runtime_id, + "foreign-worker", + &foreign_session, + ) + .unwrap(), + ) + .unwrap(); + let (status, body) = response( + &f.api, + signed(&json!({"session_id":foreign_session, "create_if_missing":false})), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_registration_cause(body, SessionRegistrationCause::binding_mismatch()); + + // The fixture removes its bootstrap policy only after the real setup POST. + let (status, body) = response( + &f.api, + signed(&json!({"session_id":missing_session, "create_if_missing":true})), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_registration_cause( + body, + SessionRegistrationCause::denied(SessionPrincipal::Subject), + ); + assert!( + store + .session_attribution(&missing_session) + .unwrap() + .is_none() + ); +} + +fn assert_registration_cause(body: Value, cause: SessionRegistrationCause) { + let error: server_api::RepositoryApiError = serde_json::from_value(body).unwrap(); + assert_eq!( + error.failure, + Some(session_registration_attachment(cause).unwrap()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn real_session_record_denial_survives_runtime_host_and_metadata_reopen() { + let fixture = RegistrationFixture::new(true).await; + // The real create preflight sees normal defaults. Revocation happens only + // after the actual factory has issued its signed Session-registration POST. + let error = scoped_create_workspace_worker( + State(fixture.connected.api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.into(), + }), + HeaderMap::new(), + Json(fixture.request()), + ) + .await + .unwrap_err(); + fixture.assert_callback(StatusCode::FORBIDDEN); + let store = open_subjektiv_store(&fixture.connected.api).unwrap(); + assert!( + store + .subject_session_attributions(&fixture.subject) + .unwrap() + .is_empty() + ); + assert!( + !store + .subject_permissions(&fixture.subject) + .unwrap() + .unwrap() + .execution + .iter() + .any(|p| p.operation == "session.record") + ); + + let failures = fixture.connected.runtime_startup_failures.lock().unwrap(); + assert_eq!( + failures.len(), + 1, + "actual RuntimeHTTP startup error envelope" + ); + let wire = &failures[0]; + let expected_primary = session_registration_attachment(SessionRegistrationCause::denied( + SessionPrincipal::Subject, + )) + .unwrap(); + assert_eq!(wire.primary, expected_primary); + assert_eq!( + wire.persistence, + Some(protocol::startup_failure::StartupPersistenceOutcome::Persisted) + ); + let host = error + .diagnostics + .iter() + .filter_map(|d| d.startup.as_ref()) + .find(|startup| startup.primary == wire.primary) + .expect("RemoteWorkerRuntime must preserve the typed startup primary in Host diagnostics"); + assert_eq!(host.primary, wire.primary); + assert!( + host.secondary.iter().any(|failure| matches!( + failure, + protocol::startup_failure::StartupSecondaryFailure::Persistence { + phase: protocol::startup_failure::StartupFailurePhase::RuntimeRecordDelete, + .. + } + )), + "a real Session retention pin rejects outer Runtime deletion without hiding the primary" + ); + let expected = serde_json::to_value(&wire.primary).unwrap(); + assert!( + expected.is_string(), + "the shared attachment contains a codec envelope" + ); + + // A fresh fixture has exactly one attempted Worker. Discover its canonical + // saved aggregate, without making any assumptions about retained ownership + // or public catalog visibility after a failed create. + let workers = std::fs::read_dir(fixture.connected.runtime_root.join("workers")) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect::>(); + assert_eq!( + workers.len(), + 1, + "factory attempt must have real saved metadata" + ); + let saved = read_metadata(&fixture.connected.runtime_root, &workers[0]); + assert!( + contains_saved_value(&saved, &expected), + "saved metadata must retain the original typed Session registration denial" + ); + let reopened = read_metadata(&fixture.connected.runtime_root, &workers[0]); + assert_eq!( + reopened, saved, + "read-only reopen, not a startup operation retry" + ); + assert!(contains_saved_value(&reopened, &expected)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn permitted_session_registration_reaches_real_factory_and_saved_confirmation() { + let fixture = RegistrationFixture::new(false).await; + let initial = open_subjektiv_store(&fixture.connected.api) + .unwrap() + .subject_permissions(&fixture.subject) + .unwrap() + .unwrap(); + let Json(created) = scoped_create_workspace_worker( + State(fixture.connected.api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.into(), + }), + HeaderMap::new(), + Json(fixture.request()), + ) + .await + .unwrap(); + let session_id = fixture.assert_callback(StatusCode::OK); + let store = open_subjektiv_store(&fixture.connected.api).unwrap(); + assert_eq!( + store + .subject_permissions(&fixture.subject) + .unwrap() + .unwrap(), + initial + ); + let attributions = store + .subject_session_attributions(&fixture.subject) + .unwrap(); + assert_eq!(attributions.len(), 1); + assert_eq!(attributions[0].session_id, session_id); + assert_eq!(attributions[0].worker_id, created.worker_id); + assert_eq!(attributions[0].runtime_id, CONNECTED_RUNTIME); + assert!( + fixture + .connected + .runtime_startup_failures + .lock() + .unwrap() + .is_empty() + ); + + let name = format!("worker-runtime-{}", created.worker_id); + let metadata = session_store::WorkerAggregateStore::open_read_only( + &fixture + .connected + .runtime_root + .join("workers") + .join(&created.worker_id), + &name, + ) + .unwrap(); + let saved = metadata.read_by_name(&name).unwrap().unwrap(); + assert!(matches!( + saved.subjektiv_session_attribution, + Some(session_store::SubjektivSessionAttributionState::Confirmed { .. }) + )); + let source = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + fixture + .connected + .factory + .retained_session_snapshot(&worker_runtime::identity::WorkerRef::try_from(&source).unwrap()) + .expect("real factory retained the permitted Worker Session"); +} diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs index ab95afe7a..514fed0de 100644 --- a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -9,6 +9,8 @@ use super::*; mod chat_delegation_connected; #[path = "chat_tests.rs"] mod chat_tests; +#[path = "session_registration_connected_tests.rs"] +mod session_registration_connected; #[path = "subject_body_cleanup_connected_tests.rs"] mod subject_body_cleanup_connected; #[path = "subject_body_connected_tests.rs"] @@ -2737,6 +2739,7 @@ pub(super) struct ConnectedWorkerHarness { materializer: worker_runtime::working_directory::RuntimeGitMaterializer, authorizations: Arc>>, runtime_errors: Arc>>, + runtime_startup_failures: Arc>>, _backend_server: AuthorizationCallbackServer, _runtime_server: ConnectedRuntimeServer, _dir: tempfile::TempDir, @@ -2942,6 +2945,8 @@ impl ConnectedWorkerHarness { .unwrap(); let runtime_errors = Arc::new(Mutex::new(Vec::::new())); let captured_errors = runtime_errors.clone(); + let runtime_startup_failures = Arc::new(Mutex::new(Vec::new())); + let captured_startup_failures = runtime_startup_failures.clone(); let runtime_server = ConnectedRuntimeServer::start( runtime_listener, worker_runtime::http_server::runtime_http_router_with_workspace_auth( @@ -2955,6 +2960,7 @@ impl ConnectedWorkerHarness { .layer(axum::middleware::from_fn( move |request: axum::extract::Request, next: axum::middleware::Next| { let errors = captured_errors.clone(); + let startup_failures = captured_startup_failures.clone(); async move { let response = next.run(request).await; if !response.status().is_client_error() @@ -2965,6 +2971,13 @@ impl ConnectedWorkerHarness { let (parts, body) = response.into_parts(); let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); let body: Value = serde_json::from_slice(&bytes).unwrap(); + if let Some(startup) = body["error"].get("startup").filter(|v| !v.is_null()) + { + startup_failures.lock().unwrap().push( + serde_json::from_value(startup.clone()) + .expect("RuntimeHTTP structured startup failure"), + ); + } errors.lock().unwrap().push( body["error"]["message"] .as_str() @@ -3045,6 +3058,7 @@ impl ConnectedWorkerHarness { materializer, authorizations, runtime_errors, + runtime_startup_failures, _backend_server: backend_server, _runtime_server: runtime_server, _dir: dir, 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 762839881..3bc3c034b 100644 --- a/crates/workspace-server/src/storage_format_tests.rs +++ b/crates/workspace-server/src/storage_format_tests.rs @@ -110,8 +110,13 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) { verify_history(conn, current).unwrap(); if current >= 114 { let plan = published_plan(conn, current).unwrap().unwrap(); - assert!(plan.migrations.is_empty()); - assert_eq!(plan.target_schema_version, current); + assert_eq!(plan.migrations.len(), 1); + assert_eq!(plan.migrations[0].version, 122); + assert_eq!( + plan.migrations[0].name, + crate::config_adoption::MIGRATION_NAME + ); + assert_eq!(plan.target_schema_version, 122); } assert_eq!(rows, saved_rows(conn)); assert_eq!(shape, snapshot(conn).unwrap()); @@ -195,11 +200,30 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad let conn = Connection::open(&path).unwrap(); // Production store owner, not a separate test migration implementation. super::super::prepare_connection(&conn).unwrap(); + assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122); + for (table, rows) in &expected { + if table != "__yoi_schema_migrations" { + assert_eq!( + saved_rows(&conn) + .into_iter() + .find(|(name, _)| name == table) + .unwrap() + .1, + *rows, + "{table}, from {current}" + ); + } + } + let mut history = super::super::workspace_schema_migration_history(&conn).unwrap(); assert_eq!( - super::super::current_schema_version(&conn).unwrap(), - current + history.pop(), + Some((122, crate::config_adoption::MIGRATION_NAME.into())) ); - assert_eq!(saved_rows(&conn), expected); + assert_eq!( + history, + super::super::workspace_schema_migration_history(&source).unwrap() + ); + verify_history(&conn, 122).unwrap(); } } } @@ -290,7 +314,7 @@ fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabe let (_temp, conn) = retained_to(103, alternate); let original = super::super::workspace_schema_migration_history(&conn).unwrap(); super::super::prepare_connection(&conn).unwrap(); - verify_history(&conn, 113).unwrap(); + verify_history(&conn, super::super::LATEST_SCHEMA_VERSION).unwrap(); let updated = super::super::workspace_schema_migration_history(&conn).unwrap(); assert_eq!(&updated[..original.len()], &original); let name: String = conn diff --git a/crates/workspace-server/src/storage_formats.rs b/crates/workspace-server/src/storage_formats.rs index 29a057301..aafe26414 100644 --- a/crates/workspace-server/src/storage_formats.rs +++ b/crates/workspace-server/src/storage_formats.rs @@ -71,6 +71,31 @@ fn invalid(message: impl std::fmt::Display) -> Error { /// Every other row, gap, mixed family, unknown suffix and missing object fails. pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> { let rows = super::workspace_schema_migration_history(conn)?; + verify_history_records(conn, current, &rows) +} + +fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> { + if current == 122 { + verify_configuration_shape(conn)?; + if rows == [(122, super::SCHEMA_BASELINE_NAME.to_owned())] { + require_shape(conn, Stage::Base)?; + return verify_103_features(conn, current, rows); + } + if rows.last().map(|(version, name)| (*version, name.as_str())) + != Some((122, crate::config_adoption::MIGRATION_NAME)) + { + return Err(invalid("unrecognized configuration adoption receipt")); + } + let prefix = &rows[..rows.len() - 1]; + let prior = prefix + .last() + .map(|row| row.0) + .ok_or_else(|| invalid("missing configuration predecessor"))?; + if !(113..=121).contains(&prior) { + return Err(invalid("invalid configuration predecessor")); + } + return verify_history_records(conn, prior, prefix); + } let Some((baseline, name)) = rows.first() else { return Err(invalid("missing migration receipts")); }; @@ -191,11 +216,34 @@ pub(super) fn published_plan( verify_history(conn, current)?; Ok(Some(super::WorkspaceSchemaMigrationPlan { current_schema_version: current, - target_schema_version: current, - migrations: vec![], + target_schema_version: 122, + migrations: if current < 122 { + vec![super::WorkspaceSchemaMigrationStep { + version: 122, + name: crate::config_adoption::MIGRATION_NAME.to_owned(), + }] + } else { + vec![] + }, })) } +fn verify_configuration_shape(conn: &Connection) -> Result<()> { + let expected_db = Connection::open_in_memory()?; + expected_db.execute_batch(content_history_store::SCHEMA)?; + expected_db.execute_batch(crate::config_adoption::SCHEMA)?; + let expected = snapshot(&expected_db)?; + let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect(); + let actual: Shape = snapshot(conn)? + .into_iter() + .filter(|(_, object)| tables.contains(&object.1)) + .collect(); + if actual != expected { + return Err(invalid("configuration adoption receipt/shape mismatch")); + } + Ok(()) +} + // sqlite_schema programs, not table/column presence alone. SQL literal bytes, // CHECK/FK clauses, indexes and trigger programs remain part of the contract. type Shape = BTreeMap)>; @@ -233,7 +281,7 @@ fn reference_base() -> Result { merge_request::migrate(&conn).map_err(invalid)?; conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; let tx = conn.unchecked_transaction()?; - super::create_latest_workspace_schema(&tx)?; + super::create_workspace_schema_through_v113(&tx)?; tx.commit()?; Ok(conn) } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index da7709d40..452e7e012 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50; #[path = "storage_formats.rs"] mod storage_formats; -const LATEST_SCHEMA_VERSION: i64 = 113; +const LATEST_SCHEMA_VERSION: i64 = 122; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Ticket work-decomposition parent links", apply: migrate_ticket_parents_v112_to_v113, }, + Migration { + version: 122, + name: crate::config_adoption::MIGRATION_NAME, + apply: migrate_configuration_adoption_to_v122, + }, ]; // Both schema-103 identities shipped. Keep the alternate implementation and accept @@ -16069,6 +16074,25 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if !(113..=121).contains(¤t) { + return Err(Error::Store( + "Configuration adoption migration requires a recognized schema 113..121".into(), + )); + } + storage_formats::verify_history(conn, current)?; + let tx = conn.unchecked_transaction()?; + tx.execute_batch(content_history_store::SCHEMA)?; + tx.execute_batch(crate::config_adoption::SCHEMA)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", + [crate::config_adoption::MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 112 { return Err(Error::Store( @@ -16165,6 +16189,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> { } fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + create_workspace_schema_through_v113(conn)?; + conn.execute_batch(content_history_store::SCHEMA)?; + conn.execute_batch(crate::config_adoption::SCHEMA)?; + Ok(()) +} + +fn create_workspace_schema_through_v113(conn: &Connection) -> Result<()> { create_workspace_schema_structure(conn)?; assignment_delivery::install(conn)?; assignment_delivery::install_post_inputs(conn)?; @@ -17083,7 +17114,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result Result<()> { - if current > LATEST_SCHEMA_VERSION { + if current > 113 { return storage_formats::verify_history(conn, current); } let rows = workspace_schema_migration_history(conn)?; @@ -18878,9 +18909,16 @@ mod tests { #[test] fn current_schema_accepts_every_retained_canonical_provenance() { - for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION { + for baseline_version in + (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) + { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;") + .unwrap(); + create_latest_workspace_schema(&conn).unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", params![baseline_version, SCHEMA_BASELINE_NAME], 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 @@