feat: connect owner configuration adoption and guarded policy cutover

Integration checkpoint for T-838, not a completed reviewed handoff. Includes published T-840 startup dependencies. Root check, API artifacts and focused HTTP/cutover tests executed; Unicode renderer roundtrip still fails on the separately owned Decodal 0.4.0 issue (T-830). Web validation/visual review, broad suites and independent review remain outstanding.
This commit is contained in:
2026-11-04 16:54:54 +09:00
parent 6d43025a30
commit 9f8aad79a3
39 changed files with 7647 additions and 818 deletions
+1
View File
@@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API
- [`ServerApi`] — HTTP operations and transport metadata - [`ServerApi`] — HTTP operations and transport metadata
- [`ServerRequestContext`] — Request context passed to Server authentication adapters - [`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 - [`RequestActor`] — Human identity and authentication method
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result - [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
@@ -1,4 +1,7 @@
fn main() -> Result<(),Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
print!("{}", server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?); print!(
"{}",
server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?
);
Ok(()) Ok(())
} }
@@ -1,8 +1,13 @@
#[cfg(feature="typescript")] #[cfg(feature = "typescript")]
fn main() { fn main() {
print!("{}",server_api::normalized_typescript(server_api::configuration_adoption::configuration_typescript())); print!(
"{}",
server_api::normalized_typescript(
server_api::configuration_adoption::configuration_typescript()
)
);
} }
#[cfg(not(feature="typescript"))] #[cfg(not(feature = "typescript"))]
fn main() { fn main() {
eprintln!("generate_configuration_adoption_types requires --features typescript"); eprintln!("generate_configuration_adoption_types requires --features typescript");
std::process::exit(2); std::process::exit(2);
+120 -62
View File
@@ -1,13 +1,15 @@
//! Workspace-owner source adoption transport. The Server authenticates the owner //! Workspace-owner source adoption transport. The Server authenticates the owner
//! before reading source, previewing, sealing migration data, adopting or reconciling. //! 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. //! A result lookup is not a new adoption and absence is not proof of failure.
use crate::{ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse}; use crate::{
ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse,
};
use feature_permissions::{PermissionAssignment, PermissionCatalog}; use feature_permissions::{PermissionAssignment, PermissionCatalog};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionRequest { pub struct ConfigurationAdoptionRequest {
pub operation_id: String, pub operation_id: String,
@@ -18,90 +20,125 @@ pub struct ConfigurationAdoptionRequest {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all="snake_case")] #[serde(rename_all = "snake_case")]
pub enum RuntimePolicyApplication { NextCurrentPolicyCheck } pub enum RuntimePolicyApplication {
NextCurrentPolicyCheck,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionReceipt { pub struct ConfigurationAdoptionReceipt {
pub workspace_id:String, pub workspace_id: String,
pub operation_id:String, pub operation_id: String,
pub history_head:String, pub history_head: String,
pub state:WorkspaceConfigTreeResponse, pub state: WorkspaceConfigTreeResponse,
pub source_stored:bool, pub source_stored: bool,
pub policy_committed:bool, pub policy_committed: bool,
pub runtime_application:RuntimePolicyApplication, pub runtime_application: RuntimePolicyApplication,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigurationSubjectPolicy { pub struct ConfigurationSubjectPolicy {
pub subject_id:String, pub subject_id: String,
pub change_id:String, pub change_id: String,
pub execution:PermissionAssignment, pub execution: PermissionAssignment,
pub delegation:PermissionAssignment, pub delegation: PermissionAssignment,
/// Exact retained domain restrictions, not editable payloads on permission keys. /// Exact retained domain restrictions, not editable payloads on permission keys.
pub legacy_constraints:Option<crate::authorization::SubjectPermissions>, pub legacy_constraints: Option<crate::authorization::SubjectPermissions>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag="kind",rename_all="snake_case",deny_unknown_fields)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationMigrationDiagnostic { pub enum ConfigurationMigrationDiagnostic {
Converted {subject_id:String,operation:String}, Converted {
DomainConstraint {subject_id:String,permission:crate::authorization::ScopedPermission}, subject_id: String,
ExplicitConfigurationRequired {subject_id:String,permission:crate::authorization::ScopedPermission}, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigurationSourceResponse { pub struct ConfigurationSourceResponse {
/// Before the first adoption this is an editable, evaluated migration draft. /// Before the first adoption this is an editable, evaluated migration draft.
/// source_stored distinguishes it from the unchanged stored source. /// source_stored distinguishes it from the unchanged stored source.
pub state:WorkspaceConfigTreeResponse, pub state: WorkspaceConfigTreeResponse,
pub history_head:Option<String>, pub history_head: Option<String>,
pub source_stored:bool, pub source_stored: bool,
pub catalog:PermissionCatalog, pub catalog: PermissionCatalog,
pub catalog_digest:String, pub catalog_digest: String,
pub expected_legacy_export:Option<String>, pub expected_legacy_export: Option<String>,
pub policies:Vec<ConfigurationSubjectPolicy>, pub policies: Vec<ConfigurationSubjectPolicy>,
pub migration_diagnostics:Vec<ConfigurationMigrationDiagnostic>, pub migration_diagnostics: Vec<ConfigurationMigrationDiagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigurationPreviewResponse { pub struct ConfigurationPreviewResponse {
pub expected_head:Option<String>, pub expected_head: Option<String>,
pub state:WorkspaceConfigTreeResponse, pub state: WorkspaceConfigTreeResponse,
pub changed_paths:Vec<String>, pub changed_paths: Vec<String>,
pub changed_subjects:Vec<String>, pub changed_subjects: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature="typescript",derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag="status",rename_all="snake_case",deny_unknown_fields)] #[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationReconciliation { pub enum ConfigurationReconciliation {
Adopted {receipt:ConfigurationAdoptionReceipt}, Adopted {
PendingTransfer {transfer_identity:String,export_digest:String}, receipt: ConfigurationAdoptionReceipt,
},
PendingTransfer {
transfer_identity: String,
export_digest: String,
},
/// No matching evidence was observed. This is not permission to change the ID. /// No matching evidence was observed. This is not permission to change the ID.
NotObserved, NotObserved,
} }
#[api_macros::api(reqwest,axum,openapi)] #[api_macros::api(reqwest, axum, openapi)]
pub trait ConfigurationAdoptionApi { 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)] #[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<ConfigurationSourceResponse,RepositoryApiError>; async fn configuration_source(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<ConfigurationSourceResponse, RepositoryApiError>;
#[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)] #[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<ConfigurationPreviewResponse,RepositoryApiError>; async fn configuration_preview(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationPreviewResponse, RepositoryApiError>;
#[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)] #[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<ConfigurationAdoptionReceipt,RepositoryApiError>; async fn configuration_adopt(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationAdoptionReceipt, RepositoryApiError>;
#[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)] #[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<ConfigurationReconciliation,RepositoryApiError>; async fn configuration_reconcile(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationReconciliation, RepositoryApiError>;
} }
impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {} impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {}
@@ -110,25 +147,46 @@ impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {}
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {} impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {}
impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {} impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {}
pub fn configuration_contract_openapi() -> Result<api_macros::openapi::OpenApiDocument,api_macros::openapi::OpenApiError> { pub fn configuration_contract_openapi()
use sha2::{Digest,Sha256}; -> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
let document=|source_digest:&str| configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo { use sha2::{Digest, Sha256};
title:"Workspace configuration adoption",version:env!("CARGO_PKG_VERSION"),source_digest, let document = |source_digest: &str| {
}); configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo {
let normalized=document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?.to_json()?; title: "Workspace configuration adoption",
let digest:String=Sha256::digest(normalized.as_bytes()).iter().map(|byte|format!("{byte:02x}")).collect(); 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}")) document(&format!("sha256:{digest}"))
} }
#[cfg(feature="typescript")] #[cfg(feature = "typescript")]
pub fn configuration_typescript() -> String { pub fn configuration_typescript() -> String {
use ts_rs::TS; use ts_rs::TS;
let config=ts_rs::Config::default(); let config = ts_rs::Config::default();
let declarations=[ let declarations = [
ConfigurationAdoptionRequest::decl(&config), RuntimePolicyApplication::decl(&config), ConfigurationAdoptionRequest::decl(&config),
ConfigurationAdoptionReceipt::decl(&config), ConfigurationSubjectPolicy::decl(&config), RuntimePolicyApplication::decl(&config),
ConfigurationMigrationDiagnostic::decl(&config), ConfigurationSourceResponse::decl(&config), ConfigurationAdoptionReceipt::decl(&config),
ConfigurationPreviewResponse::decl(&config), ConfigurationReconciliation::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::<Vec<_>>().join("\n\n")) format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
} }
+2 -1
View File
@@ -8,9 +8,10 @@ pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization; pub mod authorization;
mod chat; mod chat;
pub mod feature_permissions;
pub mod configuration_adoption; pub mod configuration_adoption;
pub mod feature_permissions;
pub use chat::*; pub use chat::*;
pub use configuration_adoption::*;
mod subject_body; mod subject_body;
pub use subject_body::*; pub use subject_body::*;
mod chat_transport; mod chat_transport;
+4 -1
View File
@@ -407,7 +407,10 @@ impl SubjektivStore {
)?; )?;
} }
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? { if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
tx.execute("DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?; tx.execute(
"DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1",
[subject],
)?;
} }
for table in DIRECT { for table in DIRECT {
tx.execute( tx.execute(
+6 -3
View File
@@ -9,8 +9,8 @@ pub mod journal;
mod legacy_migrations; mod legacy_migrations;
pub mod permissions; pub mod permissions;
pub use permissions::{ pub use permissions::{
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt,
PermissionTransferReceipt, PermissionTransferSnapshot, PermissionTransferSnapshot,
}; };
mod schema; mod schema;
mod subject_creation; mod subject_creation;
@@ -755,7 +755,10 @@ impl SubjektivStore {
} }
} }
})?; })?;
Ok(Self { database, canonical_permission_source: None }) Ok(Self {
database,
canonical_permission_source: None,
})
} }
pub fn scope_id(&self) -> &str { pub fn scope_id(&self) -> &str {
+2 -6
View File
@@ -14,7 +14,7 @@ use sha2::{Digest, Sha256};
pub(crate) mod transfer; pub(crate) mod transfer;
pub use transfer::{ pub use transfer::{
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport,
PermissionTransferReceipt, PermissionTransferSnapshot, PermissionTransferReceipt, PermissionTransferSnapshot,
}; };
@@ -238,11 +238,7 @@ fn grant_in_connection(
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)" "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<String> = connection let raw: Option<String> = connection
.query_row( .query_row(sql, params![runtime, worker], |row| row.get(0))
sql,
params![runtime, worker],
|row| row.get(0),
)
.optional()?; .optional()?;
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
.transpose() .transpose()
+219 -46
View File
@@ -1,5 +1,7 @@
//! One-way preparation of a permission-domain transfer. No Server adoption or dual commit. //! One-way preparation of a permission-domain transfer. No Server adoption or dual commit.
use super::*; use super::*;
use rusqlite::TransactionBehavior;
use std::cell::RefCell;
use std::sync::Arc; use std::sync::Arc;
/// Host connection to the canonical permission domain, not an ACL or grant issuer. /// Host connection to the canonical permission domain, not an ACL or grant issuer.
@@ -69,8 +71,95 @@ pub struct PermissionTransferReceipt {
pub snapshot: PermissionTransferSnapshot, 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<Option<Transaction<'connection>>>,
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<PermissionTransferReceipt> {
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 { impl SubjektivStore {
pub fn with_canonical_permission_source(mut self, source: Arc<dyn CanonicalPermissionSource>) -> Self { /// Hold the native Feature connection/lifecycle fence across a synchronous Host action.
/// An IMMEDIATE Feature transaction is entered before invoking the action (and thus before
/// any nested Server transaction). Without a seal it rolls back on every return path.
/// A successful seal survives even an action error: this is not a distributed transaction.
/// Callbacks must use this guard for validation and must not reenter Feature storage.
/// Hosts can retain Server error/status types by returning `Ok(server_result)` as `T`.
pub fn with_permission_authority_guard<T>(
&self,
expected: &DatabaseIdentity,
action: impl FnOnce(&PermissionAuthorityGuard<'_>) -> Result<T>,
) -> Result<T> {
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<dyn CanonicalPermissionSource>,
) -> Self {
self.canonical_permission_source = Some(source); self.canonical_permission_source = Some(source);
self self
} }
@@ -83,7 +172,10 @@ impl SubjektivStore {
/// Reads one coherent pre-seal snapshot without installing schema or changing authority. /// 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. /// After a transfer, returns the retained exact snapshot, never a changed/deleted live set.
pub fn preview_permission_transfer(&self, expected: &DatabaseIdentity) -> Result<PermissionTransferSnapshot> { pub fn preview_permission_transfer(
&self,
expected: &DatabaseIdentity,
) -> Result<PermissionTransferSnapshot> {
self.permission_transfer_transaction(expected, |tx| { self.permission_transfer_transaction(expected, |tx| {
if let Some(receipt) = retained_receipt(tx)? { if let Some(receipt) = retained_receipt(tx)? {
require_receipt_identity(&receipt, expected)?; require_receipt_identity(&receipt, expected)?;
@@ -94,6 +186,22 @@ impl SubjektivStore {
}) })
} }
/// Observe saved transfer evidence without exporting, sealing or changing policy.
pub fn permission_transfer_receipt(
&self,
expected: &DatabaseIdentity,
) -> Result<Option<PermissionTransferReceipt>> {
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 /// 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 /// 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. /// authorize/preview first and bind transfer_id to the complete Server request fingerprint.
@@ -104,31 +212,8 @@ impl SubjektivStore {
transfer_id: &str, transfer_id: &str,
expected_export_digest: &str, expected_export_digest: &str,
) -> Result<PermissionTransferReceipt> { ) -> Result<PermissionTransferReceipt> {
validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?;
self.permission_transfer_transaction(expected, |tx| { self.permission_transfer_transaction(expected, |tx| {
if let Some(receipt) = retained_receipt(tx)? { seal_in_transaction(tx, expected, transfer_id, expected_export_digest)
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)
}) })
} }
@@ -150,7 +235,11 @@ impl SubjektivStore {
}) })
} }
pub(super) fn current_permission_source(&self, connection: &Connection, subject: &str) -> Result<Option<SubjectPermissions>> { pub(super) fn current_permission_source(
&self,
connection: &Connection,
subject: &str,
) -> Result<Option<SubjectPermissions>> {
if !is_sealed(connection)? { if !is_sealed(connection)? {
return source_in_connection(connection, subject); return source_in_connection(connection, subject);
} }
@@ -158,7 +247,8 @@ impl SubjektivStore {
.ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?; .ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?;
let source = provider.subject_permissions(self.scope_id(), subject)?; let source = provider.subject_permissions(self.scope_id(), subject)?;
if let Some(source) = &source { if let Some(source) = &source {
validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; validate_authorization_identifier(&source.change_id)
.map_err(SubjektivError::InvalidRecord)?;
validate_scope(self.scope_id(), &source.execution)?; validate_scope(self.scope_id(), &source.execution)?;
validate_scope(self.scope_id(), &source.delegation)?; validate_scope(self.scope_id(), &source.delegation)?;
} }
@@ -166,51 +256,123 @@ impl SubjektivStore {
} }
} }
/// Shared exact seal/retry logic. The caller owns the transaction and its commit boundary.
fn seal_in_transaction(
tx: &Transaction<'_>,
expected: &DatabaseIdentity,
transfer_id: &str,
expected_export_digest: &str,
) -> Result<PermissionTransferReceipt> {
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<bool> { pub(crate) fn has_table(connection: &Connection, name: &str) -> Result<bool> {
Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", [name], |row| row.get(0))?) Ok(connection.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
[name],
|row| row.get(0),
)?)
} }
pub(crate) fn is_sealed(connection: &Connection) -> Result<bool> { pub(crate) fn is_sealed(connection: &Connection) -> Result<bool> {
if !has_table(connection, "permission_authority_seal")? { return Ok(false); } if !has_table(connection, "permission_authority_seal")? {
Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", [], |row| row.get(0))?) 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<()> { pub(super) fn require_unsealed(connection: &Connection) -> Result<()> {
if is_sealed(connection)? { return Err(SubjektivError::PermissionAuthoritySealed); } if is_sealed(connection)? {
return Err(SubjektivError::PermissionAuthoritySealed);
}
Ok(()) Ok(())
} }
fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> { fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> {
let stored: String = connection.query_row( let stored: String = connection.query_row(
"SELECT workspace_id FROM store_scope WHERE singleton=1", "SELECT workspace_id FROM store_scope WHERE singleton=1",
[], |row| row.get(0), [],
|row| row.get(0),
)?; )?;
if stored != scope { return Err(SubjektivError::PermissionTransferConflict); } if stored != scope {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(()) Ok(())
} }
fn require_receipt_identity(receipt: &PermissionTransferReceipt, expected: &DatabaseIdentity) -> Result<()> { fn require_receipt_identity(
if receipt.snapshot.identity != *expected { return Err(SubjektivError::PermissionTransferConflict); } receipt: &PermissionTransferReceipt,
expected: &DatabaseIdentity,
) -> Result<()> {
if receipt.snapshot.identity != *expected {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(()) Ok(())
} }
fn retained_receipt(connection: &Connection) -> Result<Option<PermissionTransferReceipt>> { fn retained_receipt(connection: &Connection) -> Result<Option<PermissionTransferReceipt>> {
if !has_table(connection, "permission_source_transfer_receipt")? { return Ok(None); } if !has_table(connection, "permission_source_transfer_receipt")? {
return Ok(None);
}
let raw: Option<String> = connection.query_row( let raw: Option<String> = connection.query_row(
"SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1", "SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1",
[], |row| row.get(0), [], |row| row.get(0),
).optional()?; ).optional()?;
let receipt: Option<PermissionTransferReceipt> = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; let receipt: Option<PermissionTransferReceipt> =
if receipt.is_some() && !is_sealed(connection)? { return Err(SubjektivError::PermissionTransferConflict); } raw.map(|raw| serde_json::from_str(&raw)).transpose()?;
if receipt.is_some() && !is_sealed(connection)? {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(receipt) Ok(receipt)
} }
fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> Result<PermissionTransferSnapshot> { fn export_in_connection(
connection: &Connection,
identity: DatabaseIdentity,
) -> Result<PermissionTransferSnapshot> {
let orphaned: bool = connection.query_row( 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)", "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), [], |row| row.get(0),
)?; )?;
if orphaned { return Err(SubjektivError::PermissionTransferConflict); } if orphaned {
return Err(SubjektivError::PermissionTransferConflict);
}
let mut statement = connection.prepare( let mut statement = connection.prepare(
"SELECT s.subject_id,p.change_id,c.record_json FROM subjects s "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_permissions p ON p.subject_id=s.subject_id
@@ -224,20 +386,31 @@ fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) ->
validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?; validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?;
let change: Option<String> = row.get(1)?; let change: Option<String> = row.get(1)?;
let raw: Option<String> = row.get(2)?; let raw: Option<String> = row.get(2)?;
let permissions: Option<SubjectPermissions> = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; let permissions: Option<SubjectPermissions> =
raw.map(|raw| serde_json::from_str(&raw)).transpose()?;
if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() { if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() {
return Err(SubjektivError::PermissionTransferConflict); return Err(SubjektivError::PermissionTransferConflict);
} }
if let Some(source) = &permissions { if let Some(source) = &permissions {
validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?; validate_authorization_identifier(&source.change_id)
.map_err(SubjektivError::InvalidRecord)?;
validate_scope(&identity.scope_id, &source.execution)?; validate_scope(&identity.scope_id, &source.execution)?;
validate_scope(&identity.scope_id, &source.delegation)?; validate_scope(&identity.scope_id, &source.delegation)?;
} }
sources.push(PermissionSourceExport { subject_id, permissions }); sources.push(PermissionSourceExport {
subject_id,
permissions,
});
} }
let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?) let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?)
.iter().map(|byte| format!("{byte:02x}")).collect(); .iter()
Ok(PermissionTransferSnapshot { identity, sources, export_digest }) .map(|byte| format!("{byte:02x}"))
.collect();
Ok(PermissionTransferSnapshot {
identity,
sources,
export_digest,
})
} }
#[cfg(test)] #[cfg(test)]
+685 -107
View File
@@ -1,22 +1,37 @@
//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim. //! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim.
use super::*; use super::*;
use crate::tests::{open_store, role}; use crate::tests::{open_store, role};
use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant}; use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant,
};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::{Barrier, Mutex}; use std::sync::{Barrier, Mutex};
fn read_permission() -> ScopedPermission { fn read_permission() -> ScopedPermission {
ScopedPermission { ScopedPermission {
operation: "file.read".into(), operation: "file.read".into(),
resource: PermissionResource::Workdir { working_directory_id: "wd".into(), path: ".".into(), recursive: true }, resource: PermissionResource::Workdir {
working_directory_id: "wd".into(),
path: ".".into(),
recursive: true,
},
} }
} }
fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: Vec<ScopedPermission>) -> (SubjectPermissionsUpdate, SubjectPermissions) { fn replace(
store: &SubjektivStore,
subject: &str,
operation: &str,
delegation: Vec<ScopedPermission>,
) -> (SubjectPermissionsUpdate, SubjectPermissions) {
let update = SubjectPermissionsUpdate { let update = SubjectPermissionsUpdate {
operation_id: operation.into(), operation_id: operation.into(),
expected_change_id: store.subject_permissions(subject).unwrap().map(|source| source.change_id), expected_change_id: store
execution: vec![], delegation, .subject_permissions(subject)
.unwrap()
.map(|source| source.change_id),
execution: vec![],
delegation,
}; };
let source = store.set_subject_permissions(subject, &update).unwrap(); let source = store.set_subject_permissions(subject, &update).unwrap();
(update, source) (update, source)
@@ -24,25 +39,52 @@ fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: V
fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant {
WorkerExecutionGrant { WorkerExecutionGrant {
subject_id: subject.into(), source_change_id: source.into(), subject_id: subject.into(),
runtime_id: "runtime".into(), worker_id: worker.into(), source_change_id: source.into(),
origin_runtime_id: "runtime".into(), origin_worker_id: "host-authenticated-root".into(), runtime_id: "runtime".into(),
profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), worker_id: worker.into(),
permissions: vec![read_permission()], redelegation: vec![], 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<i64> { fn counts(store: &SubjektivStore) -> Vec<i64> {
store.database.try_with_connection::<_, SubjektivError>(|connection| { store
["subject_permissions", "subject_permission_changes", "subject_permission_receipts", "worker_execution_grants"] .database
.iter().map(|table| Ok(connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?)).collect() .try_with_connection::<_, SubjektivError>(|connection| {
}).unwrap() [
"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 { fn seal(store: &SubjektivStore) -> PermissionTransferReceipt {
let identity = store.database_identity().unwrap(); let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap();
store.seal_permission_sources(&identity, "server-request-fingerprint", &preview.export_digest).unwrap() store
.seal_permission_sources(
&identity,
"server-request-fingerprint",
&preview.export_digest,
)
.unwrap()
} }
#[test] #[test]
@@ -55,11 +97,14 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon
assert_eq!(identity.feature_id, native.feature); assert_eq!(identity.feature_id, native.feature);
assert_eq!(identity.physical_id, native.physical); assert_eq!(identity.physical_id, native.physical);
let encoded = serde_json::to_value(&identity).unwrap(); let encoded = serde_json::to_value(&identity).unwrap();
assert_eq!(encoded, serde_json::json!({ assert_eq!(
"scope_id": native.scope, encoded,
"feature_id": native.feature, serde_json::json!({
"physical_id": native.physical, "scope_id": native.scope,
})); "feature_id": native.feature,
"physical_id": native.physical,
})
);
let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap(); let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap();
let restored = decoded.storage_identity(); let restored = decoded.storage_identity();
assert_eq!(restored.scope, native.scope); assert_eq!(restored.scope, native.scope);
@@ -75,13 +120,36 @@ fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_compon
"physical" => wrong.physical_id.push_str("-different"), "physical" => wrong.physical_id.push_str("-different"),
_ => unreachable!(), _ => unreachable!(),
} }
assert!(matches!(store.preview_permission_transfer(&wrong), Err(SubjektivError::PermissionTransferConflict))); assert!(matches!(
assert!(matches!(store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); store.preview_permission_transfer(&wrong),
assert!(matches!(store.permission_transfer_transaction::<()>(&wrong, |_| { Err(SubjektivError::PermissionTransferConflict)
panic!("an identity mismatch must not enter the SQL transaction body") ));
}), Err(SubjektivError::PermissionTransferConflict))); assert!(matches!(
store.seal_permission_sources(&wrong, "transfer", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.with_permission_authority_guard::<()>(&wrong, |_| {
panic!("an identity mismatch must not enter the guarded action")
}),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.permission_transfer_transaction::<()>(&wrong, |_| {
panic!("an identity mismatch must not enter the SQL transaction body")
}),
Err(SubjektivError::PermissionTransferConflict)
));
} }
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| has_table(
c,
"permission_source_transfer_receipt"
))
.unwrap()
);
} }
#[test] #[test]
@@ -91,48 +159,131 @@ fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_
let unset = store.create_subject(role()).unwrap().id; let unset = store.create_subject(role()).unwrap().id;
let empty = store.create_subject(role()).unwrap().id; let empty = store.create_subject(role()).unwrap().id;
let limited = store.create_subject(role()).unwrap().id; let limited = store.create_subject(role()).unwrap().id;
store.database.try_transaction::<_, SubjektivError>(|tx| { store
tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&unset])?; .database
Ok(()) .try_transaction::<_, SubjektivError>(|tx| {
}).unwrap(); tx.execute(
"DELETE FROM subject_permissions WHERE subject_id=?1",
[&unset],
)?;
Ok(())
})
.unwrap();
let (_, empty_source) = replace(&store, &empty, "empty", vec![]); let (_, empty_source) = replace(&store, &empty, "empty", vec![]);
let (old_update, limited_source) = replace(&store, &limited, "limited", vec![read_permission()]); let (old_update, limited_source) =
replace(&store, &limited, "limited", vec![read_permission()]);
let grant = child(&limited, &limited_source.change_id, "historical-child"); let grant = child(&limited, &limited_source.change_id, "historical-child");
store.bind_worker_execution_grant(&grant).unwrap(); store.bind_worker_execution_grant(&grant).unwrap();
let before = counts(&store); let before = counts(&store);
let identity = store.database_identity().unwrap(); let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap();
assert_eq!(preview.sources.len(), 3); assert_eq!(preview.sources.len(), 3);
assert!(preview.sources.windows(2).all(|pair| pair[0].subject_id < pair[1].subject_id)); assert!(
assert_eq!(preview.sources.iter().find(|row| row.subject_id == unset).unwrap().permissions, None); preview
assert_eq!(preview.sources.iter().find(|row| row.subject_id == empty).unwrap().permissions, Some(empty_source)); .sources
assert_eq!(preview.sources.iter().find(|row| row.subject_id == limited).unwrap().permissions, Some(limited_source.clone())); .windows(2)
assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); .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. // 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()); assert!(
let receipt = store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap(); !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!(receipt.snapshot, preview);
assert_eq!(counts(&store), before); assert_eq!(counts(&store), before);
assert_eq!(store.worker_execution_grant("runtime", "historical-child").unwrap(), Some(grant.clone())); assert_eq!(
store
.worker_execution_grant("runtime", "historical-child")
.unwrap(),
Some(grant.clone())
);
store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority. 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!(
assert!(matches!(store.set_managed_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed))); store.set_subject_permissions(&limited, &old_update),
assert!(matches!(store.seal_permission_sources(&identity, "different", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); Err(SubjektivError::PermissionAuthoritySealed)
assert!(matches!(store.seal_permission_sources(&identity, "transfer", "different-export"), Err(SubjektivError::PermissionTransferConflict))); ));
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(); let mut wrong_identity = identity.clone();
wrong_identity.scope_id = "workspace-b".into(); wrong_identity.scope_id = "workspace-b".into();
assert!(matches!(store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); assert!(matches!(
assert!(matches!(store.preview_permission_transfer(&wrong_identity), Err(SubjektivError::PermissionTransferConflict))); 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); drop(store);
scope.shutdown().unwrap(); scope.shutdown().unwrap();
drop(scope); drop(scope);
drop(manager); drop(manager);
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert_eq!(identity, store.database_identity().unwrap()); assert_eq!(identity, store.database_identity().unwrap());
assert_eq!(receipt, store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap()); assert_eq!(
assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap()); 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_eq!(counts(&store), before);
assert!(matches!(store.subject_permissions(&limited), Err(SubjektivError::AuthorizationDenied(_)))); assert!(matches!(
store.subject_permissions(&limited),
Err(SubjektivError::AuthorizationDenied(_))
));
} }
#[test] #[test]
@@ -143,13 +294,31 @@ fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema()
let identity = store.database_identity().unwrap(); let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap();
replace(&store, &subject, "old-writer-after-preview", vec![]); replace(&store, &subject, "old-writer-after-preview", vec![]);
assert!(matches!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); assert!(matches!(
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); store.seal_permission_sources(&identity, "transfer", &preview.export_digest),
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap()); 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![]); replace(&store, &subject, "still-writable", vec![]);
let fresh = store.preview_permission_transfer(&identity).unwrap(); let fresh = store.preview_permission_transfer(&identity).unwrap();
assert_ne!(fresh.export_digest, preview.export_digest); assert_ne!(fresh.export_digest, preview.export_digest);
store.seal_permission_sources(&identity, "new-request", &fresh.export_digest).unwrap(); store
.seal_permission_sources(&identity, "new-request", &fresh.export_digest)
.unwrap();
} }
#[test] #[test]
@@ -159,14 +328,32 @@ fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() {
let subject = store.create_subject(role()).unwrap().id; let subject = store.create_subject(role()).unwrap().id;
let identity = store.database_identity().unwrap(); let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap(); let preview = store.preview_permission_transfer(&identity).unwrap();
store.database.transaction(crate::storage_formats::install_permission_transfer).unwrap(); store
.database
.transaction(crate::storage_formats::install_permission_transfer)
.unwrap();
store.database.try_with_connection::<_, SubjektivError>(|connection| { 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;")?; 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(()) Ok(())
}).unwrap(); }).unwrap();
assert!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest).is_err()); assert!(
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap()); store
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)).unwrap().is_none()); .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![]); replace(&store, &subject, "writer-after-rollback", vec![]);
drop(store); drop(store);
scope.shutdown().unwrap(); scope.shutdown().unwrap();
@@ -182,29 +369,70 @@ struct Canonical {
calls: Mutex<Vec<(String, String)>>, calls: Mutex<Vec<(String, String)>>,
} }
impl CanonicalPermissionSource for Canonical { impl CanonicalPermissionSource for Canonical {
fn subject_permissions(&self, workspace: &str, subject: &str) -> Result<Option<SubjectPermissions>> { fn subject_permissions(
self.calls.lock().unwrap().push((workspace.into(), subject.into())); &self,
if workspace != "workspace-a" { return Err(denied("foreign Workspace")); } workspace: &str,
self.sources.get(subject).cloned().ok_or_else(|| denied("canonical source unavailable")) subject: &str,
) -> Result<Option<SubjectPermissions>> {
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] #[test]
fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() { fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy()
{
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id; let subject = store.create_subject(role()).unwrap().id;
let (_, old) = replace(&store, &subject, "old", vec![read_permission()]); let (_, old) = replace(&store, &subject, "old", vec![read_permission()]);
let receipt = seal(&store); let receipt = seal(&store);
assert!(matches!(store.subject_permissions(&subject), Err(SubjektivError::AuthorizationDenied(_)))); assert!(matches!(
assert!(matches!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), Err(SubjektivError::AuthorizationDenied(_)))); 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 before = counts(&store);
let created = store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap(); let created = store
assert_eq!(created, store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap()); .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(); let non_idempotent = store.create_subject(role()).unwrap();
assert!(store.subject_permission_enrollment(&created.id).unwrap().is_some()); assert!(
assert!(store.subject_permission_enrollment(&non_idempotent.id).unwrap().is_some()); store
assert!(store.subject_permission_enrollment(&subject).unwrap().is_none()); .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); assert_eq!(counts(&store), before);
store.database.try_with_connection::<_, SubjektivError>(|connection| { store.database.try_with_connection::<_, SubjektivError>(|connection| {
for id in [&created.id, &non_idempotent.id] { for id in [&created.id, &non_idempotent.id] {
@@ -215,35 +443,103 @@ fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_
} }
Ok(()) Ok(())
}).unwrap(); }).unwrap();
let canonical = SubjectPermissions { change_id: "canonical-change".into(), execution: vec![], delegation: vec![read_permission()] }; let canonical = SubjectPermissions {
change_id: "canonical-change".into(),
execution: vec![],
delegation: vec![read_permission()],
};
let provider = Arc::new(Canonical { let provider = Arc::new(Canonical {
sources: BTreeMap::from([(subject.clone(), Some(canonical.clone())), (created.id.clone(), None)]), sources: BTreeMap::from([
(subject.clone(), Some(canonical.clone())),
(created.id.clone(), None),
]),
calls: Mutex::new(vec![]), calls: Mutex::new(vec![]),
}); });
let store = store.with_canonical_permission_source(provider.clone()); let store = store.with_canonical_permission_source(provider.clone());
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(canonical.clone())); assert_eq!(
store.subject_permissions(&subject).unwrap(),
Some(canonical.clone())
);
assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default. assert_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.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()); assert!(
store.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")).unwrap(); store
assert_eq!(store.list_subject_worker_execution_grants(&subject).unwrap(), vec![child(&subject, &canonical.change_id, "canonical-child")]); .bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source"))
assert_eq!(store.worker_execution_grant("runtime", "canonical-child").unwrap(), Some(child(&subject, &canonical.change_id, "canonical-child"))); .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 [ for sql in [
"INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants", "INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants",
"UPDATE canonical_worker_execution_grants SET source_change_id='forged'", "UPDATE canonical_worker_execution_grants SET source_change_id='forged'",
"DELETE FROM canonical_worker_execution_grants", "DELETE FROM canonical_worker_execution_grants",
] { ] {
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err()); assert!(
store
.database
.try_with_connection::<_, SubjektivError>(|c| {
c.execute_batch(sql)?;
Ok(())
})
.is_err()
);
} }
assert!(provider.calls.lock().unwrap().iter().all(|(workspace, id)| workspace == "workspace-a" && [subject.as_str(), created.id.as_str(), non_idempotent.id.as_str()].contains(&id.as_str()))); assert!(
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); provider
store.begin_subject_deletion(&subject, "delete-canonical-subject").unwrap(); .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(); let workers = store.deletion_subject_workers(&subject).unwrap();
assert!(workers.contains(&("runtime".into(), "canonical-child".into()))); assert!(workers.contains(&("runtime".into(), "canonical-child".into())));
assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into()))); assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into())));
assert!(store.finish_subject_deletion(&subject, "delete-canonical-subject").unwrap().completed); assert!(
assert!(store.worker_execution_grant("runtime", "canonical-child").is_err()); store
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); .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] #[test]
@@ -255,10 +551,18 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r
let receipt = seal(&store); let receipt = seal(&store);
// Reacquire a managed store handle; tests must not infer/open physical storage paths. // Reacquire a managed store handle; tests must not infer/open physical storage paths.
let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap(); let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap();
assert!(old_writer.database.try_transaction::<_, SubjektivError>(|tx| { assert!(
tx.execute("UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", [&subject])?; old_writer
Ok(()) .database
}).is_err()); .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); drop(old_writer);
for sql in [ for sql in [
"INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions", "INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions",
@@ -278,7 +582,16 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r
"UPDATE permission_source_transfer_receipt SET receipt_json='{}'", "UPDATE permission_source_transfer_receipt SET receipt_json='{}'",
"DELETE FROM permission_source_transfer_receipt", "DELETE FROM permission_source_transfer_receipt",
] { ] {
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err(), "{sql}"); assert!(
store
.database
.try_with_connection::<_, SubjektivError>(|c| {
c.execute_batch(sql)?;
Ok(())
})
.is_err(),
"{sql}"
);
} }
store.database.try_transaction::<_, SubjektivError>(|tx| { 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("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?;
@@ -287,8 +600,22 @@ fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_r
tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?; tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?;
Ok(()) Ok(())
}).unwrap(); }).unwrap();
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot); assert_eq!(
assert_eq!(store.seal_permission_sources(&receipt.snapshot.identity, &receipt.transfer_id, &receipt.snapshot.export_digest).unwrap(), receipt); 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] #[test]
@@ -304,16 +631,29 @@ fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_expor
let start = barrier.clone(); let start = barrier.clone();
let thread = std::thread::spawn(move || { let thread = std::thread::spawn(move || {
start.wait(); start.wait();
writer.set_subject_permissions(&subject, &SubjectPermissionsUpdate { writer.set_subject_permissions(
operation_id: "racing-old-writer".into(), expected_change_id: Some(initial.change_id), execution: vec![], delegation: vec![], &subject,
}) &SubjectPermissionsUpdate {
operation_id: "racing-old-writer".into(),
expected_change_id: Some(initial.change_id),
execution: vec![],
delegation: vec![],
},
)
}); });
barrier.wait(); barrier.wait();
let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest); let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest);
let written = thread.join().unwrap(); let written = thread.join().unwrap();
match (sealed, written) { match (sealed, written) {
(Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => assert_eq!(receipt.snapshot, preview), (Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => {
(Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(!store.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()), 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:?}"), other => panic!("unexpected serialized outcomes: {other:?}"),
} }
} }
@@ -325,20 +665,241 @@ fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback()
let subject = store.create_subject(role()).unwrap().id; let subject = store.create_subject(role()).unwrap().id;
seal(&store); seal(&store);
let provider = Arc::new(Canonical { let provider = Arc::new(Canonical {
sources: BTreeMap::from([(subject.clone(), Some(SubjectPermissions { sources: BTreeMap::from([(
change_id: "foreign-source".into(), subject.clone(),
execution: vec![ScopedPermission { Some(SubjectPermissions {
operation: "file.read".into(), change_id: "foreign-source".into(),
resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() }, execution: vec![ScopedPermission {
}], operation: "file.read".into(),
delegation: vec![read_permission()], resource: PermissionResource::Workspace {
}))]), workspace_id: "workspace-b".into(),
},
}],
delegation: vec![read_permission()],
}),
)]),
calls: Mutex::new(vec![]), calls: Mutex::new(vec![]),
}); });
let store = store.with_canonical_permission_source(provider); let store = store.with_canonical_permission_source(provider);
assert!(store.subject_permissions(&subject).is_err()); assert!(store.subject_permissions(&subject).is_err());
assert!(store.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")).is_err()); assert!(
assert!(store.worker_execution_grant("runtime", "foreign-child").unwrap().is_none()); 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<std::result::Result<(), u16>> =
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] #[test]
@@ -351,7 +912,24 @@ fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_works
let b = store_b.database_identity().unwrap(); let b = store_b.database_identity().unwrap();
assert_ne!(a, b); assert_ne!(a, b);
let preview = store_a.preview_permission_transfer(&a).unwrap(); let preview = store_a.preview_permission_transfer(&a).unwrap();
assert!(matches!(store_b.preview_permission_transfer(&a), Err(SubjektivError::PermissionTransferConflict))); assert!(matches!(
assert!(matches!(store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict))); store_b.preview_permission_transfer(&a),
assert!(!store_b.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()); 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()
);
} }
+94 -30
View File
@@ -586,46 +586,96 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
published_fixture(temp.path(), format); published_fixture(temp.path(), format);
let (scope, db) = open(temp.path(), REGISTRATION); let (scope, db) = open(temp.path(), REGISTRATION);
let saved = db.with_connection(|connection| Ok(receipts(connection))).unwrap(); let saved = db
let old_seal = db.with_connection(|connection| { .with_connection(|connection| Ok(receipts(connection)))
if format == Format::Permission18 { .unwrap();
Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?)) let old_seal = db
} else { Ok(None) } .with_connection(|connection| {
}).unwrap(); 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.transaction(install_permission_transfer).unwrap();
db.with_connection(|connection| { db.with_connection(|connection| {
assert_eq!(receipts(connection), saved); assert_eq!(receipts(connection), saved);
assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); assert_eq!(
domain_shape(connection)?,
reference_shape_with_transfer(format, true)?
);
if let Some(raw) = &old_seal { if let Some(raw) = &old_seal {
assert_eq!(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?, *raw); assert_eq!(
connection.query_row(
"SELECT export_json FROM permission_authority_seal",
[],
|row| row.get::<_, String>(0)
)?,
*raw
);
} }
assert_foreign_keys(connection); assert_foreign_keys(connection);
Ok(()) Ok(())
}).unwrap(); })
.unwrap();
let store = SubjektivStore::from_database(db.clone()).unwrap(); let store = SubjektivStore::from_database(db.clone()).unwrap();
if old_seal.is_some() { if old_seal.is_some() {
let identity = store.database_identity().unwrap(); let identity = store.database_identity().unwrap();
assert!(matches!(store.preview_permission_transfer(&identity), Err(crate::SubjektivError::PermissionAuthoritySealed))); assert!(matches!(
assert!(matches!(store.seal_permission_sources(&identity, "new-transfer", "export"), Err(crate::SubjektivError::PermissionAuthoritySealed))); 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; struct CanonicalEmpty;
impl crate::CanonicalPermissionSource for CanonicalEmpty { impl crate::CanonicalPermissionSource for CanonicalEmpty {
fn subject_permissions(&self, workspace: &str, _subject: &str) -> crate::Result<Option<server_api::authorization::SubjectPermissions>> { fn subject_permissions(
&self,
workspace: &str,
_subject: &str,
) -> crate::Result<Option<server_api::authorization::SubjectPermissions>>
{
assert_eq!(workspace, SCOPE); assert_eq!(workspace, SCOPE);
Ok(Some(server_api::authorization::SubjectPermissions { Ok(Some(server_api::authorization::SubjectPermissions {
change_id: "canonical-source".into(), execution: vec![], delegation: vec![], change_id: "canonical-source".into(),
execution: vec![],
delegation: vec![],
})) }))
} }
} }
let connected = store.clone().with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty)); let connected = store
.clone()
.with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty));
let subject = connected.list_subjects(1).unwrap().items[0].id.clone(); let subject = connected.list_subjects(1).unwrap().items[0].id.clone();
assert_eq!(connected.subject_permissions(&subject).unwrap().unwrap().change_id, "canonical-source"); assert_eq!(
connected.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant { connected
subject_id: subject, source_change_id: "canonical-source".into(), .subject_permissions(&subject)
runtime_id: "runtime".into(), worker_id: "canonical-child".into(), .unwrap()
origin_runtime_id: "runtime".into(), origin_worker_id: "root".into(), .unwrap()
profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(), .change_id,
permissions: vec![], redelegation: vec![], "canonical-source"
}).unwrap(); );
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(store);
drop(db); drop(db);
@@ -633,10 +683,14 @@ fn permission_transfer_extension_reopens_on_every_published_family_without_relab
let (scope, db) = open(temp.path(), REGISTRATION); let (scope, db) = open(temp.path(), REGISTRATION);
db.with_connection(|connection| { db.with_connection(|connection| {
assert_eq!(receipts(connection), saved); assert_eq!(receipts(connection), saved);
assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?); assert_eq!(
domain_shape(connection)?,
reference_shape_with_transfer(format, true)?
);
assert_foreign_keys(connection); assert_foreign_keys(connection);
Ok(()) Ok(())
}).unwrap(); })
.unwrap();
drop(db); drop(db);
scope.shutdown().unwrap(); scope.shutdown().unwrap();
} }
@@ -647,7 +701,9 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let (scope, db) = open(temp.path(), REGISTRATION); let (scope, db) = open(temp.path(), REGISTRATION);
let before_shape = db.with_connection(domain_shape).unwrap(); let before_shape = db.with_connection(domain_shape).unwrap();
let before_rows = db.with_connection(|connection| Ok(all_rows(connection))).unwrap(); let before_rows = db
.with_connection(|connection| Ok(all_rows(connection)))
.unwrap();
let result = db.transaction(|tx| { let result = db.transaction(|tx| {
install_permission_transfer(tx)?; install_permission_transfer(tx)?;
tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?; tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?;
@@ -658,16 +714,24 @@ fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reope
assert_eq!(domain_shape(connection)?, before_shape); assert_eq!(domain_shape(connection)?, before_shape);
assert_eq!(all_rows(connection), before_rows); assert_eq!(all_rows(connection), before_rows);
Ok(()) Ok(())
}).unwrap(); })
.unwrap();
db.transaction(install_permission_transfer).unwrap(); db.transaction(install_permission_transfer).unwrap();
db.with_connection(|connection| { db.with_connection(|connection| {
connection.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?; connection
.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?;
Ok(()) Ok(())
}).unwrap(); })
.unwrap();
drop(db); drop(db);
scope.shutdown().unwrap(); scope.shutdown().unwrap();
let scope = feature_storage::FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); let scope = feature_storage::FeatureStorage::new(temp.path())
.scope(SCOPE)
.unwrap();
let registered = scope.register(REGISTRATION).unwrap(); let registered = scope.register(REGISTRATION).unwrap();
assert!(matches!(scope.open(&registered), Err(FeatureStorageError::MigrationHistory { .. }))); assert!(matches!(
scope.open(&registered),
Err(FeatureStorageError::MigrationHistory { .. })
));
scope.shutdown().unwrap(); scope.shutdown().unwrap();
} }
+19 -6
View File
@@ -86,7 +86,9 @@ pub(super) fn validate(
[], |row| row.get::<_, bool>(0), [], |row| row.get::<_, bool>(0),
)?; )?;
if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) { if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) {
return Err(incompatible("permission transfer requires complete common 1..17")); return Err(incompatible(
"permission transfer requires complete common 1..17",
));
} }
let expected = reference_shape_with_transfer(format, transfer)?; let expected = reference_shape_with_transfer(format, transfer)?;
let actual = domain_shape(tx)?; let actual = domain_shape(tx)?;
@@ -127,7 +129,10 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
reference_shape_with_transfer(format, false) reference_shape_with_transfer(format, false)
} }
fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_storage::Result<Vec<SchemaObject>> { fn reference_shape_with_transfer(
format: Format,
transfer: bool,
) -> feature_storage::Result<Vec<SchemaObject>> {
// Cache reference shapes only; every target is reread under its caller's transaction. // Cache reference shapes only; every target is reread under its caller's transaction.
static SHAPES: [OnceLock<Vec<SchemaObject>>; 50] = [const { OnceLock::new() }; 50]; static SHAPES: [OnceLock<Vec<SchemaObject>>; 50] = [const { OnceLock::new() }; 50];
let index = match format { let index = match format {
@@ -208,8 +213,11 @@ fn reference_shape_with_transfer(format: Format, transfer: bool) -> feature_stor
/// Existing published seals and their serialized exports are never rewritten or relabelled. /// Existing published seals and their serialized exports are never rewritten or relabelled.
/// The FeatureDatabase guard and caller's transaction cover validation and installation. /// The FeatureDatabase guard and caller's transaction cover validation and installation.
pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> { 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 mut statement = tx.prepare(&format!(
let applied = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? "SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"
))?;
let applied = statement
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<(u32, String)>, _>>()?; .collect::<std::result::Result<Vec<(u32, String)>, _>>()?;
let format = recognize(&applied)?; let format = recognize(&applied)?;
if matches!(format, Format::Common(count) if count < COMMON_COUNT) { if matches!(format, Format::Common(count) if count < COMMON_COUNT) {
@@ -226,7 +234,10 @@ pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_stora
validate(tx, &applied) validate(tx, &applied)
} }
fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> feature_storage::Result<()> { fn install_transfer_shape(
tx: &Transaction<'_>,
published_permission: bool,
) -> feature_storage::Result<()> {
if !published_permission { if !published_permission {
tx.execute_batch(PERMISSION_SEAL_SQL)?; tx.execute_batch(PERMISSION_SEAL_SQL)?;
} }
@@ -237,7 +248,9 @@ fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> f
for action in ["INSERT", "UPDATE", "DELETE"] { for action in ["INSERT", "UPDATE", "DELETE"] {
let deletion_exception = if action == "DELETE" { let deletion_exception = if action == "DELETE" {
" AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)" " AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)"
} else { "" }; } else {
""
};
tx.execute_batch(&format!( tx.execute_batch(&format!(
"CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table} "CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table}
WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception} WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception}
+4 -1
View File
@@ -21,7 +21,10 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
impl SubjektivStore { impl SubjektivStore {
/// Creation-only enrollment evidence for the canonical domain. Never a current permission /// 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. /// 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<Option<SubjectPermissions>> { pub fn subject_permission_enrollment(
&self,
subject_id: &str,
) -> Result<Option<SubjectPermissions>> {
self.database.try_transaction(|tx| { self.database.try_transaction(|tx| {
require_subject(tx, subject_id)?; require_subject(tx, subject_id)?;
if !permissions::transfer::has_table(tx, "permission_authority_seal")? { if !permissions::transfer::has_table(tx, "permission_authority_seal")? {
@@ -6,54 +6,102 @@
//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes //! exact names. No dotted-prefix children are inferred. Profiles/provider scopes
//! and actual operation authorization remain independent of this metadata load. //! and actual operation authorization remain independent of this metadata load.
use std::collections::BTreeMap;
use feature_permissions::{FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions,
PermissionDefinition, PermissionHost, PermissionKey, PermissionReference, PermissionSetId,
SetContribution, WorkspaceId};
use super::super::FeatureInstallError; use 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)] = &[ const OWNERS: &[(&str, &str)] = &[
("filesystem", "builtin:workdir"), ("bash", "builtin:bash"), ("filesystem", "builtin:workdir"),
("workdir", "builtin:workdir"), ("worker", "builtin:worker"), ("bash", "builtin:bash"),
("subworker", "builtin:sub-worker-control"), ("ticket", "builtin:ticket"), ("workdir", "builtin:workdir"),
("objective", "builtin:objective"), ("merge_request", "builtin:merge-request"), ("worker", "builtin:worker"),
("repository", "builtin:repository"), ("drive", "builtin:drive"), ("subworker", "builtin:sub-worker-control"),
("workspace_config", "builtin:workspace-config"), ("memory", "builtin:memory"), ("ticket", "builtin:ticket"),
("journal", "builtin:journal-recording"), ("chat", "builtin:subjektiv-chat"), ("objective", "builtin:objective"),
("session", "builtin:session-explore"), ("web", "builtin:web"), ("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"), ("notification", "builtin:notification"),
]; ];
pub fn builtin_permission_host(workspace: WorkspaceId) -> Result<PermissionHost, FeatureInstallError> { pub fn builtin_permission_host(
workspace: WorkspaceId,
) -> Result<PermissionHost, FeatureInstallError> {
let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string()); let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string());
let mut host = PermissionHost::new(workspace); let mut host = PermissionHost::new(workspace);
let mut features = BTreeMap::new(); let mut features = BTreeMap::new();
let operations = server_api::authorization::subject_permission_operations(); // MCP's scalar startup permission is the retained T-787 exception, not a
// payload-free operation key. Do not invent mcp.* authority or a Set for it.
// Legacy migration retains it as an explicit-configuration-required diagnostic.
let operations = server_api::authorization::subject_permission_operations()
.into_iter()
.filter(|operation| operation.operation != "mcp")
.collect::<Vec<_>>();
for (namespace, owner) in OWNERS { for (namespace, owner) in OWNERS {
let feature = match features.get(owner) { let feature = match features.get(owner) {
Some(feature) => feature, Some(feature) => feature,
None => { None => {
let feature = host.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?).map_err(|e| invalid(&e))?; let feature = host
.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?)
.map_err(|e| invalid(&e))?;
features.entry(owner).or_insert(feature) features.entry(owner).or_insert(feature)
}, }
}; };
let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?; let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?;
let handle = host.bind_namespace(feature, id.clone()).map_err(|e| invalid(&e))?; let handle = host
let permissions = operations.iter().filter(|operation| operation.operation.split('.').next() == Some(namespace)) .bind_namespace(feature, id.clone())
.map(|operation| Ok(PermissionDefinition { .map_err(|e| invalid(&e))?;
key:PermissionKey::parse(operation.operation.clone()).map_err(|e| invalid(&e))?, let permissions = operations
description:operation.description.clone(),children:vec![], .iter()
})).collect::<Result<Vec<_>,FeatureInstallError>>()?; .filter(|operation| operation.operation.split('.').next() == Some(namespace))
let keys = permissions.iter().map(|definition| definition.key.clone()).collect(); .map(|operation| {
host.declare(&handle,NamespaceDeclaration { Ok(PermissionDefinition {
permissions, key: PermissionKey::parse(operation.operation.clone())
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))?,
}).map_err(|e|invalid(&e))?; description: operation.description.clone(),
children: vec![],
})
})
.collect::<Result<Vec<_>, 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. // A new builtin operation needs an intentional namespace ownership decision.
for operation in operations { for operation in operations {
if !OWNERS.iter().any(|(namespace,_)| operation.operation.split('.').next() == Some(*namespace)) { if !OWNERS
return Err(FeatureInstallError::InvalidDescriptor(format!("builtin operation has no namespace owner: {}",operation.operation))); .iter()
.any(|(namespace, _)| operation.operation.split('.').next() == Some(*namespace))
{
return Err(FeatureInstallError::InvalidDescriptor(format!(
"builtin operation has no namespace owner: {}",
operation.operation
)));
} }
} }
Ok(host) Ok(host)
@@ -64,15 +112,23 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn builtin_keys_are_complete_exact_and_have_no_inferred_children() { fn builtin_keys_are_complete_exact_and_have_no_inferred_children() {
let host=builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap(); let host = builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap();
let catalog=host.catalog(); let catalog = host.catalog();
let mut keys=std::collections::BTreeSet::new(); let mut keys = std::collections::BTreeSet::new();
for namespace in catalog.namespaces { for namespace in catalog.namespaces {
for permission in namespace.declaration.permissions { for permission in namespace.declaration.permissions {
assert!(permission.children.is_empty()); assert!(permission.children.is_empty());
assert!(keys.insert(permission.key.as_str().to_owned())); assert!(keys.insert(permission.key.as_str().to_owned()));
} }
} }
assert_eq!(keys,server_api::authorization::subject_permission_operations().into_iter().map(|o|o.operation).collect()); assert!(!keys.contains("mcp"));
assert_eq!(
keys,
server_api::authorization::subject_permission_operations()
.into_iter()
.filter(|operation| operation.operation != "mcp")
.map(|o| o.operation)
.collect()
);
} }
} }
+1
View File
@@ -9,3 +9,4 @@ Backend that manages persistent Workspace resources and authorization and provid
- [`hosts`] / [`worker_source`] — Runtime delegation and request source verification - [`hosts`] / [`worker_source`] — Runtime delegation and request source verification
- [`backend_job`] — Immutable input, attempt, and structured result boundaries - [`backend_job`] — Immutable input, attempt, and structured result boundaries
- [`workspace_catalog`] — Workspace creation and replay contracts - [`workspace_catalog`] — Workspace creation and replay contracts
- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments
+252 -60
View File
@@ -5,6 +5,53 @@
//! after seal leaves an explicit, resumable transfer, never a legacy fallback. //! after seal leaves an explicit, resumable transfer, never a legacy fallback.
//! Normal saves never touch the old database. Object preparation, source, //! Normal saves never touch the old database. Object preparation, source,
//! projection, policy, editing head and receipt commit in one caller-owned transaction. //! 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 std::collections::{BTreeMap, BTreeSet};
@@ -78,13 +125,26 @@ pub(crate) struct CanonicalConfigurationSource {
} }
impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource { impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource {
fn subject_permissions(&self, workspace: &str, subject: &str) -> subjektiv::Result<Option<server_api::authorization::SubjectPermissions>> { fn subject_permissions(
&self,
workspace: &str,
subject: &str,
) -> subjektiv::Result<Option<server_api::authorization::SubjectPermissions>> {
if self.host.catalog().workspace.as_str() != workspace { if self.host.catalog().workspace.as_str() != workspace {
return Err(subjektiv::SubjektivError::PermissionTransferConflict); return Err(subjektiv::SubjektivError::PermissionTransferConflict);
} }
self.store.adopted_subject_policy(workspace, subject) self.store
.and_then(|policy| policy.map(|policy| policy.legacy_read(&self.host)).transpose()) .adopted_subject_policy(workspace, subject)
.map_err(|error| subjektiv::SubjektivError::InvalidRecord(format!("canonical configuration policy unavailable: {error}"))) .and_then(|policy| {
policy
.map(|policy| policy.legacy_read(&self.host))
.transpose()
})
.map_err(|error| {
subjektiv::SubjektivError::InvalidRecord(format!(
"canonical configuration policy unavailable: {error}"
))
})
} }
} }
@@ -106,7 +166,8 @@ pub(crate) struct ConfigurationMigrationBaseline<'a> {
struct OwnerScope<'a>(&'a str); struct OwnerScope<'a>(&'a str);
impl Authority for OwnerScope<'_> { impl Authority for OwnerScope<'_> {
fn allows(&self, key: &ScopeKey, access: Access) -> bool { fn allows(&self, key: &ScopeKey, access: Access) -> bool {
key.workspace == self.0 && key.target == "configuration" key.workspace == self.0
&& key.target == "configuration"
&& matches!(access, Access::Read | Access::Write) && matches!(access, Access::Read | Access::Write)
} }
} }
@@ -124,75 +185,137 @@ fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string())) serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string()))
} }
fn scope(workspace: &str) -> ScopeKey { fn scope(workspace: &str) -> ScopeKey {
ScopeKey { workspace: workspace.into(), target: "configuration".into() } ScopeKey {
workspace: workspace.into(),
target: "configuration".into(),
}
} }
fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> { fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> {
let allowed: bool = conn.query_row( let allowed: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", "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), params![workspace, account], |r| r.get(0),
)?; )?;
if !allowed { return Err(Error::WorkspacePermissionDenied("configuration management requires the Workspace owner".into())); } if !allowed {
return Err(Error::WorkspacePermissionDenied(
"configuration management requires the Workspace owner".into(),
));
}
Ok(()) Ok(())
} }
pub(crate) fn head(conn: &Connection, workspace: &str) -> Result<Option<HistoryId>> { pub(crate) fn head(conn: &Connection, workspace: &str) -> Result<Option<HistoryId>> {
let bytes: Option<Vec<u8>> = conn.query_row( let bytes: Option<Vec<u8>> = conn
"SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", [workspace], |r| r.get(0), .query_row(
).optional()?.flatten(); "SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'",
bytes.map(|bytes| bytes.try_into().map(HistoryId::from_bytes) [workspace],
.map_err(|_| Error::RegistryInconsistency("invalid configuration editing head".into()))).transpose() |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<String> { pub(crate) fn request_fingerprint(
server_api::authorization::validate_authorization_identifier(&request.operation_id).map_err(Error::InvalidInput)?; workspace: &str,
Ok(config_source::digest_bytes(json(&(workspace, account, request))?.as_bytes())) account: &str,
request: &ConfigurationAdoptionRequest,
) -> Result<String> {
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<bool> { pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result<bool> {
Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |row| row.get(0))?) Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",
[workspace],
|row| row.get(0),
)?)
} }
pub fn catalog_digest(host: &PermissionHost) -> Result<String> { pub fn catalog_digest(host: &PermissionHost) -> Result<String> {
Ok(config_source::digest_bytes(json(&host.catalog())?.as_bytes())) Ok(config_source::digest_bytes(
json(&host.catalog())?.as_bytes(),
))
} }
fn policies(conn: &Connection, workspace: &str) -> Result<BTreeMap<String, AdoptedSubjectPolicy>> { fn policies(conn: &Connection, workspace: &str) -> Result<BTreeMap<String, AdoptedSubjectPolicy>> {
let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?; 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(); let mut result = BTreeMap::new();
for row in query.query_map([workspace], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? { for row in query.query_map([workspace], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})? {
let (subject, raw) = row?; let (subject, raw) = row?;
let policy: AdoptedSubjectPolicy = decode(&raw)?; let policy: AdoptedSubjectPolicy = decode(&raw)?;
if subject != policy.source.subject_id { return Err(Error::RegistryInconsistency("Subject policy identity mismatch".into())); } if subject != policy.source.subject_id {
return Err(Error::RegistryInconsistency(
"Subject policy identity mismatch".into(),
));
}
result.insert(subject, policy); result.insert(subject, policy);
} }
Ok(result) Ok(result)
} }
fn receipt(conn: &Connection, workspace: &str, operation: &str) -> Result<Option<(String, ConfigurationAdoptionResult)>> { fn receipt(
conn: &Connection,
workspace: &str,
operation: &str,
) -> Result<Option<(String, ConfigurationAdoptionResult)>> {
let row: Option<(String,String)> = conn.query_row( 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", "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)?)), params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)),
).optional()?; ).optional()?;
row.map(|(fingerprint,result)| Ok((fingerprint,decode(&result)?))).transpose() row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?)))
.transpose()
} }
fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec<String> { fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec<String> {
before.entries.keys().chain(after.entries.keys()).collect::<BTreeSet<_>>().into_iter() before
.entries
.keys()
.chain(after.entries.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|path| before.entries.get(*path) != after.entries.get(*path)) .filter(|path| before.entries.get(*path) != after.entries.get(*path))
.map(|path| path.as_str().to_owned()).collect() .map(|path| path.as_str().to_owned())
.collect()
} }
fn changed_subjects(before: &BTreeMap<String,AdoptedSubjectPolicy>, after: &BTreeMap<String,AdoptedSubjectPolicy>) -> Vec<String> { fn changed_subjects(
before.keys().chain(after.keys()).collect::<BTreeSet<_>>().into_iter() before: &BTreeMap<String, AdoptedSubjectPolicy>,
.filter(|subject| before.get(*subject) != after.get(*subject)).cloned().collect() after: &BTreeMap<String, AdoptedSubjectPolicy>,
) -> Vec<String> {
before
.keys()
.chain(after.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|subject| before.get(*subject) != after.get(*subject))
.cloned()
.collect()
} }
impl SqliteWorkspaceStore { impl SqliteWorkspaceStore {
pub(crate) fn configuration_current(&self, workspace: &str, account: &str) -> Result<ConfigurationCurrent> { pub(crate) fn configuration_current(
&self,
workspace: &str,
account: &str,
) -> Result<ConfigurationCurrent> {
self.with_conn(|conn| { self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
require_owner(&tx, workspace, account)?; require_owner(&tx, workspace, account)?;
Ok(ConfigurationCurrent { Ok(ConfigurationCurrent {
state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?, state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| {
Error::InvalidInput("Workspace source not materialized".into())
})?,
history_head: head(&tx, workspace)?.map(|id| id.to_string()), history_head: head(&tx, workspace)?.map(|id| id.to_string()),
canonical: canonical(&tx, workspace)?, canonical: canonical(&tx, workspace)?,
policies: policies(&tx, workspace)?, policies: policies(&tx, workspace)?,
@@ -200,7 +323,12 @@ impl SqliteWorkspaceStore {
}) })
} }
pub(crate) fn configuration_request_result(&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result<Option<ConfigurationAdoptionResult>> { pub(crate) fn configuration_request_result(
&self,
workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
) -> Result<Option<ConfigurationAdoptionResult>> {
let fingerprint = request_fingerprint(workspace, account, request)?; let fingerprint = request_fingerprint(workspace, account, request)?;
self.with_conn(|conn| { self.with_conn(|conn| {
require_owner(conn, workspace, account)?; require_owner(conn, workspace, account)?;
@@ -214,31 +342,58 @@ impl SqliteWorkspaceStore {
/// Authentication supplies `account`; it is never accepted from an adoption DTO. /// Authentication supplies `account`; it is never accepted from an adoption DTO.
pub fn configuration_head(&self, workspace: &str, account: &str) -> Result<Option<String>> { pub fn configuration_head(&self, workspace: &str, account: &str) -> Result<Option<String>> {
self.with_conn(|conn| { require_owner(conn, workspace, account)?; Ok(head(conn,workspace)?.map(|id|id.to_string())) }) self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
Ok(head(conn, workspace)?.map(|id| id.to_string()))
})
} }
/// Result lookup is a read. A historical receipt never reactivates policy. /// Result lookup is a read. A historical receipt never reactivates policy.
pub fn configuration_adoption_result(&self, workspace: &str, account: &str, operation: &str) -> Result<Option<ConfigurationAdoptionResult>> { pub fn configuration_adoption_result(
self.with_conn(|conn| { require_owner(conn,workspace,account)?; Ok(receipt(conn,workspace,operation)?.map(|(_,result)|result)) }) &self,
workspace: &str,
account: &str,
operation: &str,
) -> Result<Option<ConfigurationAdoptionResult>> {
self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result))
})
} }
pub(crate) fn preview_configuration_adoption( pub(crate) fn preview_configuration_adoption(
&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, &self,
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>, baseline: Option<&ConfigurationMigrationBaseline<'_>>,
mut require_subject: impl FnMut(&str) -> Result<()>, mut require_subject: impl FnMut(&str) -> Result<()>,
) -> Result<ConfigurationPreview> { ) -> Result<ConfigurationPreview> {
self.with_conn(|conn| { self.with_conn(|conn| {
require_owner(conn,workspace,account)?; require_owner(conn, workspace, account)?;
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let (current,state,source) = prepare(&tx,workspace,request,schema,host,baseline,&mut require_subject)?; let (current, state, source) = prepare(
let before = match baseline { Some(baseline) => baseline.policies.clone(), None => policies(&tx,workspace)? }; &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(); let mut after = before.clone();
policy_config::apply(&mut after,&source,"preview"); policy_config::apply(&mut after, &source, "preview");
Ok(ConfigurationPreview { Ok(ConfigurationPreview {
expected_head: request.expected_head.clone(), expected_head: request.expected_head.clone(),
changed_paths: changed_paths(&current.snapshot,&state.snapshot), changed_paths: changed_paths(&current.snapshot, &state.snapshot),
changed_subjects: changed_subjects(&before,&after), state, changed_subjects: changed_subjects(&before, &after),
state,
}) })
}) })
} }
@@ -247,8 +402,12 @@ impl SqliteWorkspaceStore {
/// current-head checks. If subsequent Server persistence fails, that specific /// current-head checks. If subsequent Server persistence fails, that specific
/// transfer remains pending; callers reconcile/retry the same request, not a new ID. /// transfer remains pending; callers reconcile/retry the same request, not a new ID.
pub(crate) fn adopt_configuration( pub(crate) fn adopt_configuration(
&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest, &self,
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>, baseline: Option<&ConfigurationMigrationBaseline<'_>>,
mut require_subject: impl FnMut(&str) -> Result<()>, mut require_subject: impl FnMut(&str) -> Result<()>,
mut prepare_transfer: impl FnMut(&str) -> Result<ConfigurationPolicyTransfer>, mut prepare_transfer: impl FnMut(&str) -> Result<ConfigurationPolicyTransfer>,
@@ -301,7 +460,11 @@ impl SqliteWorkspaceStore {
}) })
} }
pub fn adopted_subject_policy(&self, workspace: &str, subject: &str) -> Result<Option<AdoptedSubjectPolicy>> { pub fn adopted_subject_policy(
&self,
workspace: &str,
subject: &str,
) -> Result<Option<AdoptedSubjectPolicy>> {
self.with_conn(|conn| { 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))?; 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())); } if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); }
@@ -312,39 +475,68 @@ impl SqliteWorkspaceStore {
} }
fn prepare( fn prepare(
tx: &Transaction<'_>, workspace: &str, request: &ConfigurationAdoptionRequest, tx: &Transaction<'_>,
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost, workspace: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>, baseline: Option<&ConfigurationMigrationBaseline<'_>>,
require_subject: &mut impl FnMut(&str) -> Result<()>, require_subject: &mut impl FnMut(&str) -> Result<()>,
) -> Result<(WorkspaceConfigState,WorkspaceConfigState,PermissionSource)> { ) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> {
if let Some(baseline) = baseline { if let Some(baseline) = baseline {
if baseline.diagnostics.iter().any(|diagnostic| matches!(diagnostic, policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. })) { if baseline.diagnostics.iter().any(|diagnostic| {
matches!(
diagnostic,
policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. }
)
}) {
return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into())); return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into()));
} }
} }
if host.catalog().workspace.as_str() != workspace { if host.catalog().workspace.as_str() != workspace {
return Err(Error::WorkspacePermissionDenied("permission registry belongs to another Workspace".into())); return Err(Error::WorkspacePermissionDenied(
"permission registry belongs to another Workspace".into(),
));
} }
if catalog_digest(host)? != request.expected_catalog_digest { return Err(conflict("Feature catalog changed")); } if catalog_digest(host)? != request.expected_catalog_digest {
if head(tx,workspace)?.map(|id|id.to_string()) != request.expected_head { return Err(conflict("editing history head changed")); } return Err(conflict("Feature catalog changed"));
let current = crate::config_source::load_state(tx,workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?; }
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 { let editing_base = if let Some(baseline) = baseline {
if *baseline.original != current || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) { if *baseline.original != current
|| request.expected_legacy_export.as_deref() != Some(baseline.export_digest)
{
return Err(conflict("migration source or export changed")); return Err(conflict("migration source or export changed"));
} }
baseline.state baseline.state
} else { &current }; } else {
if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); } &current
};
if editing_base.snapshot.digest != request.source.base_digest {
return Err(conflict("source content changed"));
}
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?; crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
let candidate = crate::config_source::evaluate_candidate_in_transaction( let candidate = crate::config_source::evaluate_candidate_in_transaction(
tx,workspace,editing_base.clone(),crate::config_source::load_evaluation_identity(tx,workspace)?, tx,
&request.source.changes,schema, workspace,
editing_base.clone(),
crate::config_source::load_evaluation_identity(tx, workspace)?,
&request.source.changes,
schema,
)?; )?;
crate::config_source::validate_workspace_config_candidate_projections(workspace,&candidate)?; crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
let source = policy_config::source(&candidate.evaluation)?; let source = policy_config::source(&candidate.evaluation)?;
policy_config::validate_source(host,&source,require_subject)?; policy_config::validate_source(host, &source, require_subject)?;
let state = WorkspaceConfigState { snapshot:candidate.snapshot,contract:candidate.contract,projection_digest:candidate.evaluation.projection_digest }; let state = WorkspaceConfigState {
Ok((current,state,source)) snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
};
Ok((current, state, source))
} }
#[cfg(test)] #[cfg(test)]
@@ -12,24 +12,55 @@ const OWNER: &str = "adoption-owner";
fn host(children: bool) -> PermissionHost { fn host(children: bool) -> PermissionHost {
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
let feature = host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()).unwrap(); let feature = host
let namespace = host.bind_namespace(&feature,NamespaceId::parse("ticket").unwrap()).unwrap(); .register_feature(FeatureIdentity::parse("builtin:ticket").unwrap())
host.declare(&namespace, NamespaceDeclaration { .unwrap();
permissions: vec![ let namespace = host
PermissionDefinition { key: PermissionKey::parse("ticket.hoge").unwrap(), description:String::new(), children: if children {vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]} else {vec![]} }, .bind_namespace(&feature, NamespaceId::parse("ticket").unwrap())
PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] }, .unwrap();
], host.declare(
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()]})] }], &namespace,
}).unwrap(); 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 host
} }
fn schema() -> WorkspaceConfigSchemaBundle { fn schema() -> WorkspaceConfigSchemaBundle {
WorkspaceConfigSchemaBundle::compose([ WorkspaceConfigSchemaBundle::compose([
PermissionConfigSchemaProvider.contribution().unwrap(), PermissionConfigSchemaProvider.contribution().unwrap(),
crate::prompt_settings::PromptConfigSchemaProvider.contribution().unwrap(), crate::prompt_settings::PromptConfigSchemaProvider
]).unwrap() .contribution()
.unwrap(),
])
.unwrap()
}
fn open(path: &std::path::Path) -> SqliteWorkspaceStore {
SqliteWorkspaceStore::open_for_test(path).unwrap()
} }
fn open(path: &std::path::Path) -> SqliteWorkspaceStore { SqliteWorkspaceStore::open_for_test(path).unwrap() }
fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let db = open(&dir.path().join("server.sqlite")); let db = open(&dir.path().join("server.sqlite"));
@@ -37,119 +68,350 @@ fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?; 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(()) Ok(())
}).unwrap(); }).unwrap();
db.ensure_workspace_config_materialized_with_schema(WORKSPACE,"now",schema()).unwrap(); db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema())
(dir,db) .unwrap();
(dir, db)
} }
fn request(db: &SqliteWorkspaceStore, host: &PermissionHost, id: &str, source: Option<&str>) -> ConfigurationAdoptionRequest { fn request(
db: &SqliteWorkspaceStore,
host: &PermissionHost,
id: &str,
source: Option<&str>,
) -> ConfigurationAdoptionRequest {
let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap(); let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap();
let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap(); let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap();
let changes = source.map(|source| vec![ConfigTreeChange::Update { let changes = source
path: path.clone(), expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), content:source.into(), .map(|source| {
}]).unwrap_or_default(); vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(),
content: source.into(),
}]
})
.unwrap_or_default();
ConfigurationAdoptionRequest { 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(), operation_id: id.into(),
source:ConfigCommitRequest {base_digest:current.snapshot.digest,changes,entrypoints:vec![path]}, 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<ConfigurationAdoptionResult> { fn adopt(
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 { db: &SqliteWorkspaceStore,
transfer_identity:fingerprint.into(),physical_identity:"fixture-physical".into(),export_digest:"fixture-empty".into(),policies:BTreeMap::new(), host: &PermissionHost,
})) request: &ConfigurationAdoptionRequest,
) -> Result<ConfigurationAdoptionResult> {
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 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"#; const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#;
#[test] #[test]
fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() { fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() {
let (_dir,db) = fixture(); let host=host(false); let (_dir, db) = fixture();
let initial=request(&db,&host,"initial",Some(A)); let host = host(false);
let first=adopt(&db,&host,&initial).unwrap(); let initial = request(&db, &host, "initial", Some(A));
let stale=request(&db,&host,"stale-a",Some(B)); let first = adopt(&db, &host, &initial).unwrap();
adopt(&db,&host,&request(&db,&host,"to-b",Some(B))).unwrap(); let stale = request(&db, &host, "stale-a", Some(B));
let final_a=adopt(&db,&host,&request(&db,&host,"back-a",Some(A))).unwrap(); adopt(&db, &host, &request(&db, &host, "to-b", Some(B))).unwrap();
assert_eq!(first.state.snapshot,final_a.state.snapshot); let final_a = adopt(&db, &host, &request(&db, &host, "back-a", Some(A))).unwrap();
assert_ne!(first.history_head,final_a.history_head); assert_eq!(first.state.snapshot, final_a.state.snapshot);
assert!(adopt(&db,&host,&stale).is_err()); 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. // Exact replay precedes stale checks and returns only the saved old result.
assert_eq!(adopt(&db,&host,&initial).unwrap(),first); assert_eq!(adopt(&db, &host, &initial).unwrap(), first);
assert_eq!(db.configuration_adoption_result(WORKSPACE,OWNER,"initial").unwrap(),Some(first)); assert_eq!(
assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),Some(final_a.history_head)); db.configuration_adoption_result(WORKSPACE, OWNER, "initial")
let mut changed=initial; changed.source.changes.clear(); .unwrap(),
assert!(adopt(&db,&host,&changed).is_err()); 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] #[test]
fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() { fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() {
for (table,event) in [ for (table, event) in [
("content_objects","INSERT"), ("workspace_config_entries","INSERT"), ("content_objects", "INSERT"),
("workspace_configuration_policies","INSERT"), ("content_scopes","UPDATE"), ("workspace_config_entries", "INSERT"),
("workspace_configuration_receipts","INSERT"), ("workspace_configuration_policies", "INSERT"),
("content_scopes", "UPDATE"),
("workspace_configuration_receipts", "INSERT"),
] { ] {
let (dir,db)=fixture(); let host=host(false); let (dir, db) = fixture();
let before=adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); let host = host(false);
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); let before = adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let candidate=request(&db,&host,"failing",Some(B)); 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(); db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap();
assert!(adopt(&db,&host,&candidate).is_err(),"{table}"); assert!(adopt(&db, &host, &candidate).is_err(), "{table}");
drop(db); drop(db);
let reopened=open(&dir.path().join("server.sqlite")); let reopened = open(&dir.path().join("server.sqlite"));
assert_eq!(reopened.load_workspace_config(WORKSPACE).unwrap(),Some(before.state),"{table}"); assert_eq!(
assert_eq!(reopened.configuration_head(WORKSPACE,OWNER).unwrap(),Some(before.history_head),"{table}"); reopened.load_workspace_config(WORKSPACE).unwrap(),
assert_eq!(reopened.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),policy,"{table}"); Some(before.state),
assert!(reopened.configuration_adoption_result(WORKSPACE,OWNER,"failing").unwrap().is_none(),"{table}"); "{table}"
adopt(&reopened,&host,&candidate).unwrap(); );
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] #[test]
fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() { fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() {
let (_dir,db)=fixture(); let host=host(false); let (_dir, db) = fixture();
let initial=db.load_workspace_config(WORKSPACE).unwrap(); let host = host(false);
let initial = db.load_workspace_config(WORKSPACE).unwrap();
for source in [ for source in [
"not valid Decodal {", "not valid Decodal {",
r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#, r#"{permissions={subjects=[{subject_id="subject-a"; execution={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="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#,
r#"{permissions={subjects=[{subject_id="missing";}];};} 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";},{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"#, 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 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")); let result = db.adopt_configuration(
assert!(result.is_err(),"{source}"); WORKSPACE,
assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(),initial); OWNER,
assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),None); &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); 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()); assert!(
db.adopt_configuration(
WORKSPACE,
"non-owner",
&request,
schema(),
&host,
None,
|_| Ok(()),
|_| panic!("owner refusal must precede seal")
)
.is_err()
);
} }
#[test] #[test]
fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() { fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() {
let (_dir,db)=fixture(); let host=host(false); let (_dir, db) = fixture();
adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); let host = host(false);
let original=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(); adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let preview=db.preview_configuration_adoption(WORKSPACE,OWNER,&request(&db,&host,"noop",None),schema(),&host,None,|_|Ok(())).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_paths.is_empty());
assert!(preview.changed_subjects.is_empty()); assert!(preview.changed_subjects.is_empty());
adopt(&db,&host,&request(&db,&host,"noop",None)).unwrap(); adopt(&db, &host, &request(&db, &host, "noop", None)).unwrap();
assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); assert_eq!(
adopt(&db,&host,&request(&db,&host,"omit",Some("{} as WorkspaceConfigSchema"))).unwrap(); db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(),
assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original); original
adopt(&db,&host,&request(&db,&host,"remove",Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#))).unwrap(); );
assert!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().is_none()); adopt(
&db,
&host,
&request(&db, &host, "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] #[test]
fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() { fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() {
for children in [false,true] { for children in [false, true] {
let (_dir,db)=fixture(); let host=host(children); let (_dir, db) = fixture();
adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap(); let host = host(children);
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let resolution=host.resolve(Some(&policy.source.execution.assignment())); let policy = db
assert_eq!(resolution.projection().allowed.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),children); .adopted_subject_policy(WORKSPACE, "subject-a")
assert!(host.resolve(Some(&policy.source.delegation.assignment())).projection().allowed.is_empty()); .unwrap()
adopt(&db,&host,&request(&db,&host,"multi",Some(B))).unwrap(); .unwrap();
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap(); let resolution = host.resolve(Some(&policy.source.execution.assignment()));
assert_eq!(policy.source.execution.namespaces[0].keys[0].as_str(),"ticket.hoge.fuga"); 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, &registry, &request(&db, &registry, "initial", Some(A))).unwrap();
let other = open(&dir.path().join("server.sqlite"));
let web = request(&db, &registry, "web-edit", Some(B));
let cli = request(
&other,
&registry,
"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()
);
}
+53 -39
View File
@@ -183,49 +183,56 @@ impl SqliteWorkspaceStore {
schema_bundle: WorkspaceConfigSchemaBundle, schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigState> { ) -> Result<WorkspaceConfigState> {
let desired_schema = schema_bundle.clone(); let desired_schema = schema_bundle.clone();
let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| { let (state, requires_toolchain_refresh, base_identity, canonical) =
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; self.with_conn_mut(|conn| {
let workspace_exists: bool = tx.query_row( let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", let workspace_exists: bool = tx.query_row(
[workspace_id], "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|row| row.get(0), [workspace_id],
)?; |row| row.get(0),
if !workspace_exists { )?;
return Err(Error::WorkspaceIdMismatch); if !workspace_exists {
} return Err(Error::WorkspaceIdMismatch);
let stored_decodal_version = tx }
let stored_decodal_version = tx
.query_row( .query_row(
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
[workspace_id], [workspace_id],
|row| row.get::<_, String>(0), |row| row.get::<_, String>(0),
) )
.optional()?; .optional()?;
let requires_toolchain_refresh = stored_decodal_version let requires_toolchain_refresh = stored_decodal_version
.as_deref() .as_deref()
.is_some_and(|version| version != DECODAL_VERSION); .is_some_and(|version| version != DECODAL_VERSION);
let state = match load_state(&tx, workspace_id)? { let state = match load_state(&tx, workspace_id)? {
Some(state) => state, Some(state) => state,
None => { None => {
let mut state = initial_state_with_schema(schema_bundle.clone())?; let mut state = initial_state_with_schema(schema_bundle.clone())?;
if has_plugin_provider(&schema_bundle) { if has_plugin_provider(&schema_bundle) {
let (contract, evaluation, _) = let (contract, evaluation, _) =
crate::plugin_settings::evaluate_catalog_config( crate::plugin_settings::evaluate_catalog_config(
&tx, &tx,
workspace_id, workspace_id,
&state.snapshot, &state.snapshot,
schema_bundle.clone(), schema_bundle.clone(),
)?; )?;
state.contract = contract; state.contract = contract;
state.projection_digest = evaluation.projection_digest; 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)?;
} let canonical = crate::config_adoption::canonical(&tx, workspace_id)?;
}; tx.commit()?;
let base_identity = load_evaluation_identity(&tx, workspace_id)?; Ok((state, requires_toolchain_refresh, base_identity, canonical))
tx.commit()?; })?;
Ok((state, requires_toolchain_refresh, base_identity)) // 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( let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema, desired_schema,
&state.contract.schema_bundle, &state.contract.schema_bundle,
@@ -570,14 +577,21 @@ fn evaluate_candidate(
} }
pub(crate) fn sync_adopted_config_references( pub(crate) fn sync_adopted_config_references(
tx: &rusqlite::Transaction<'_>, workspace: &str, state: &WorkspaceConfigState, tx: &rusqlite::Transaction<'_>,
workspace: &str,
state: &WorkspaceConfigState,
) -> Result<()> { ) -> Result<()> {
if has_plugin_provider(&state.contract.schema_bundle) { if has_plugin_provider(&state.contract.schema_bundle) {
let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config( let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config(
tx, workspace, &state.snapshot, state.contract.schema_bundle.clone(), tx,
workspace,
&state.snapshot,
state.contract.schema_bundle.clone(),
)?; )?;
if contract != state.contract || evaluation.projection_digest != state.projection_digest { if contract != state.contract || evaluation.projection_digest != state.projection_digest {
return Err(config_conflict("Plugin catalog/schema changed during adoption")); return Err(config_conflict(
"Plugin catalog/schema changed during adoption",
));
} }
crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?; crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?;
} }
@@ -62,13 +62,20 @@ pub(crate) fn preview_migration(
Error::RegistryInconsistency("workspace config tree is not materialized".into()) Error::RegistryInconsistency("workspace config tree is not materialized".into())
}) })
})?; })?;
let snapshot = legacy.preview_permission_transfer(&identity).map_err(legacy_error)?; let snapshot = legacy
.preview_permission_transfer(&identity)
.map_err(legacy_error)?;
if snapshot.identity != identity { if snapshot.identity != identity {
return Err(Error::WorkspaceConfigConflict("legacy export identity changed".into())); return Err(Error::WorkspaceConfigConflict(
"legacy export identity changed".into(),
));
} }
let (policies, diagnostics) = migrate_policies(host, &snapshot)?; let (policies, diagnostics) = migrate_policies(host, &snapshot)?;
let source = PermissionSource { let source = PermissionSource {
subjects: policies.values().map(|policy| policy.source.clone()).collect(), subjects: policies
.values()
.map(|policy| policy.source.clone())
.collect(),
remove_subjects: vec![], remove_subjects: vec![],
}; };
let changes = draft_changes(&original, &source)?; let changes = draft_changes(&original, &source)?;
@@ -90,7 +97,11 @@ pub(crate) fn preview_migration(
"migration import did not produce the exact generated Subject assignments".into(), "migration import did not produce the exact generated Subject assignments".into(),
)); ));
} }
let subjects: BTreeSet<_> = snapshot.sources.iter().map(|item| item.subject_id.as_str()).collect(); let subjects: BTreeSet<_> = snapshot
.sources
.iter()
.map(|item| item.subject_id.as_str())
.collect();
policy_config::validate_source(host, &evaluated_source, |subject| { policy_config::validate_source(host, &evaluated_source, |subject| {
if subjects.contains(subject) { if subjects.contains(subject) {
Ok(()) Ok(())
@@ -103,7 +114,13 @@ pub(crate) fn preview_migration(
contract: candidate.contract, contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest, projection_digest: candidate.evaluation.projection_digest,
}; };
Ok(MigrationSeed { original, state, snapshot, policies, diagnostics }) Ok(MigrationSeed {
original,
state,
snapshot,
policies,
diagnostics,
})
} }
fn legacy_error(error: subjektiv::SubjektivError) -> Error { fn legacy_error(error: subjektiv::SubjektivError) -> Error {
@@ -117,17 +134,25 @@ fn path(value: &str) -> Result<VirtualPath> {
fn migrate_policies( fn migrate_policies(
host: &PermissionHost, host: &PermissionHost,
snapshot: &PermissionTransferSnapshot, snapshot: &PermissionTransferSnapshot,
) -> Result<(BTreeMap<String, AdoptedSubjectPolicy>, Vec<MigrationDiagnostic>)> { ) -> Result<(
BTreeMap<String, AdoptedSubjectPolicy>,
Vec<MigrationDiagnostic>,
)> {
let mut policies = BTreeMap::new(); let mut policies = BTreeMap::new();
let mut diagnostics = vec![]; let mut diagnostics = vec![];
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
for item in &snapshot.sources { for item in &snapshot.sources {
if !seen.insert(&item.subject_id) { if !seen.insert(&item.subject_id) {
return Err(Error::RegistryInconsistency("duplicate Subject in legacy export".into())); return Err(Error::RegistryInconsistency(
"duplicate Subject in legacy export".into(),
));
} }
// None stays unset. In particular, never enroll it in a default Set. // None stays unset. In particular, never enroll it in a default Set.
let Some(legacy) = &item.permissions else { continue }; let Some(legacy) = &item.permissions else {
let (policy, mut converted) = policy_config::migrate_subject(host, &item.subject_id, legacy)?; continue;
};
let (policy, mut converted) =
policy_config::migrate_subject(host, &item.subject_id, legacy)?;
policies.insert(item.subject_id.clone(), policy); policies.insert(item.subject_id.clone(), policy);
// ExplicitConfigurationRequired must survive preview, even though the // ExplicitConfigurationRequired must survive preview, even though the
// unsupported operation is absent from the editable assignment. Core must // unsupported operation is absent from the editable assignment. Core must
@@ -137,7 +162,10 @@ fn migrate_policies(
Ok((policies, diagnostics)) Ok((policies, diagnostics))
} }
fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Result<Vec<ConfigTreeChange>> { fn draft_changes(
original: &WorkspaceConfigState,
source: &PermissionSource,
) -> Result<Vec<ConfigTreeChange>> {
// Evaluate with the existing open schema, not application defaults. This // Evaluate with the existing open schema, not application defaults. This
// distinguishes an explicit `permissions = {}` (including imports/dotted // distinguishes an explicit `permissions = {}` (including imports/dotted
// fields) from an omitted field without introducing another Decodal parser. // fields) from an omitted field without introducing another Decodal parser.
@@ -149,10 +177,16 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) ->
); );
let raw = SnapshotEnvironment::new(original.snapshot.clone()) let raw = SnapshotEnvironment::new(original.snapshot.clone())
.evaluate_contract(&open_contract) .evaluate_contract(&open_contract)
.map_err(|diagnostics| Error::InvalidInput(format!( .map_err(|diagnostics| {
"cannot inspect current configuration before permission migration: {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()) { ))
})?;
if raw
.projections
.iter()
.any(|projection| projection.data_json.get("permissions").is_some())
{
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
"current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(), "current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(),
)); ));
@@ -163,13 +197,17 @@ fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) ->
Error::RegistryInconsistency("workspace config tree has no main.dcdl".into()) Error::RegistryInconsistency("workspace config tree has no main.dcdl".into())
})?; })?;
if main.content_type != ConfigContentType::Decodal { if main.content_type != ConfigContentType::Decodal {
return Err(Error::InvalidInput("main.dcdl is not Decodal source".into())); return Err(Error::InvalidInput(
"main.dcdl is not Decodal source".into(),
));
} }
// Use the existing syntax formatter to supply a final field separator when // Use the existing syntax formatter to supply a final field separator when
// needed. It preserves comments; no local lexer or alternate language rules. // needed. It preserves comments; no local lexer or alternate language rules.
let formatted_main = SnapshotEnvironment::new(original.snapshot.clone()) let formatted_main = SnapshotEnvironment::new(original.snapshot.clone())
.format(&main.content) .format(&main.content)
.map_err(|error| Error::InvalidInput(format!("cannot format migration entrypoint: {error}")))?; .map_err(|error| {
Error::InvalidInput(format!("cannot format migration entrypoint: {error}"))
})?;
Ok(vec![ Ok(vec![
ConfigTreeChange::Create { ConfigTreeChange::Create {
path: generated_path.clone(), path: generated_path.clone(),
@@ -206,8 +244,10 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result<String> {
// assertion. Limit this edit to that existing representation: no rewrites of // assertion. Limit this edit to that existing representation: no rewrites of
// arbitrary expressions, no JSON round-trip, no replacement of other fields. // arbitrary expressions, no JSON round-trip, no replacement of other fields.
let trimmed = source.trim_end(); let trimmed = source.trim_end();
let object = trimmed.strip_suffix("as WorkspaceConfigSchema") let object = trimmed
.map(str::trim_end).unwrap_or(trimmed); .strip_suffix("as WorkspaceConfigSchema")
.map(str::trim_end)
.unwrap_or(trimmed);
if !object.starts_with('{') || !object.ends_with('}') { if !object.starts_with('{') || !object.ends_with('}') {
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
"permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(), "permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(),
@@ -215,7 +255,11 @@ fn append_import(source: &str, imported: &VirtualPath) -> Result<String> {
} }
let close = object.len() - 1; let close = object.len() - 1;
let import = quote(&format!("./{}", imported.as_str())); let import = quote(&format!("./{}", imported.as_str()));
Ok(format!("{}\n permissions = import {import};\n{}", &source[..close], &source[close..])) Ok(format!(
"{}\n permissions = import {import};\n{}",
&source[..close],
&source[close..]
))
} }
// A deliberately specific, payload-free renderer. JSON quoting is used only for // A deliberately specific, payload-free renderer. JSON quoting is used only for
@@ -225,15 +269,23 @@ fn quote(value: &str) -> String {
} }
fn render_strings<'a>(values: impl IntoIterator<Item = &'a str>) -> String { fn render_strings<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
format!("[{}]", values.into_iter().map(quote).collect::<Vec<_>>().join(", ")) format!(
"[{}]",
values.into_iter().map(quote).collect::<Vec<_>>().join(", ")
)
} }
fn render_namespaces(values: &[NamespacePermissions]) -> String { fn render_namespaces(values: &[NamespacePermissions]) -> String {
let items = values.iter().map(|value| format!( let items = values
"{{ namespace = {}; keys = {}; }}", .iter()
quote(value.namespace.as_str()), .map(|value| {
render_strings(value.keys.iter().map(|key| key.as_str())), format!(
)).collect::<Vec<_>>(); "{{ namespace = {}; keys = {}; }}",
quote(value.namespace.as_str()),
render_strings(value.keys.iter().map(|key| key.as_str())),
)
})
.collect::<Vec<_>>();
format!("[{}]", items.join(", ")) format!("[{}]", items.join(", "))
} }
@@ -249,10 +301,19 @@ fn render_assignment(source: &AssignmentSource) -> String {
} }
fn render_permission_source(source: &PermissionSource) -> String { fn render_permission_source(source: &PermissionSource) -> String {
let subjects = source.subjects.iter().map(|item| format!( let subjects = source
" {{ subject_id = {}; execution = {}; delegation = {}; }}", .subjects
quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation), .iter()
)).collect::<Vec<_>>().join(",\n"); .map(|item| {
format!(
" {{ subject_id = {}; execution = {}; delegation = {}; }}",
quote(&item.subject_id),
render_assignment(&item.execution),
render_assignment(&item.delegation),
)
})
.collect::<Vec<_>>()
.join(",\n");
format!( format!(
"{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n", "{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n",
render_strings(source.remove_subjects.iter().map(String::as_str)), render_strings(source.remove_subjects.iter().map(String::as_str)),
@@ -264,8 +325,8 @@ mod tests {
use super::*; use super::*;
use config_source::{ConfigEntry, EvaluationResult}; use config_source::{ConfigEntry, EvaluationResult};
use feature_permissions::{ use feature_permissions::{
FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionKey,
PermissionKey, PermissionSetId, WorkspaceId, PermissionSetId, WorkspaceId,
}; };
use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions}; use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions};
use subjektiv::{DatabaseIdentity, PermissionSourceExport}; use subjektiv::{DatabaseIdentity, PermissionSourceExport};
@@ -274,22 +335,35 @@ mod tests {
fn host(children: bool) -> PermissionHost { fn host(children: bool) -> PermissionHost {
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap()); let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
let feature = host.register_feature(FeatureIdentity::parse("builtin:file").unwrap()).unwrap(); let feature = host
let namespace = host.bind_namespace(&feature, NamespaceId::parse("file").unwrap()).unwrap(); .register_feature(FeatureIdentity::parse("builtin:file").unwrap())
host.declare(&namespace, NamespaceDeclaration { .unwrap();
permissions: vec![ let namespace = host
PermissionDefinition { .bind_namespace(&feature, NamespaceId::parse("file").unwrap())
key: PermissionKey::parse("file.read").unwrap(), .unwrap();
description: String::new(), host.declare(
children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] }, &namespace,
}, NamespaceDeclaration {
PermissionDefinition { permissions: vec![
key: PermissionKey::parse("file.read.text").unwrap(), PermissionDefinition {
description: String::new(), children: vec![], key: PermissionKey::parse("file.read").unwrap(),
}, description: String::new(),
], children: if children {
sets: vec![], vec![PermissionKey::parse("file.read.text").unwrap()]
}).unwrap(); } else {
vec![]
},
},
PermissionDefinition {
key: PermissionKey::parse("file.read.text").unwrap(),
description: String::new(),
children: vec![],
},
],
sets: vec![],
},
)
.unwrap();
host host
} }
@@ -297,7 +371,9 @@ mod tests {
ScopedPermission { ScopedPermission {
operation: operation.into(), operation: operation.into(),
resource: PermissionResource::Workdir { resource: PermissionResource::Workdir {
working_directory_id: "one-workdir".into(), path: "only-this.txt".into(), recursive: false, working_directory_id: "one-workdir".into(),
path: "only-this.txt".into(),
recursive: false,
}, },
} }
} }
@@ -305,18 +381,25 @@ mod tests {
fn export(sources: Vec<PermissionSourceExport>) -> PermissionTransferSnapshot { fn export(sources: Vec<PermissionSourceExport>) -> PermissionTransferSnapshot {
PermissionTransferSnapshot { PermissionTransferSnapshot {
identity: DatabaseIdentity { identity: DatabaseIdentity {
scope_id: WORKSPACE.into(), feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(), scope_id: WORKSPACE.into(),
feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(),
physical_id: "typed-test-identity".into(), physical_id: "typed-test-identity".into(),
}, },
sources, export_digest: "not-used-by-pure-conversion-tests".into(), sources,
export_digest: "not-used-by-pure-conversion-tests".into(),
} }
} }
fn current(main: &str, extra: Vec<ConfigEntry>) -> WorkspaceConfigState { fn current(main: &str, extra: Vec<ConfigEntry>) -> WorkspaceConfigState {
let mut current = crate::config_source::initial_state().unwrap(); let mut current = crate::config_source::initial_state().unwrap();
let mut entries = vec![ConfigEntry::new( let mut entries = vec![
path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, main, ConfigEntry::new(
).unwrap()]; path(MAIN_CONFIG_ENTRYPOINT).unwrap(),
ConfigContentType::Decodal,
main,
)
.unwrap(),
];
entries.extend(extra); entries.extend(extra);
current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap(); current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap();
current current
@@ -324,43 +407,81 @@ mod tests {
fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult { fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult {
let contract = crate::config_source::initial_state().unwrap().contract; let contract = crate::config_source::initial_state().unwrap().contract;
SnapshotEnvironment::new(snapshot).evaluate_contract(&contract).unwrap() SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract)
.unwrap()
} }
#[test] #[test]
fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() { fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() {
let empty = SubjectPermissions { change_id: "explicit-empty".into(), execution: vec![], delegation: vec![] }; let empty = SubjectPermissions {
change_id: "explicit-empty".into(),
execution: vec![],
delegation: vec![],
};
let limited = SubjectPermissions { let limited = SubjectPermissions {
change_id: "keep-limited-change".into(), execution: vec![limited("file.read")], change_id: "keep-limited-change".into(),
execution: vec![limited("file.read")],
delegation: vec![limited("file.read.text")], delegation: vec![limited("file.read.text")],
}; };
let snapshot = export(vec![ let snapshot = export(vec![
PermissionSourceExport { subject_id: "unset".into(), permissions: None }, PermissionSourceExport {
PermissionSourceExport { subject_id: "empty".into(), permissions: Some(empty.clone()) }, subject_id: "unset".into(),
PermissionSourceExport { subject_id: "limited".into(), permissions: Some(limited.clone()) }, 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 host = host(false);
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap();
assert!(!policies.contains_key("unset")); assert!(!policies.contains_key("unset"));
assert_eq!(policies["empty"].change_id, empty.change_id); assert_eq!(policies["empty"].change_id, empty.change_id);
assert_eq!(policies["empty"].source.execution, AssignmentSource::default()); assert_eq!(
assert_eq!(policies["empty"].source.delegation, AssignmentSource::default()); 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!(policies["limited"].legacy_read(&host).unwrap(), limited);
assert_eq!(diagnostics.len(), 2); assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.iter().all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))); assert!(
diagnostics
.iter()
.all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))
);
} }
#[test] #[test]
fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() { fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() {
for (host, permission) in [(host(false), limited("file.unknown")), (host(true), limited("file.read"))] { for (host, permission) in [
(host(false), limited("file.unknown")),
(host(true), limited("file.read")),
] {
let legacy = SubjectPermissions { let legacy = SubjectPermissions {
change_id: "keep-change".into(), execution: vec![permission.clone()], delegation: vec![], change_id: "keep-change".into(),
execution: vec![permission.clone()],
delegation: vec![],
}; };
let snapshot = export(vec![PermissionSourceExport { subject_id: "blocked".into(), permissions: Some(legacy.clone()) }]); let snapshot = export(vec![PermissionSourceExport {
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap(); subject_id: "blocked".into(),
assert_eq!(diagnostics, vec![MigrationDiagnostic::ExplicitConfigurationRequired { permissions: Some(legacy.clone()),
subject_id: "blocked".into(), permission,
}]); }]);
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_eq!(policies["blocked"].legacy_constraints, Some(legacy));
assert!(policies["blocked"].source.execution.namespaces.is_empty()); assert!(policies["blocked"].source.execution.namespaces.is_empty());
assert!(policies["blocked"].source.execution.sets.is_empty()); assert!(policies["blocked"].source.execution.sets.is_empty());
@@ -384,7 +505,8 @@ mod tests {
}; };
let source = PermissionSource { let source = PermissionSource {
subjects: vec![policy_config::SubjectAssignmentSource { subjects: vec![policy_config::SubjectAssignmentSource {
subject_id: "subject-\"quoted\\日本語".into(), execution: assignment, subject_id: "subject-\"quoted\\日本語".into(),
execution: assignment,
delegation: AssignmentSource::default(), delegation: AssignmentSource::default(),
}], }],
remove_subjects: vec!["removed".into()], remove_subjects: vec!["removed".into()],
@@ -393,33 +515,58 @@ mod tests {
assert!(rendered.contains("sets = [\"shared\"]")); assert!(rendered.contains("sets = [\"shared\"]"));
assert!(rendered.contains("keys = [\"file.read.text\"]")); assert!(rendered.contains("keys = [\"file.read.text\"]"));
assert!(!rendered.contains("legacy_constraints")); assert!(!rendered.contains("legacy_constraints"));
let snapshot = ConfigTreeSnapshot::from_entries([ let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
ConfigEntry::new(path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, path(MAIN_CONFIG_ENTRYPOINT).unwrap(),
format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema")).unwrap(), ConfigContentType::Decodal,
]).unwrap(); format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema"),
)
.unwrap()])
.unwrap();
assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source); assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source);
} }
#[test] #[test]
fn draft_preserves_main_comments_other_settings_and_existing_migration_files() { 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 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 occupied = ConfigEntry::new(
path("permissions/migrated.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ keep = true; }\n",
)
.unwrap();
let original = current(main, vec![occupied.clone()]); let original = current(main, vec![occupied.clone()]);
let source = PermissionSource::default(); let source = PermissionSource::default();
let changes = draft_changes(&original, &source).unwrap(); let changes = draft_changes(&original, &source).unwrap();
let draft = original.snapshot.apply(&changes).unwrap(); let draft = original.snapshot.apply(&changes).unwrap();
assert_eq!(draft.get(&occupied.path), Some(&occupied)); assert_eq!(draft.get(&occupied.path), Some(&occupied));
assert!(draft.get(&path("permissions/migrated-1.dcdl").unwrap()).is_some()); assert!(
let updated_main = &draft.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content; draft
assert!(updated_main.contains("// permissions in a comment are not a field")); .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("brace } and permissions"));
assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";")); assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";"));
assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n")); assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n"));
let before = evaluate(original.snapshot.clone()); let before = evaluate(original.snapshot.clone());
let after = evaluate(draft); let after = evaluate(draft);
assert_eq!(after.projections[0].data_json["other"], before.projections[0].data_json["other"]); assert_eq!(
after.projections[0].data_json["other"],
before.projections[0].data_json["other"]
);
assert_eq!(policy_config::source(&after).unwrap(), source); assert_eq!(policy_config::source(&after).unwrap(), source);
assert_eq!(original.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content, main); assert_eq!(
original
.snapshot
.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap())
.unwrap()
.content,
main
);
} }
#[test] #[test]
@@ -429,11 +576,19 @@ mod tests {
"{ permissions.subjects = []; } as WorkspaceConfigSchema", "{ permissions.subjects = []; } as WorkspaceConfigSchema",
"{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema", "{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema",
] { ] {
let existing = ConfigEntry::new(path("permissions/existing.dcdl").unwrap(), ConfigContentType::Decodal, "{ subjects = []; }").unwrap(); let existing = ConfigEntry::new(
path("permissions/existing.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ subjects = []; }",
)
.unwrap();
let original = current(main, vec![existing]); let original = current(main, vec![existing]);
let before = original.clone(); let before = original.clone();
let error = draft_changes(&original, &PermissionSource::default()).unwrap_err(); let error = draft_changes(&original, &PermissionSource::default()).unwrap_err();
assert!(error.to_string().contains("explicitly defines permissions"), "{error}"); assert!(
error.to_string().contains("explicitly defines permissions"),
"{error}"
);
assert_eq!(original, before); assert_eq!(original, before);
} }
} }
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -6,9 +6,10 @@ pub mod backend_job;
pub mod companion; pub mod companion;
pub mod config; pub mod config;
pub mod config_adoption; pub mod config_adoption;
mod configuration_migration;
pub mod policy_config;
pub mod config_source; pub mod config_source;
mod configuration_migration;
#[cfg(test)]
mod configuration_transfer_tests;
#[cfg(test)] #[cfg(test)]
mod content_history_tests; mod content_history_tests;
pub mod db_executor; pub mod db_executor;
@@ -27,6 +28,7 @@ pub mod plugin_plan;
pub mod plugin_receipt; pub mod plugin_receipt;
pub mod plugin_service; pub mod plugin_service;
pub mod plugin_settings; pub mod plugin_settings;
pub mod policy_config;
pub mod profile_settings; pub mod profile_settings;
pub mod prompt_settings; pub mod prompt_settings;
pub mod records; pub mod records;
+148 -41
View File
@@ -3,6 +3,38 @@
//! An omitted Subject is unchanged; removal is explicit. Assignments contain only //! An omitted Subject is unchanged; removal is explicit. Assignments contain only
//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions //! references to Feature-owned Sets and Namespaces. Legacy resource restrictions
//! remain separate domain constraints, never payloads on permission keys. //! 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 std::collections::{BTreeMap, BTreeSet};
@@ -68,8 +100,16 @@ pub struct AssignmentSource {
impl AssignmentSource { impl AssignmentSource {
pub fn assignment(&self) -> PermissionAssignment { pub fn assignment(&self) -> PermissionAssignment {
let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| { let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| {
sets.iter().cloned().map(PermissionReference::Set) sets.iter()
.chain(namespaces.iter().cloned().map(PermissionReference::Namespace)).collect() .cloned()
.map(PermissionReference::Set)
.chain(
namespaces
.iter()
.cloned()
.map(PermissionReference::Namespace),
)
.collect()
}; };
PermissionAssignment { PermissionAssignment {
grants: references(&self.sets, &self.namespaces), grants: references(&self.sets, &self.namespaces),
@@ -112,16 +152,29 @@ pub struct AdoptedSubjectPolicy {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)] #[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
pub enum MigrationDiagnostic { pub enum MigrationDiagnostic {
Converted { subject_id: String, operation: String }, Converted {
DomainConstraint { subject_id: String, permission: ScopedPermission }, subject_id: String,
ExplicitConfigurationRequired { subject_id: String, permission: ScopedPermission }, operation: String,
},
DomainConstraint {
subject_id: String,
permission: ScopedPermission,
},
ExplicitConfigurationRequired {
subject_id: String,
permission: ScopedPermission,
},
} }
pub fn source(evaluation: &EvaluationResult) -> Result<PermissionSource> { pub fn source(evaluation: &EvaluationResult) -> Result<PermissionSource> {
let value = evaluation.projections.first() let value = evaluation
.projections
.first()
.and_then(|p| p.data_json.get("permissions")) .and_then(|p| p.data_json.get("permissions"))
.cloned().unwrap_or_else(|| serde_json::json!({})); .cloned()
serde_json::from_value(value).map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}"))) .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<()> { fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> {
@@ -130,10 +183,18 @@ fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Resu
let mut assignment = source.assignment(); let mut assignment = source.assignment();
assignment.revoked = false; assignment.revoked = false;
let resolution = host.resolve(Some(&assignment)); let resolution = host.resolve(Some(&assignment));
if resolution.projection().diagnostics.iter().any(|d| matches!(d, if resolution.projection().diagnostics.iter().any(|d| {
PermissionDiagnostic::UnknownKey(_) | PermissionDiagnostic::UnknownNamespace(_) matches!(
| PermissionDiagnostic::UnknownSet(_))) { d,
return Err(Error::InvalidInput(format!("invalid permission references: {:?}", resolution.projection().diagnostics))); PermissionDiagnostic::UnknownKey(_)
| PermissionDiagnostic::UnknownNamespace(_)
| PermissionDiagnostic::UnknownSet(_)
)
}) {
return Err(Error::InvalidInput(format!(
"invalid permission references: {:?}",
resolution.projection().diagnostics
)));
} }
Ok(()) Ok(())
} }
@@ -158,7 +219,9 @@ pub fn validate_source(
server_api::authorization::validate_authorization_identifier(subject) server_api::authorization::validate_authorization_identifier(subject)
.map_err(Error::InvalidInput)?; .map_err(Error::InvalidInput)?;
if !subjects.insert(subject) { if !subjects.insert(subject) {
return Err(Error::InvalidInput("Subject both assigned and removed, or removed twice".into())); return Err(Error::InvalidInput(
"Subject both assigned and removed, or removed twice".into(),
));
} }
require_subject(subject)?; require_subject(subject)?;
} }
@@ -172,19 +235,26 @@ pub fn apply(
source: &PermissionSource, source: &PermissionSource,
change_id: &str, change_id: &str,
) { ) {
for subject in &source.remove_subjects { policies.remove(subject); } for subject in &source.remove_subjects {
policies.remove(subject);
}
for item in &source.subjects { for item in &source.subjects {
match policies.get_mut(&item.subject_id) { match policies.get_mut(&item.subject_id) {
Some(previous) if previous.source == *item => {}, Some(previous) if previous.source == *item => {}
Some(previous) => { Some(previous) => {
previous.source = item.clone(); previous.source = item.clone();
previous.change_id = change_id.to_owned(); previous.change_id = change_id.to_owned();
}, }
None => { None => {
policies.insert(item.subject_id.clone(), AdoptedSubjectPolicy { policies.insert(
source: item.clone(), change_id: change_id.to_owned(), legacy_constraints: None, item.subject_id.clone(),
}); AdoptedSubjectPolicy {
}, source: item.clone(),
change_id: change_id.to_owned(),
legacy_constraints: None,
},
);
}
} }
} }
} }
@@ -201,36 +271,66 @@ pub fn migrate_subject(
let mut lane = |permissions: &[ScopedPermission]| -> Result<AssignmentSource> { let mut lane = |permissions: &[ScopedPermission]| -> Result<AssignmentSource> {
let mut by_namespace: BTreeMap<NamespaceId, BTreeSet<PermissionKey>> = BTreeMap::new(); let mut by_namespace: BTreeMap<NamespaceId, BTreeSet<PermissionKey>> = BTreeMap::new();
for permission in permissions { for permission in permissions {
let operation = server_api::authorization::canonical_worker_operation(&permission.operation).unwrap_or(&permission.operation); let operation =
let key = PermissionKey::parse(operation.to_owned()).map_err(Error::InvalidInput)?; server_api::authorization::canonical_worker_operation(&permission.operation)
let assignment = PermissionAssignment { grants: vec![PermissionReference::Namespace(NamespacePermissions { .unwrap_or(&permission.operation);
namespace: key.namespace(), keys: vec![key.clone()], let Ok(key) = PermissionKey::parse(operation.to_owned()) else {
})], exclusions: vec![], revoked: false }; 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 resolution = host.resolve(Some(&assignment));
let exact = resolution.projection().diagnostics.is_empty() let exact = resolution.projection().diagnostics.is_empty()
&& resolution.projection().allowed.len() == 1 && resolution.projection().allowed.len() == 1
&& resolution.projection().allowed.contains_key(&key); && resolution.projection().allowed.contains_key(&key);
if !exact { if !exact {
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired { diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
subject_id: subject_id.into(), permission: permission.clone(), subject_id: subject_id.into(),
permission: permission.clone(),
}); });
continue; continue;
} }
by_namespace.entry(key.namespace()).or_default().insert(key); by_namespace.entry(key.namespace()).or_default().insert(key);
diagnostics.push(MigrationDiagnostic::DomainConstraint { diagnostics.push(MigrationDiagnostic::DomainConstraint {
subject_id: subject_id.into(), permission: permission.clone(), subject_id: subject_id.into(),
permission: permission.clone(),
}); });
} }
Ok(AssignmentSource { namespaces: by_namespace.into_iter().map(|(namespace, keys)| NamespacePermissions { Ok(AssignmentSource {
namespace, keys: keys.into_iter().collect(), namespaces: by_namespace
}).collect(), ..Default::default() }) .into_iter()
.map(|(namespace, keys)| NamespacePermissions {
namespace,
keys: keys.into_iter().collect(),
})
.collect(),
..Default::default()
})
}; };
let execution = lane(&legacy.execution)?; let execution = lane(&legacy.execution)?;
let delegation = lane(&legacy.delegation)?; let delegation = lane(&legacy.delegation)?;
Ok((AdoptedSubjectPolicy { Ok((
source: SubjectAssignmentSource { subject_id: subject_id.into(), execution, delegation }, AdoptedSubjectPolicy {
change_id: legacy.change_id.clone(), legacy_constraints: Some(legacy.clone()), source: SubjectAssignmentSource {
}, diagnostics)) subject_id: subject_id.into(),
execution,
delegation,
},
change_id: legacy.change_id.clone(),
legacy_constraints: Some(legacy.clone()),
},
diagnostics,
))
} }
impl AdoptedSubjectPolicy { impl AdoptedSubjectPolicy {
@@ -241,12 +341,15 @@ impl AdoptedSubjectPolicy {
pub fn legacy_read(&self, host: &PermissionHost) -> Result<SubjectPermissions> { pub fn legacy_read(&self, host: &PermissionHost) -> Result<SubjectPermissions> {
let execution = host.resolve(Some(&self.source.execution.assignment())); let execution = host.resolve(Some(&self.source.execution.assignment()));
let delegation = host.resolve(Some(&self.source.delegation.assignment())); let delegation = host.resolve(Some(&self.source.delegation.assignment()));
let filter = |allowed: &feature_permissions::PermissionResolution, constraints: &[ScopedPermission]| -> Result<Vec<ScopedPermission>> { let filter = |allowed: &feature_permissions::PermissionResolution,
constraints: &[ScopedPermission]|
-> Result<Vec<ScopedPermission>> {
let mut represented = BTreeSet::new(); let mut represented = BTreeSet::new();
let mut output = vec![]; let mut output = vec![];
for permission in constraints { for permission in constraints {
let operation = server_api::authorization::canonical_worker_operation(&permission.operation) let operation =
.unwrap_or(&permission.operation); server_api::authorization::canonical_worker_operation(&permission.operation)
.unwrap_or(&permission.operation);
if let Ok(key) = PermissionKey::parse(operation.to_owned()) { if let Ok(key) = PermissionKey::parse(operation.to_owned()) {
if allowed.allowed.contains_key(&key) { if allowed.allowed.contains_key(&key) {
represented.insert(key); represented.insert(key);
@@ -259,12 +362,16 @@ impl AdoptedSubjectPolicy {
} }
Ok(output) Ok(output)
}; };
let empty = SubjectPermissions { change_id:self.change_id.clone(),execution:vec![],delegation:vec![] }; let empty = SubjectPermissions {
change_id: self.change_id.clone(),
execution: vec![],
delegation: vec![],
};
let retained = self.legacy_constraints.as_ref().unwrap_or(&empty); let retained = self.legacy_constraints.as_ref().unwrap_or(&empty);
Ok(SubjectPermissions { Ok(SubjectPermissions {
change_id:self.change_id.clone(), change_id: self.change_id.clone(),
execution:filter(execution.projection(),&retained.execution)?, execution: filter(execution.projection(), &retained.execution)?,
delegation:filter(delegation.projection(),&retained.delegation)?, delegation: filter(delegation.projection(), &retained.delegation)?,
}) })
} }
} }
+33 -17
View File
@@ -60,6 +60,8 @@ mod plugin_host;
#[path = "server_plugin_runtime.rs"] #[path = "server_plugin_runtime.rs"]
mod plugin_runtime; mod plugin_runtime;
pub use plugin_delivery::InProcessPluginArtifactSource; pub use plugin_delivery::InProcessPluginArtifactSource;
#[path = "server_configuration_adoption.rs"]
mod configuration_adoption;
#[path = "server_workspace_config.rs"] #[path = "server_workspace_config.rs"]
mod workspace_config; mod workspace_config;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
@@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool {
}; };
resource == "config/source-tree" resource == "config/source-tree"
|| resource.starts_with("config/source-tree/") || resource.starts_with("config/source-tree/")
|| resource == "config/adoption"
|| resource.starts_with("config/adoption/")
|| resource == "settings/profiles" || resource == "settings/profiles"
|| resource.starts_with("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_auth_contract_router(contract_service.clone()))
.merge(generated_workspace_catalog_contract_router( .merge(generated_workspace_catalog_contract_router(
contract_service, contract_service,
)); ))
.merge(configuration_adoption::routes(api.clone()));
catalog.layer(axum::middleware::from_fn_with_state( catalog.layer(axum::middleware::from_fn_with_state(
api, api,
enforce_server_cookie_mutation_origin, enforce_server_cookie_mutation_origin,
@@ -4636,6 +4641,9 @@ impl WorkspaceApi {
crate::profile_settings::ProfileConfigSchemaProvider, crate::profile_settings::ProfileConfigSchemaProvider,
)) ))
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .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(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider))
@@ -16486,22 +16494,13 @@ async fn scoped_get_workspace_config_entry(
} }
async fn scoped_commit_workspace_config_tree( async fn scoped_commit_workspace_config_tree(
State(api): State<WorkspaceApi>, State(_api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(_path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<server_api::ConfigCommitRequest>, Json(_request): Json<server_api::ConfigCommitRequest>,
) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> { ) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> {
api.host_executors Err(Error::WorkspaceConfigConflict(
.host()? "source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(),
.run(move || { ).into())
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
} }
/// Shared canonical commit and activation boundary for UI and logical config operations. /// Shared canonical commit and activation boundary for UI and logical config operations.
@@ -24198,11 +24197,27 @@ where
executor.run(move || runtime.block_on(future)).await executor.run(move || runtime.block_on(future)).await
} }
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> { fn open_legacy_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|error| ApiError::from(Error::Store(error.to_string()))) .map_err(|error| ApiError::from(Error::Store(error.to_string())))
} }
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubjektivWorkerAuthority { enum SubjektivWorkerAuthority {
Subject, Subject,
@@ -40388,6 +40403,7 @@ mod tests {
mod workspace_admission_tests; mod workspace_admission_tests;
mod workspace_protocol_backpressure_tests; mod workspace_protocol_backpressure_tests;
include!("server_workspace_config_tests.rs"); include!("server_workspace_config_tests.rs");
include!("server_configuration_adoption_tests.rs");
use super::*; use super::*;
use axum::body::{Body, to_bytes}; use axum::body::{Body, to_bytes};
use axum::http::Request; use axum::http::Request;
@@ -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<T: Send + 'static>(
service: &WorkspaceServerApi,
context: ServerRequestContext,
workspace: String,
operation: impl FnOnce(WorkspaceApi, String) -> Result<T> + Send + 'static,
) -> std::result::Result<T, RepositoryApiError> {
// 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<T> = 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<AdoptionRequest> {
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<PermissionHost> {
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<SubjektivStore> {
// 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<ConfigurationCurrent> {
api.config_store
.configuration_current(&api.config.workspace_id, account)
}
fn saved_result(
api: &WorkspaceApi,
account: &str,
request: &AdoptionRequest,
) -> Result<Option<AdoptionResult>> {
// 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<Option<PermissionTransferReceipt>> {
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<MigrationSeed>,
}
fn prepare(api: &WorkspaceApi, account: &str, request: &AdoptionRequest) -> Result<Preparation> {
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<ConfigurationSourceResponse, RepositoryApiError> {
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<ConfigurationPreviewResponse, RepositoryApiError> {
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<ConfigurationAdoptionReceipt, RepositoryApiError> {
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<ConfigurationReconciliation, RepositoryApiError> {
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
}
}
@@ -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<Value>,
) -> Request<Body> {
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<Value>,
origin: Option<&str>,
) -> Request<Body> {
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<Body>) -> (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::<std::result::Result<Vec<_>, _>>()?)
}).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<Change>) -> 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(&current);
// Explicit removal makes old-receipt resurrection observable.
let removal = format!(
"{{ subjects = []; remove_subjects = [{:?}]; }}\n",
fixture.subject
);
fixture
.commit(&request(
&current,
"later-policy-removal",
vec![update(&current, &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::<Source>(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();
}
}
}
}
@@ -619,22 +619,28 @@ pub(super) async fn commit(
{ {
return Err(fail(409, "authority_changed")); return Err(fail(409, "authority_changed"));
} }
// Preserve the shared helper's ordering and atomic CAS, but never // Legacy logical writes share the physical authority gate with
// run its remote Runtime publication on the sole DB worker. // initial cutover, in Feature -> Server order. A durable seal
project_repository_access_candidate( // fences this writer even when the Server adoption rolled back.
&*persistence_api.store, let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?;
&persistence_api.repository_secrets, let identity = legacy.database_identity().map_err(|_| unknown())?;
&persistence_api.config.workspace_id, let state = legacy.with_permission_authority_guard(&identity, |authority| {
&candidate, authority.require_unsealed()?;
) Ok((|| {
.map_err(|_| unknown())?; project_repository_access_candidate(
let state = persistence_api &*persistence_api.store,
.config_store &persistence_api.repository_secrets,
.commit_evaluated_workspace_config( &persistence_api.config.workspace_id,
&persistence_api.config.workspace_id, &candidate,
&candidate, ).map_err(|_| unknown())?;
) persistence_api.config_store.commit_evaluated_workspace_config(
.map_err(|_| unknown())?; &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 let publication = persistence_api
.prompt_projection_cache .prompt_projection_cache
.resolve(&persistence_api.config.workspace_id, &state) .resolve(&persistence_api.config.workspace_id, &state)
@@ -112,7 +112,10 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) {
let plan = published_plan(conn, current).unwrap().unwrap(); let plan = published_plan(conn, current).unwrap().unwrap();
assert_eq!(plan.migrations.len(), 1); assert_eq!(plan.migrations.len(), 1);
assert_eq!(plan.migrations[0].version, 122); assert_eq!(plan.migrations[0].version, 122);
assert_eq!(plan.migrations[0].name, crate::config_adoption::MIGRATION_NAME); assert_eq!(
plan.migrations[0].name,
crate::config_adoption::MIGRATION_NAME
);
assert_eq!(plan.target_schema_version, 122); assert_eq!(plan.target_schema_version, 122);
} }
assert_eq!(rows, saved_rows(conn)); assert_eq!(rows, saved_rows(conn));
@@ -200,12 +203,26 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad
assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122); assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122);
for (table, rows) in &expected { for (table, rows) in &expected {
if table != "__yoi_schema_migrations" { if table != "__yoi_schema_migrations" {
assert_eq!(saved_rows(&conn).into_iter().find(|(name, _)| name == table).unwrap().1, *rows, "{table}, from {current}"); assert_eq!(
saved_rows(&conn)
.into_iter()
.find(|(name, _)| name == table)
.unwrap()
.1,
*rows,
"{table}, from {current}"
);
} }
} }
let mut history = super::super::workspace_schema_migration_history(&conn).unwrap(); let mut history = super::super::workspace_schema_migration_history(&conn).unwrap();
assert_eq!(history.pop(), Some((122, crate::config_adoption::MIGRATION_NAME.into()))); assert_eq!(
assert_eq!(history, super::super::workspace_schema_migration_history(&source).unwrap()); history.pop(),
Some((122, crate::config_adoption::MIGRATION_NAME.into()))
);
assert_eq!(
history,
super::super::workspace_schema_migration_history(&source).unwrap()
);
verify_history(&conn, 122).unwrap(); verify_history(&conn, 122).unwrap();
} }
} }
+17 -6
View File
@@ -87,8 +87,13 @@ fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)
return Err(invalid("unrecognized configuration adoption receipt")); return Err(invalid("unrecognized configuration adoption receipt"));
} }
let prefix = &rows[..rows.len() - 1]; let prefix = &rows[..rows.len() - 1];
let prior = prefix.last().map(|row| row.0).ok_or_else(|| invalid("missing configuration predecessor"))?; let prior = prefix
if !(113..=121).contains(&prior) { return Err(invalid("invalid configuration predecessor")); } .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); return verify_history_records(conn, prior, prefix);
} }
let Some((baseline, name)) = rows.first() else { let Some((baseline, name)) = rows.first() else {
@@ -217,7 +222,9 @@ pub(super) fn published_plan(
version: 122, version: 122,
name: crate::config_adoption::MIGRATION_NAME.to_owned(), name: crate::config_adoption::MIGRATION_NAME.to_owned(),
}] }]
} else { vec![] }, } else {
vec![]
},
})) }))
} }
@@ -227,9 +234,13 @@ fn verify_configuration_shape(conn: &Connection) -> Result<()> {
expected_db.execute_batch(crate::config_adoption::SCHEMA)?; expected_db.execute_batch(crate::config_adoption::SCHEMA)?;
let expected = snapshot(&expected_db)?; let expected = snapshot(&expected_db)?;
let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect(); let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect();
let actual: Shape = snapshot(conn)?.into_iter() let actual: Shape = snapshot(conn)?
.filter(|(_, object)| tables.contains(&object.1)).collect(); .into_iter()
if actual != expected { return Err(invalid("configuration adoption receipt/shape mismatch")); } .filter(|(_, object)| tables.contains(&object.1))
.collect();
if actual != expected {
return Err(invalid("configuration adoption receipt/shape mismatch"));
}
Ok(()) Ok(())
} }
+12 -4
View File
@@ -16077,13 +16077,18 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> { fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?; let current = current_schema_version(conn)?;
if !(113..=121).contains(&current) { if !(113..=121).contains(&current) {
return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into())); return Err(Error::Store(
"Configuration adoption migration requires a recognized schema 113..121".into(),
));
} }
storage_formats::verify_history(conn, current)?; storage_formats::verify_history(conn, current)?;
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
tx.execute_batch(content_history_store::SCHEMA)?; tx.execute_batch(content_history_store::SCHEMA)?;
tx.execute_batch(crate::config_adoption::SCHEMA)?; tx.execute_batch(crate::config_adoption::SCHEMA)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", [crate::config_adoption::MIGRATION_NAME])?; tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)",
[crate::config_adoption::MIGRATION_NAME],
)?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -18904,12 +18909,15 @@ mod tests {
#[test] #[test]
fn current_schema_accepts_every_retained_canonical_provenance() { fn current_schema_accepts_every_retained_canonical_provenance() {
for baseline_version in (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) { for baseline_version in
(OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION))
{
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap(); configure_sqlite(&conn).unwrap();
ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
merge_request::migrate(&conn).unwrap(); merge_request::migrate(&conn).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
.unwrap();
create_latest_workspace_schema(&conn).unwrap(); create_latest_workspace_schema(&conn).unwrap();
conn.execute( conn.execute(
"INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)",
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -420,6 +420,17 @@
"error": { "error": {
"type": "string" "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": { "message": {
"type": "string" "type": "string"
} }
@@ -449,6 +460,10 @@
], ],
"type": "object" "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": { "ValidatePermissionsRequest": {
"additionalProperties": false, "additionalProperties": false,
"description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", "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": { "info": {
"title": "Feature permission declaration contract", "title": "Feature permission declaration contract",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" "x-yoi-source-digest": "sha256:9a4f2ec1ab277c73bf8c69b2adef050dbae26cff5430ff2bd1201371feef5a38"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "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", "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", "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: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: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", "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",
@@ -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<ConfigurationSubjectPolicy>, migration_diagnostics: Array<ConfigurationMigrationDiagnostic>, };
export type ConfigurationPreviewResponse = { expected_head: string | null, state: WorkspaceConfigTreeResponse, changed_paths: Array<string>, changed_subjects: Array<string>, };
export type ConfigurationReconciliation = { "status": "adopted", receipt: ConfigurationAdoptionReceipt, } | { "status": "pending_transfer", transfer_identity: string, export_digest: string, } | { "status": "not_observed" };
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
import type { WorkspaceConfigTreeResponse } from "./types.ts";
const toolchain = vi.hoisted(() => ({ const toolchain = vi.hoisted(() => ({
construct: vi.fn(), construct: vi.fn(),
@@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({
})); }));
const schemaSource = "schema body\n".repeat(6000); const schemaSource = "schema body\n".repeat(6000);
const tree = { const tree: WorkspaceConfigTreeResponse = {
snapshot: { snapshot: {
digest: "sha256:tree", digest: "sha256:tree",
entries: { entries: {
@@ -64,7 +65,27 @@ const tree = {
projection_digest: "sha256:projection", projection_digest: "sha256:projection",
}; };
const fetcher = vi.fn(); 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(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation
finishSetup(); finishSetup();
await expectReady(view); await expectReady(view);
expect(fetcher).toHaveBeenCalledWith( expect(fetcher).toHaveBeenCalledWith(
"/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/adoption",
expect.anything(), expect.anything(),
); );
}); });
@@ -140,7 +161,7 @@ test.each(["http", "invalid"])(
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
failure === "http" failure === "http"
? new Response("Backend unavailable", { status: 503 }) ? new Response("Backend unavailable", { status: 503 })
: new Response(JSON.stringify({ ...tree, unexpected: true })), : new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })),
); );
const view = renderEditor(); const view = renderEditor();
await waitFor(() => await waitFor(() =>
@@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)(
test("a successfully loaded empty tree still offers source creation", async () => { test("a successfully loaded empty tree still offers source creation", async () => {
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({ new Response(JSON.stringify(sourceResponse({
...tree, ...tree,
snapshot: { ...tree.snapshot, entries: {} }, snapshot: { ...tree.snapshot, entries: {} },
})), }))),
); );
const view = renderEditor(); const view = renderEditor();
await waitFor(() => 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"); 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" } as const;
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" }; const candidate = {
const candidate = { digest: "sha256:local-draft",
digest: "sha256:local-draft", entries: {
entries: { ...tree.snapshot.entries,
...tree.snapshot.entries, "module.dcdl": {
"module.dcdl": { path: change.path, content_type: change.content_type, content: change.content,
path: change.path, content_digest: "sha256:module",
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.applyChanges.mockResolvedValue(candidate);
toolchain.changesBetween.mockResolvedValue([change]); toolchain.changesBetween.mockResolvedValue([change]);
toolchain.format.mockImplementation(async (source: string) => source); toolchain.format.mockImplementation(async (source: string) => source);
const view = renderEditor(); const view = renderEditor();
await expectReady(view); await expectReady(view);
await fireEvent.click(view.getByRole("button", { name: "Create local source" })); 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" }); const commit = view.getByRole("button", { name: "Commit" });
expect(commit).toBeInstanceOf(HTMLButtonElement);
fetcher.mockResolvedValueOnce(new Response( fetcher.mockResolvedValueOnce(new Response(
status === 409 ? "base content digest mismatch" : "candidate evaluation failed", status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status },
{ status },
)); ));
await fireEvent.click(commit); await fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain( 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 = { const first = requestAt(1);
base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"], expect(first).toEqual({
}; operation_id: expect.any(String), expected_head: "head:observed",
expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest); 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(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
expect(commit).toHaveProperty("disabled", false); expect(commit).toHaveProperty("disabled", false);
expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate"); 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 fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); 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(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle);
expect(commit).toHaveProperty("disabled", true); 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();
});
@@ -1,15 +1,22 @@
<script lang="ts"> <script lang="ts">
// commitの条件は保存したbase_digestと各entryのexpected_digest。時刻や要求順では // 内容digestとは別に、GETで観測した履歴headと移行条件を編集元として保持する。
// 編集元の内容を特定できないため、成功時だけ返されたsnapshotを新しいbaseにする。 // 応答喪失時は依頼全体を固定し、照合で採用を確認するまで別IDへ切り替えない。
// Server拒否時はdraftを残し、reload/reapplyを明示的な判断にする。
// 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と // 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と
// 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。 // 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。
import { onMount } from "svelte"; import { onMount } from "svelte";
import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte"; import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
import { import {
commitConfigTree, commitConfigTree,
ConfigSourceApiError,
fetchConfigTree, fetchConfigTree,
previewConfigTree,
reconcileConfigTree,
} from "./api.ts"; } from "./api.ts";
import type { ConfigurationEditingState } from "./api.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
} from "#lib/generated/configuration-adoption.generated.ts";
import { ConfigSourceToolchain } from "./toolchain.ts"; import { ConfigSourceToolchain } from "./toolchain.ts";
import type { import type {
ConfigDiagnostic, ConfigDiagnostic,
@@ -31,6 +38,13 @@
let busy = $state(false); let busy = $state(false);
let workingChanges = $state<ConfigTreeChange[]>([]); let workingChanges = $state<ConfigTreeChange[]>([]);
let baseDigest = $state(""); let baseDigest = $state("");
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
let editingWorkspaceId = $state("");
let pendingAdoption = $state.raw<{
workspaceId: string;
request: ConfigurationAdoptionRequest;
draftSignature: string;
} | null>(null);
let renamePath = $state(""); let renamePath = $state("");
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null); let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let conflict = $state(false); let conflict = $state(false);
@@ -73,7 +87,7 @@
void analyzer.analyze(path, value).then((result) => { void analyzer.analyze(path, value).then((result) => {
if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest); if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest);
}).catch((error) => { }).catch((error) => {
if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`; if (!request.signal.aborted && !pendingAdoption && !busy) status = `Analyze failed: ${String(error)}`;
}); });
}, 250); }, 250);
return () => { return () => {
@@ -83,20 +97,28 @@
}); });
async function reload() { async function reload() {
if (pendingAdoption) {
status = "Adoption outcome unknown. Reconcile the retained request before reloading.";
return;
}
loadState = "loading"; loadState = "loading";
status = "Loading source tree…"; status = "Loading source tree…";
analysisReady = false; analysisReady = false;
analysisRequest?.abort(); analysisRequest?.abort();
const requestedWorkspace = workspaceId;
try { try {
const remote = await fetchConfigTree(workspaceId); const observed = await fetchConfigTree(requestedWorkspace);
if (!mounted) return; const remote = observed.state;
if (!mounted || workspaceId !== requestedWorkspace) return;
toolchain?.close(); toolchain?.close();
toolchain = null; toolchain = null;
toolchain = new ConfigSourceToolchain(); toolchain = new ConfigSourceToolchain();
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
if (!mounted) return; if (!mounted || workspaceId !== requestedWorkspace) return;
// Publish the new baseline only after both fetch and toolchain setup succeed. // Publish the new baseline only after both fetch and toolchain setup succeed.
treeState = remote; treeState = remote;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
} }
@@ -204,52 +226,174 @@
function recordCommitError(error: unknown) { function recordCommitError(error: unknown) {
const message = String(error); const message = String(error);
conflict = message.includes("conflict") || message.includes("base content digest mismatch"); conflict = error instanceof ConfigSourceApiError && error.status === 409;
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
} }
async function commit() { function draftSignature(): string {
if (!treeState) return; const entries = new Map(Object.entries(treeState?.snapshot.entries ?? {}).map(
([path, entry]) => [path, [entry.content_type, entry.content]],
));
if (selectedPath) entries.set(selectedPath, [selected?.content_type ?? "decodal", source]);
return JSON.stringify([...entries].sort(([left], [right]) => left.localeCompare(right)));
}
async function applyReceipt(receipt: ConfigurationAdoptionReceipt) {
const edited = pendingAdoption !== null && draftSignature() !== pendingAdoption.draftSignature;
const local = edited && treeState ? $state.snapshot(treeState.snapshot) : null;
const localSource = source;
// receipt確定後のtoolchain障害は採用失敗ではない。headと確定状態を先に更新する。
treeState = receipt.state;
editingMetadata = {
state: receipt.state,
history_head: receipt.history_head,
catalog_digest: editingMetadata!.catalog_digest,
expected_legacy_export: null,
source_stored: true,
};
baseSnapshot = $state.snapshot(receipt.state.snapshot);
baseDigest = receipt.state.snapshot.digest;
pendingAdoption = null;
workingChanges = [];
conflict = false;
analysisReady = false;
diagnostics = [];
if (local) treeState = { ...receipt.state, snapshot: local };
if (!edited && !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
source = edited ? localSource : treeState.snapshot.entries[selectedPath]?.content ?? "";
try {
if (local && toolchain) workingChanges = await toolchain.changesBetween(baseSnapshot, local);
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
analysisReady = true;
status = edited
? "Adoption confirmed. Later local edits remain unsaved; Commit to adopt them separately."
: `Committed formatted source tree · ${receipt.state.snapshot.digest.slice(0, 20)}…`;
} catch (error) {
status = `Adoption confirmed, but local editor setup failed: ${String(error)}`;
}
}
async function reconcilePending(): Promise<"adopted" | "unresolved" | "unavailable"> {
const pending = pendingAdoption;
if (!pending) return "adopted";
try {
const result = await reconcileConfigTree(pending.workspaceId, pending.request);
if (result.status === "adopted") {
await applyReceipt(result.receipt);
return "adopted";
}
status = result.status === "pending_transfer"
? "Adoption outcome unknown: transfer pending. Commit resumes the same request; reconcile before adopting edits."
: "Adoption outcome unknown: not observed does not mean failed. Commit resumes the same request; reconcile before adopting edits.";
return "unresolved";
} catch (error) {
status = `Adoption outcome unknown. Retained request must be reconciled: ${String(error)}`;
return "unavailable";
}
}
async function submitPending(wasUnknown: boolean) {
const pending = pendingAdoption;
if (!pending) return;
let receipt: ConfigurationAdoptionReceipt;
try {
receipt = await commitConfigTree(pending.workspaceId, pending.request);
} catch (error) {
// 明確な初回拒否と、既に結果不明の依頼に対する拒否は区別する。
if (!wasUnknown && error instanceof ConfigSourceApiError &&
[400, 401, 403, 404, 409, 422].includes(error.status)) {
pendingAdoption = null;
recordCommitError(error);
} else {
status = `Adoption outcome unknown: ${String(error)}`;
await reconcilePending();
}
return;
}
await applyReceipt(receipt);
}
async function reconcile() {
if (busy) return;
busy = true; busy = true;
try { try {
await reconcilePending();
} finally {
busy = false;
}
}
async function commit() {
if (!treeState || !editingMetadata || busy || !analysisReady) return;
busy = true;
try {
if (pendingAdoption) {
const edited = draftSignature() !== pendingAdoption.draftSignature;
const outcome = await reconcilePending();
if (outcome !== "unresolved") return;
if (edited) {
status = "Adoption outcome unknown. Draft changed; reconcile the original request before adopting edits.";
return;
}
// 明示的な次のCommitだけが、保存済みの同一依頼を再送できる。
await submitPending(true);
return;
}
if (editingWorkspaceId !== workspaceId) {
status = "Workspace changed. Reload the authoritative source tree before adopting edits.";
return;
}
const adoptionWorkspaceId = editingWorkspaceId;
await formatWorkingSources(); await formatWorkingSources();
if (workingChanges.length === 0) { if (workingChanges.length === 0 && editingMetadata.source_stored) {
status = "No working changes to commit."; status = "No working changes to commit.";
return; return;
} }
treeState = await commitConfigTree(workspaceId, { const request: ConfigurationAdoptionRequest = {
base_digest: baseDigest, operation_id: crypto.randomUUID(),
changes: workingChanges, expected_head: editingMetadata.history_head,
entrypoints: entrypoints(), expected_catalog_digest: editingMetadata.catalog_digest,
}); expected_legacy_export: editingMetadata.expected_legacy_export,
workingChanges = []; source: {
conflict = false; base_digest: baseDigest,
baseSnapshot = $state.snapshot(treeState.snapshot); changes: $state.snapshot(workingChanges),
baseDigest = treeState.snapshot.digest; entrypoints: entrypoints(),
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); },
source = treeState.snapshot.entries[selectedPath]?.content ?? ""; };
diagnostics = []; const signature = draftSignature();
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`; await previewConfigTree(adoptionWorkspaceId, request);
pendingAdoption = { workspaceId: adoptionWorkspaceId, request, draftSignature: signature };
await submitPending(false);
} catch (error) { } catch (error) {
recordCommitError(error); if (pendingAdoption) status = `Adoption outcome unknown: ${String(error)}`;
else recordCommitError(error);
} finally { } finally {
busy = false; busy = false;
} }
} }
async function discardAndReload() { async function discardAndReload() {
if (busy) return;
await reload(); await reload();
} }
async function reloadAndReapply() { async function reloadAndReapply() {
if (!toolchain || !treeState) return; if (!toolchain || !treeState || busy || pendingAdoption) return;
const localChanges = [...workingChanges]; busy = true;
const remote = await fetchConfigTree(workspaceId); const requestedWorkspace = workspaceId;
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
try { try {
await stageCurrent();
const localChanges = $state.snapshot(workingChanges);
const observed = await fetchConfigTree(requestedWorkspace);
const remote = observed.state;
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
const candidate = await toolchain.applyChanges(localChanges); const candidate = await toolchain.applyChanges(localChanges);
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
workingChanges = localChanges; workingChanges = localChanges;
treeState = { ...remote, snapshot: candidate }; treeState = { ...remote, snapshot: candidate };
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? ""; selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
@@ -258,7 +402,14 @@
status = "Local changes reapplied to the latest source tree. Commit to persist them."; status = "Local changes reapplied to the latest source tree. Commit to persist them.";
} catch (error) { } catch (error) {
conflict = true; conflict = true;
try {
await toolchain.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
} catch {
analysisReady = false;
}
status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`; status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
} finally {
busy = false;
} }
} }
@@ -331,6 +482,7 @@
<button <button
type="button" type="button"
class:active={path === selectedPath} class:active={path === selectedPath}
disabled={busy}
onclick={() => select(path)} onclick={() => select(path)}
> >
<span>{path}</span> <span>{path}</span>
@@ -340,8 +492,8 @@
</nav> </nav>
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}> <form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label> <label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" /> <input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" disabled={busy} />
<button type="submit">Create local source</button> <button type="submit" disabled={busy}>Create local source</button>
</form> </form>
</aside> </aside>
@@ -355,7 +507,7 @@
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} /> <input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button> <button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button> <button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button> <button class="primary" type="button" onclick={commit} disabled={(!dirty && !pendingAdoption && editingMetadata?.source_stored !== false) || busy || !analysisReady}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button> <button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div> </div>
</header> </header>
@@ -369,10 +521,16 @@
: Promise.resolve(null)} : Promise.resolve(null)}
/> />
<p class="config-source-status" aria-live="polite">{status}</p> <p class="config-source-status" aria-live="polite">{status}</p>
{#if conflict} {#if pendingAdoption}
<div class="config-source-conflict" role="status">
<span>Adoption outcome unknown. Commit resumes the unchanged request; edited drafts require reconciliation first.</span>
<button type="button" onclick={reconcile} disabled={busy}>Reconcile adoption</button>
</div>
{/if}
{#if conflict && !pendingAdoption}
<div class="config-source-conflict" role="alert"> <div class="config-source-conflict" role="alert">
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button> <button type="button" onclick={discardAndReload} disabled={busy}>Discard local candidate and reload</button>
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button> <button type="button" onclick={reloadAndReapply} disabled={busy}>Reload and reapply local candidate</button>
</div> </div>
{/if} {/if}
{#if diagnostics.length > 0} {#if diagnostics.length > 0}
@@ -0,0 +1,143 @@
/// <reference lib="deno.ns" />
import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert";
import {
commitConfigTree,
ConfigSourceApiError,
fetchConfigTree,
parseConfigurationAdoptionReceipt,
parseConfigurationReconciliation,
parseConfigurationSourceResponse,
previewConfigTree,
reconcileConfigTree,
} from "./api.ts";
import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts";
import type { WorkspaceConfigTreeResponse } from "./types.ts";
const state: WorkspaceConfigTreeResponse = {
snapshot: { digest: "sha256:content-A", entries: {} },
contract: {
contract_version: 1, decodal_version: "0.4.0", schema_version: 1,
entrypoints: ["main.dcdl"], import_policy_version: 1,
schema_bundle: { contributions: [], source: "builtin", fingerprint: "sha256:schema" },
fingerprint: "sha256:contract",
},
projection_digest: "sha256:projection",
};
const source = {
state, history_head: null, source_stored: false,
catalog: { workspace: "w/one", namespaces: [] },
catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy",
policies: [], migration_diagnostics: [],
};
const request: ConfigurationAdoptionRequest = {
operation_id: "operation:one", expected_head: "head:A-after-B",
expected_catalog_digest: source.catalog_digest,
expected_legacy_export: source.expected_legacy_export,
source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] },
};
const receipt = {
workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted",
state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
};
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const replies = (...bodies: unknown[]) =>
(() => Promise.resolve(json(bodies.shift()))) as typeof fetch;
Deno.test("adoption source parser retains nullable observed metadata separately from content identity", () => {
const parsed = parseConfigurationSourceResponse(source);
assertEquals(parsed.state, state);
assertEquals(parsed.history_head, null);
assertEquals(parsed.source_stored, false);
assertEquals(parsed.expected_legacy_export, "sha256:legacy");
// A -> B -> A can retain content-A while having a different editing head.
assertEquals(parseConfigurationSourceResponse({ ...source, history_head: "head:A-after-B" }).history_head, request.expected_head);
});
Deno.test("adoption source parser rejects missing metadata, unknown envelope fields and unbounded opaque collections", () => {
const { history_head: _head, ...missingHead } = source;
const { expected_legacy_export: _export, ...missingExport } = source;
for (const invalid of [
state, missingHead, missingExport,
{ ...source, unexpected: true }, { ...source, history_head: 1 },
{ ...source, catalog_digest: "x".repeat(257) },
{ ...source, expected_legacy_export: false }, { ...source, source_stored: null },
{ ...source, policies: {} }, { ...source, migration_diagnostics: Array(257).fill({}) },
{ ...source, catalog: { namespaces: Array(257).fill({}) } },
{ ...source, state: { ...state, snapshot: { ...state.snapshot, unexpected: true } } },
]) assertThrows(() => parseConfigurationSourceResponse(invalid), ConfigSourceApiError);
});
Deno.test("adoption transport posts the full unchanged request to owner adoption routes, including reconcile", async () => {
const calls: Array<{ url: string; body?: BodyInit | null }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, body: init?.body });
const body = url.endsWith("/preview")
? { expected_head: request.expected_head, state, changed_paths: [], changed_subjects: [] }
: url.endsWith("/commit") ? receipt
: url.endsWith("/reconcile") ? { status: "adopted", receipt }
: source;
return Promise.resolve(json(body));
}) as typeof fetch;
await fetchConfigTree("w/one", fetcher);
await previewConfigTree("w/one", request, fetcher);
await commitConfigTree("w/one", request, fetcher);
await reconcileConfigTree("w/one", request, fetcher);
assertEquals(calls.map(({ url }) => url), [
"/api/w/w%2Fone/config/adoption", "/api/w/w%2Fone/config/adoption/preview",
"/api/w/w%2Fone/config/adoption/commit", "/api/w/w%2Fone/config/adoption/reconcile",
]);
for (const call of calls.slice(1)) assertEquals(JSON.parse(String(call.body)), request);
});
Deno.test("preview does not refresh an editing head from an unexpected response", async () => {
await assertRejects(() => previewConfigTree("w/one", request, replies({
expected_head: "head:new", state, changed_paths: [], changed_subjects: [],
})), ConfigSourceApiError, "invalid response");
assertEquals(request.expected_head, "head:A-after-B");
});
Deno.test("receipt requires committed source and policy and matches the requested workspace and operation", async () => {
for (const invalid of [
{ ...receipt, source_stored: false }, { ...receipt, policy_committed: false },
{ ...receipt, runtime_application: "already_running" },
{ ...receipt, history_head: null }, { ...receipt, extra: true },
]) assertThrows(() => parseConfigurationAdoptionReceipt(invalid), ConfigSourceApiError);
for (const invalid of [
{ ...receipt, workspace_id: "other" }, { ...receipt, operation_id: "other" },
]) {
await assertRejects(() => commitConfigTree("w/one", request, replies(invalid)), ConfigSourceApiError);
await assertRejects(() => reconcileConfigTree("w/one", request, replies({ status: "adopted", receipt: invalid })), ConfigSourceApiError);
}
});
Deno.test("not_observed and pending_transfer remain distinct unknown evidence, never a failed-save receipt", () => {
for (const evidence of [
{ status: "not_observed" },
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
{ status: "adopted", receipt },
]) assertEquals(parseConfigurationReconciliation(evidence), evidence);
for (const invalid of [
{ status: "failed" }, { status: "not_observed", receipt },
{ status: "pending_transfer", transfer_identity: "transfer:one" },
{ status: "adopted" },
]) assertThrows(() => parseConfigurationReconciliation(invalid), ConfigSourceApiError);
});
Deno.test("lost or invalid responses do not retry or change request identity inside the adapter", async () => {
for (const failure of ["network", "malformed", "oversized", "server"]) {
let calls = 0;
const fetcher = (() => {
calls++;
if (failure === "network") return Promise.reject(new TypeError("response lost"));
return Promise.resolve(failure === "malformed" ? new Response("not JSON")
: failure === "oversized" ? new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } })
: new Response("outcome unavailable", { status: 500 }));
}) as typeof fetch;
await assertRejects(() => commitConfigTree("w/one", request, fetcher));
assertEquals(calls, 1);
assertEquals(request.operation_id, "operation:one");
assertEquals(request.expected_head, "head:A-after-B");
}
});
@@ -4,7 +4,6 @@
* Browserの解析成功はServerのcommit受理を代替しない。 * Browserの解析成功はServerのcommit受理を代替しない。
*/ */
import type { import type {
ConfigCommitRequest,
ConfigContentType, ConfigContentType,
ConfigEntry, ConfigEntry,
ConfigProjectionValidator, ConfigProjectionValidator,
@@ -15,6 +14,20 @@ import type {
WorkspaceConfigTreeResponse, WorkspaceConfigTreeResponse,
} from "./types.ts"; } from "./types.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
ConfigurationPreviewResponse,
ConfigurationReconciliation,
ConfigurationSourceResponse,
} from "#lib/generated/configuration-adoption.generated.ts";
// 未使用のpolicy/catalogを認可判定へ転用しない。editorが使う観測値だけを型へ変換する。
export type ConfigurationEditingState = Pick<
ConfigurationSourceResponse,
"state" | "history_head" | "catalog_digest" | "expected_legacy_export" | "source_stored"
>;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// Schema bodies are documents, not short metadata. Keep them within the // Schema bodies are documents, not short metadata. Keep them within the
// response budget; readJson also bounds the combined bundle and contributions. // response budget; readJson also bounds the combined bundle and contributions.
@@ -277,15 +290,138 @@ export function parseWorkspaceConfigTreeResponse(
}; };
} }
function adoptionUrl(workspaceId: string): string {
return `/api/w/${encodeURIComponent(workspaceId)}/config/adoption`;
}
function nullableDigest(value: unknown): string | null {
return value === null ? null : boundedString(value, MAX_DIGEST_BYTES);
}
// 非表示のpolicy/catalogも大きさと深さを制限するが、その内部を型付け・再解釈しない。
function checkOpaqueData(value: unknown, depth = 0, budget = { nodes: 65536 }): void {
if (depth > 16 || --budget.nodes < 0) throw schemaError();
if (value === null || typeof value === "boolean") return;
if (typeof value === "string") {
boundedString(value);
return;
}
if (typeof value === "number" && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const child of boundedArray(value)) checkOpaqueData(child, depth + 1, budget);
return;
}
const entries = Object.entries(objectValue(value));
if (entries.length > MAX_COLLECTION_ITEMS) throw schemaError();
for (const [key, child] of entries) {
boundedString(key);
checkOpaqueData(child, depth + 1, budget);
}
}
export function parseConfigurationSourceResponse(value: unknown): ConfigurationEditingState {
const item = record(value, [
"state", "history_head", "source_stored", "catalog", "catalog_digest",
"expected_legacy_export", "policies", "migration_diagnostics",
]);
if (typeof item.source_stored !== "boolean") throw schemaError();
objectValue(item.catalog);
boundedArray(item.policies);
boundedArray(item.migration_diagnostics);
checkOpaqueData([item.catalog, item.policies, item.migration_diagnostics]);
return {
state: parseWorkspaceConfigTreeResponse(item.state),
history_head: nullableDigest(item.history_head),
source_stored: item.source_stored,
catalog_digest: boundedString(item.catalog_digest, MAX_DIGEST_BYTES),
expected_legacy_export: nullableDigest(item.expected_legacy_export),
};
}
export function parseConfigurationPreviewResponse(value: unknown): ConfigurationPreviewResponse {
const item = record(value, ["expected_head", "state", "changed_paths", "changed_subjects"]);
return {
expected_head: nullableDigest(item.expected_head),
state: parseWorkspaceConfigTreeResponse(item.state),
changed_paths: boundedArray(item.changed_paths).map((path) => boundedString(path, MAX_PATH_BYTES)),
changed_subjects: boundedArray(item.changed_subjects).map((subject) => boundedString(subject)),
};
}
export function parseConfigurationAdoptionReceipt(value: unknown): ConfigurationAdoptionReceipt {
const item = record(value, [
"workspace_id", "operation_id", "history_head", "state", "source_stored",
"policy_committed", "runtime_application",
]);
if (item.source_stored !== true || item.policy_committed !== true ||
item.runtime_application !== "next_current_policy_check") throw schemaError();
return {
workspace_id: boundedString(item.workspace_id),
operation_id: boundedString(item.operation_id),
history_head: boundedString(item.history_head, MAX_DIGEST_BYTES),
state: parseWorkspaceConfigTreeResponse(item.state),
source_stored: true,
policy_committed: true,
runtime_application: "next_current_policy_check",
};
}
export function parseConfigurationReconciliation(value: unknown): ConfigurationReconciliation {
const status = objectValue(value).status;
if (status === "adopted") {
const item = record(value, ["status", "receipt"]);
return { status, receipt: parseConfigurationAdoptionReceipt(item.receipt) };
}
if (status === "pending_transfer") {
const item = record(value, ["status", "transfer_identity", "export_digest"]);
return {
status,
transfer_identity: boundedString(item.transfer_identity),
export_digest: boundedString(item.export_digest, MAX_DIGEST_BYTES),
};
}
if (status === "not_observed") {
record(value, ["status"]);
return { status };
}
throw schemaError();
}
function matchingReceipt(
receipt: ConfigurationAdoptionReceipt,
workspaceId: string,
request: ConfigurationAdoptionRequest,
): ConfigurationAdoptionReceipt {
if (receipt.workspace_id !== workspaceId || receipt.operation_id !== request.operation_id) throw schemaError();
return receipt;
}
async function postAdoption<T>(
workspaceId: string,
action: "preview" | "commit" | "reconcile",
request: ConfigurationAdoptionRequest,
parser: Parser<T>,
fetcher: typeof fetch,
): Promise<T> {
return await readJson(
await fetcher(`${adoptionUrl(workspaceId)}/${action}`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(request),
}),
parser,
);
}
export async function fetchConfigTree( export async function fetchConfigTree(
workspaceId: string, workspaceId: string,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationEditingState> {
return await readJson( return await readJson(
await fetcher(sourceTreeUrl(workspaceId), { await fetcher(adoptionUrl(workspaceId), {
headers: { accept: "application/json" }, headers: { accept: "application/json" },
}), }),
parseWorkspaceConfigTreeResponse, parseConfigurationSourceResponse,
); );
} }
@@ -318,30 +454,32 @@ export async function fetchConfigHistory(
export async function previewConfigTree( export async function previewConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationPreviewResponse> {
return await readJson( const preview = await postAdoption(workspaceId, "preview", request, parseConfigurationPreviewResponse, fetcher);
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, { if (preview.expected_head !== request.expected_head) throw schemaError();
method: "POST", return preview;
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
);
} }
export async function commitConfigTree( export async function commitConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationAdoptionReceipt> {
return await readJson( return matchingReceipt(
await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, { await postAdoption(workspaceId, "commit", request, parseConfigurationAdoptionReceipt, fetcher),
method: "POST", workspaceId,
headers: { "content-type": "application/json" }, request,
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
); );
} }
export async function reconcileConfigTree(
workspaceId: string,
request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch,
): Promise<ConfigurationReconciliation> {
const result = await postAdoption(workspaceId, "reconcile", request, parseConfigurationReconciliation, fetcher);
if (result.status === "adopted") matchingReceipt(result.receipt, workspaceId, request);
return result;
}
+153 -63
View File
@@ -8,13 +8,24 @@ import {
} from "jsr:@std/assert"; } from "jsr:@std/assert";
import { import {
commitConfigTree, commitConfigTree,
type ConfigurationEditingState,
ConfigSourceApiError, ConfigSourceApiError,
fetchConfigEntry, fetchConfigEntry,
fetchConfigHistory, fetchConfigHistory,
fetchConfigTree, fetchConfigTree,
parseWorkspaceConfigTreeResponse, parseWorkspaceConfigTreeResponse,
previewConfigTree, previewConfigTree,
reconcileConfigTree,
} from "../../src/lib/workspace/config-source/api.ts"; } from "../../src/lib/workspace/config-source/api.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
ConfigurationPreviewResponse,
} from "../../src/lib/generated/configuration-adoption.generated.ts";
import type {
ConfigEntry,
WorkspaceConfigTreeResponse,
} from "../../src/lib/workspace/config-source/types.ts";
function response(body: unknown, status = 200): Response { function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -23,7 +34,7 @@ function response(body: unknown, status = 200): Response {
}); });
} }
const entry = { const entry: ConfigEntry = {
path: "profiles/main.dcdl", path: "profiles/main.dcdl",
content_type: "decodal", content_type: "decodal",
content: "profile = {}", content: "profile = {}",
@@ -33,7 +44,7 @@ const snapshot = {
digest: "sha256:tree", digest: "sha256:tree",
entries: { "profiles/main.dcdl": entry }, entries: { "profiles/main.dcdl": entry },
}; };
const tree = { const tree: WorkspaceConfigTreeResponse = {
snapshot, snapshot,
contract: { contract: {
contract_version: 1, contract_version: 1,
@@ -51,38 +62,119 @@ const tree = {
projection_digest: "sha256:projection", projection_digest: "sha256:projection",
}; };
Deno.test("config source API commits directly through the workspace scope", async () => { function editingState(
state: WorkspaceConfigTreeResponse = tree,
): ConfigurationEditingState {
return {
state,
history_head: "head:observed",
catalog_digest: "sha256:catalog",
expected_legacy_export: "sha256:legacy",
source_stored: false,
};
}
function sourceResponse(state: unknown = tree) {
return {
...editingState(),
state,
catalog: {},
policies: [],
migration_diagnostics: [],
};
}
function adoptionRequest(
source: ConfigurationAdoptionRequest["source"] = {
base_digest: snapshot.digest,
changes: [],
entrypoints: ["main.dcdl"],
},
): ConfigurationAdoptionRequest {
const observed = editingState();
return {
operation_id: "operation:one",
expected_head: observed.history_head,
expected_catalog_digest: observed.catalog_digest,
expected_legacy_export: observed.expected_legacy_export,
source,
};
}
function adoptionReceipt(
workspaceId: string,
request: ConfigurationAdoptionRequest,
state: WorkspaceConfigTreeResponse = tree,
): ConfigurationAdoptionReceipt {
return {
workspace_id: workspaceId,
operation_id: request.operation_id,
history_head: "head:adopted",
state,
source_stored: true,
policy_committed: true,
runtime_application: "next_current_policy_check",
};
}
Deno.test("config source API loads adoption metadata and posts the full request while retaining history and entry routes", async () => {
const request = adoptionRequest({
base_digest: snapshot.digest,
changes: [{
kind: "update",
path: entry.path,
expected_digest: entry.content_digest,
content: "profile = { changed = true }",
}],
entrypoints: ["main.dcdl"],
});
const receipt = adoptionReceipt("w/one", request);
const preview: ConfigurationPreviewResponse = {
expected_head: request.expected_head,
state: tree,
changed_paths: [entry.path],
changed_subjects: ["subject:one"],
};
const calls: Array<{ url: string; init?: RequestInit }> = []; const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => { const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
const url = String(input); const url = String(input);
calls.push({ url, init }); calls.push({ url, init });
if (url.includes("/entries/")) return Promise.resolve(response(entry)); if (url.includes("/entries/")) return Promise.resolve(response(entry));
if (url.includes("/history/")) return Promise.resolve(response(snapshot)); if (url.includes("/history/")) return Promise.resolve(response(snapshot));
return Promise.resolve(response(tree)); if (url.endsWith("/preview")) return Promise.resolve(response(preview));
if (url.endsWith("/commit")) return Promise.resolve(response(receipt));
if (url.endsWith("/reconcile")) {
return Promise.resolve(response({ status: "adopted", receipt }));
}
return Promise.resolve(response(sourceResponse()));
}) as typeof fetch; }) as typeof fetch;
await fetchConfigTree("w/one", fetcher); assertEquals(await fetchConfigTree("w/one", fetcher), editingState());
await fetchConfigHistory("w/one", "sha256:tree", fetcher); assertEquals(await fetchConfigHistory("w/one", "sha256:tree", fetcher), snapshot);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); assertEquals(await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher), entry);
await commitConfigTree("w/one", { assertEquals(await previewConfigTree("w/one", request, fetcher), preview);
base_digest: "sha256:base", assertEquals(await commitConfigTree("w/one", request, fetcher), receipt);
changes: [], assertEquals(await reconcileConfigTree("w/one", request, fetcher), {
entrypoints: [], status: "adopted",
}, fetcher); receipt,
});
assertEquals<unknown>(calls.map((call) => call.url), [ assertEquals(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/adoption",
"/api/w/w%2Fone/config/source-tree/history/sha256%3Atree", "/api/w/w%2Fone/config/source-tree/history/sha256%3Atree",
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/commit", "/api/w/w%2Fone/config/adoption/preview",
"/api/w/w%2Fone/config/adoption/commit",
"/api/w/w%2Fone/config/adoption/reconcile",
]); ]);
assertEquals<unknown>(calls[3].init?.method, "POST"); for (const call of calls.slice(3)) {
assert( assertEquals(call.init?.method, "POST");
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), assertEquals(
); new Headers(call.init?.headers).get("content-type"),
assertEquals(JSON.parse(String(calls[3].init?.body)), { "application/json",
base_digest: "sha256:base", changes: [], entrypoints: [], );
}); assertEquals(JSON.parse(String(call.init?.body)), request);
}
}); });
function withSchema(source: unknown, contributionSource: unknown = "builtin") { function withSchema(source: unknown, contributionSource: unknown = "builtin") {
@@ -111,15 +203,19 @@ for (const field of ["bundle", "contribution"] as const) {
const body = field === "bundle" const body = field === "bundle"
? withSchema(source) ? withSchema(source)
: withSchema("builtin", source); : withSchema("builtin", source);
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; const request = adoptionRequest();
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body); const receipt = { ...adoptionReceipt("w", request), state: body };
const fetcher = ((input: string | URL | Request) =>
Promise.resolve(response(
String(input).endsWith("/commit") ? receipt : sourceResponse(body),
))) as typeof fetch;
assertEquals<unknown>(await fetchConfigTree("w", fetcher), {
...editingState(),
state: body,
});
assertEquals<unknown>( assertEquals<unknown>(
await commitConfigTree("w", { await commitConfigTree("w", request, fetcher),
base_digest: snapshot.digest, receipt,
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
body,
); );
}); });
@@ -159,7 +255,7 @@ Deno.test("config source API retains metadata and total response limits", async
const fetcher = (() => const fetcher = (() =>
Promise.resolve( Promise.resolve(
new Response( new Response(
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), JSON.stringify(sourceResponse(withSchema("x".repeat(8 * 1024 * 1024)))),
{ headers }, { headers },
), ),
)) as typeof fetch; )) as typeof fetch;
@@ -172,9 +268,10 @@ Deno.test("config source API retains metadata and total response limits", async
}); });
Deno.test("config source API rejects unknown response fields", async () => { Deno.test("config source API rejects unknown response fields", async () => {
const fetcher = const fetcher = (() =>
(() => Promise.resolve(response(
Promise.resolve(response({ ...tree, unexpected: true }))) as typeof fetch; sourceResponse({ ...tree, unexpected: true }),
))) as typeof fetch;
await assertRejects( await assertRejects(
() => fetchConfigTree("w", fetcher), () => fetchConfigTree("w", fetcher),
ConfigSourceApiError, ConfigSourceApiError,
@@ -190,7 +287,7 @@ Deno.test("config source API rejects mismatched entry map paths", async () => {
entries: { "profiles/other.dcdl": entry }, entries: { "profiles/other.dcdl": entry },
}, },
}; };
const fetcher = (() => Promise.resolve(response(invalid))) as typeof fetch; const fetcher = (() => Promise.resolve(response(sourceResponse(invalid)))) as typeof fetch;
await assertRejects( await assertRejects(
() => fetchConfigTree("w", fetcher), () => fetchConfigTree("w", fetcher),
ConfigSourceApiError, ConfigSourceApiError,
@@ -240,11 +337,11 @@ Deno.test("config source API does not materialize imported builtins in the works
}, },
}, },
}; };
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; const fetcher = (() => Promise.resolve(response(sourceResponse(body)))) as typeof fetch;
const result = await fetchConfigTree("w", fetcher); const result = await fetchConfigTree("w", fetcher);
assertEquals(Object.keys(result.snapshot.entries), ["profiles/main.dcdl"]); assertEquals(Object.keys(result.state.snapshot.entries), ["profiles/main.dcdl"]);
assertEquals( assertEquals(
result.snapshot.entries["profiles/main.dcdl"].content, result.state.snapshot.entries["profiles/main.dcdl"].content,
body.snapshot.entries["profiles/main.dcdl"].content, body.snapshot.entries["profiles/main.dcdl"].content,
); );
}); });
@@ -269,16 +366,12 @@ Deno.test("config source API preserves unknown read-only builtin diagnostics wit
}) as typeof fetch; }) as typeof fetch;
const error = await assertRejects( const error = await assertRejects(
() => () =>
commitConfigTree("w", { commitConfigTree("w", adoptionRequest(), fetcher),
base_digest: "sha256:tree",
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
ConfigSourceApiError, ConfigSourceApiError,
diagnostic.message, diagnostic.message,
); );
assert(String(error).includes("main.dcdl")); assert(String(error).includes("main.dcdl"));
assertEquals(calls, ["/api/w/w/config/source-tree/commit"]); assertEquals(calls, ["/api/w/w/config/adoption/commit"]);
}); });
Deno.test("config source API surfaces bounded failed evaluation details", async () => { Deno.test("config source API surfaces bounded failed evaluation details", async () => {
@@ -286,17 +379,12 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
Promise.resolve( Promise.resolve(
new Response("structured diagnostics", { status: 422 }), new Response("structured diagnostics", { status: 422 }),
)) as typeof fetch; )) as typeof fetch;
let message = ""; const error = await assertRejects(
try { () => commitConfigTree("w", adoptionRequest(), fetcher),
await commitConfigTree("w", { ConfigSourceApiError,
base_digest: "sha256:base", "structured diagnostics",
changes: [], );
entrypoints: [], assertEquals(error.status, 422);
}, fetcher);
} catch (error) {
message = String(error);
}
assert(message.includes("structured diagnostics"));
}); });
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => { Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => {
@@ -337,11 +425,13 @@ Deno.test("config source API carries bounded optional authoring schemas without
} }
}); });
Deno.test("config source API previews a candidate without calling commit", async () => { Deno.test("config source API previews adoption change scope without calling commit", async () => {
const request = { const request = adoptionRequest();
base_digest: "sha256:base", const preview: ConfigurationPreviewResponse = {
changes: [], expected_head: request.expected_head,
entrypoints: ["main.dcdl"], state: tree,
changed_paths: [entry.path],
changed_subjects: ["subject:one"],
}; };
const calls: string[] = []; const calls: string[] = [];
const result = await previewConfigTree( const result = await previewConfigTree(
@@ -351,9 +441,9 @@ Deno.test("config source API previews a candidate without calling commit", async
calls.push(String(input)); calls.push(String(input));
assertEquals(init?.method, "POST"); assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request); assertEquals(JSON.parse(String(init?.body)), request);
return Promise.resolve(response(tree)); return Promise.resolve(response(preview));
}) as typeof fetch, }) as typeof fetch,
); );
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]); assertEquals(calls, ["/api/w/w%2Fone/config/adoption/preview"]);
assertEquals<unknown>(result, tree); assertEquals(result, preview);
}); });
@@ -137,7 +137,7 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
); );
}); });
Deno.test("commit formats every working Decodal source without a preview roundtrip", async () => { Deno.test("config adoption formats changed sources and validates a request bound to observed editing metadata", async () => {
const source = await Deno.readTextFile( const source = await Deno.readTextFile(
new URL( new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", "../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
@@ -154,16 +154,59 @@ Deno.test("commit formats every working Decodal source without a preview roundtr
"all changed Decodal entries should be formatted rather than only the selected source", "all changed Decodal entries should be formatted rather than only the selected source",
); );
assert( assert(
source.includes("await formatWorkingSources();") && source.includes("expected_head: editingMetadata.history_head") &&
source.includes("entrypoints: entrypoints()") && source.includes("expected_catalog_digest: editingMetadata.catalog_digest") &&
source.includes("Committed formatted source tree"), source.includes("expected_legacy_export: editingMetadata.expected_legacy_export") &&
"commit should format first and send the working changes directly to Backend authority", source.includes("base_digest: baseDigest") &&
source.includes("entrypoints: entrypoints()"),
"adoption should carry the observed history head and migration conditions separately from the source digest",
);
const commit = source.slice(
source.indexOf("async function commit()"),
source.indexOf("async function discardAndReload()"),
);
const formatted = commit.indexOf("await formatWorkingSources()");
const validated = commit.indexOf("await previewConfigTree(");
const submitted = commit.indexOf("await submitPending(false)");
assert(
formatted >= 0 && validated > formatted && submitted > validated,
"a new adoption should format and validate with Server authority before submission; local analysis alone is insufficient",
);
});
Deno.test("config editor wires retained unknown requests to an explicit reconciliation action", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("reconcileConfigTree(pending.workspaceId, pending.request)") &&
source.includes("commitConfigTree(pending.workspaceId, pending.request)"),
"reconciliation and explicit resubmission should use the retained workspace and complete request, not a new operation identity",
);
const reconciliation = source.slice(
source.indexOf("async function reconcilePending("),
source.indexOf("async function submitPending("),
); );
assert( assert(
!source.includes("previewConfigTree") && reconciliation.includes('result.status === "adopted"') &&
!source.includes("requestCandidatePreview") && reconciliation.includes("applyReceipt(result.receipt)") &&
!source.includes("toolchain_fingerprint"), !reconciliation.includes("pendingAdoption = null"),
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint", "unknown evidence must leave the request retained; only an adopted receipt can be applied",
);
const action = source.slice(
source.indexOf("async function reconcile()"),
source.indexOf("async function commit()"),
);
assert(
source.includes("onclick={reconcile}") &&
action.includes("await reconcilePending()") &&
!action.includes("commitConfigTree(") &&
!action.includes("submitPending("),
"the explicit reconciliation control must look up the retained outcome without submitting an adoption",
); );
}); });