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:
@@ -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);
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
encoded,
|
||||||
|
serde_json::json!({
|
||||||
"scope_id": native.scope,
|
"scope_id": native.scope,
|
||||||
"feature_id": native.feature,
|
"feature_id": native.feature,
|
||||||
"physical_id": native.physical,
|
"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)
|
||||||
|
));
|
||||||
|
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")
|
panic!("an identity mismatch must not enter the SQL transaction body")
|
||||||
}), Err(SubjektivError::PermissionTransferConflict)));
|
}),
|
||||||
|
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
|
||||||
|
.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||||
|
[&unset],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}).unwrap();
|
})
|
||||||
|
.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
|
||||||
|
.database
|
||||||
|
.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1",
|
||||||
|
[&subject],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}).is_err());
|
})
|
||||||
|
.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([(
|
||||||
|
subject.clone(),
|
||||||
|
Some(SubjectPermissions {
|
||||||
change_id: "foreign-source".into(),
|
change_id: "foreign-source".into(),
|
||||||
execution: vec![ScopedPermission {
|
execution: vec![ScopedPermission {
|
||||||
operation: "file.read".into(),
|
operation: "file.read".into(),
|
||||||
resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() },
|
resource: PermissionResource::Workspace {
|
||||||
|
workspace_id: "workspace-b".into(),
|
||||||
|
},
|
||||||
}],
|
}],
|
||||||
delegation: vec![read_permission()],
|
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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
.unwrap();
|
||||||
|
let old_seal = db
|
||||||
|
.with_connection(|connection| {
|
||||||
if format == Format::Permission18 {
|
if format == Format::Permission18 {
|
||||||
Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?))
|
Ok(Some(connection.query_row(
|
||||||
} else { Ok(None) }
|
"SELECT export_json FROM permission_authority_seal",
|
||||||
}).unwrap();
|
[],
|
||||||
|
|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(®istered), Err(FeatureStorageError::MigrationHistory { .. })));
|
assert!(matches!(
|
||||||
|
scope.open(®istered),
|
||||||
|
Err(FeatureStorageError::MigrationHistory { .. })
|
||||||
|
));
|
||||||
scope.shutdown().unwrap();
|
scope.shutdown().unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
key: PermissionKey::parse(operation.operation.clone())
|
||||||
|
.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,
|
permissions,
|
||||||
sets:vec![SetContribution { set:PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:id,keys})] }],
|
sets: vec![SetContribution {
|
||||||
}).map_err(|e|invalid(&e))?;
|
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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(¤t.snapshot,&state.snapshot),
|
changed_paths: changed_paths(¤t.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 { ¤t };
|
} else {
|
||||||
if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); }
|
¤t
|
||||||
|
};
|
||||||
|
if editing_base.snapshot.digest != request.source.base_digest {
|
||||||
|
return Err(conflict("source content changed"));
|
||||||
|
}
|
||||||
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
|
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();
|
||||||
|
let namespace = host
|
||||||
|
.bind_namespace(&feature, NamespaceId::parse("ticket").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
host.declare(
|
||||||
|
&namespace,
|
||||||
|
NamespaceDeclaration {
|
||||||
permissions: vec![
|
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 {
|
||||||
PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] },
|
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()]})] }],
|
sets: vec![SetContribution {
|
||||||
}).unwrap();
|
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,74 +68,154 @@ 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"#,
|
||||||
@@ -113,43 +224,194 @@ fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() {
|
|||||||
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()))
|
||||||
}
|
}
|
||||||
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());
|
|_| panic!("validation failure must precede seal"),
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "{source}");
|
||||||
|
assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(), initial);
|
||||||
|
assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None);
|
||||||
|
}
|
||||||
|
let request = request(&db, &host, "other-owner", None);
|
||||||
|
assert!(
|
||||||
|
db.adopt_configuration(
|
||||||
|
WORKSPACE,
|
||||||
|
"non-owner",
|
||||||
|
&request,
|
||||||
|
schema(),
|
||||||
|
&host,
|
||||||
|
None,
|
||||||
|
|_| Ok(()),
|
||||||
|
|_| panic!("owner refusal must precede seal")
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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, ®istry, &request(&db, ®istry, "initial", Some(A))).unwrap();
|
||||||
|
let other = open(&dir.path().join("server.sqlite"));
|
||||||
|
let web = request(&db, ®istry, "web-edit", Some(B));
|
||||||
|
let cli = request(
|
||||||
|
&other,
|
||||||
|
®istry,
|
||||||
|
"cli-edit",
|
||||||
|
Some("{} as WorkspaceConfigSchema"),
|
||||||
|
);
|
||||||
|
assert_eq!(web.expected_head, cli.expected_head);
|
||||||
|
let barrier = std::sync::Barrier::new(2);
|
||||||
|
let (web, cli) = std::thread::scope(|scope| {
|
||||||
|
let web = scope.spawn(|| {
|
||||||
|
barrier.wait();
|
||||||
|
adopt(&db, &host(false), &web)
|
||||||
|
});
|
||||||
|
let cli = scope.spawn(|| {
|
||||||
|
barrier.wait();
|
||||||
|
adopt(&other, &host(false), &cli)
|
||||||
|
});
|
||||||
|
(web.join().unwrap(), cli.join().unwrap())
|
||||||
|
});
|
||||||
|
let (winner, loser) = match (web, cli) {
|
||||||
|
(Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser),
|
||||||
|
results => panic!("exactly one observed head may adopt: {results:?}"),
|
||||||
|
};
|
||||||
|
assert!(matches!(loser, Error::WorkspaceConfigConflict(_)));
|
||||||
|
assert_eq!(
|
||||||
|
db.load_workspace_config(WORKSPACE).unwrap(),
|
||||||
|
Some(winner.state)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.configuration_head(WORKSPACE, OWNER).unwrap(),
|
||||||
|
Some(winner.history_head)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
other
|
||||||
|
.configuration_current(WORKSPACE, OWNER)
|
||||||
|
.unwrap()
|
||||||
|
.history_head,
|
||||||
|
db.configuration_head(WORKSPACE, OWNER).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ 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) =
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let workspace_exists: bool = tx.query_row(
|
let workspace_exists: bool = tx.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||||
@@ -223,9 +224,15 @@ impl SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||||
|
let canonical = crate::config_adoption::canonical(&tx, workspace_id)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok((state, requires_toolchain_refresh, base_identity))
|
Ok((state, requires_toolchain_refresh, base_identity, canonical))
|
||||||
})?;
|
})?;
|
||||||
|
// Once adopted, schema changes must be previewed/adopted by the owner,
|
||||||
|
// not persisted as a second startup writer outside the policy/history tx.
|
||||||
|
if canonical {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
let desired_schema = crate::plugin_settings::materialization_schema(
|
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| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
"cannot inspect current configuration before permission migration: {diagnostics:?}"
|
"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
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
format!(
|
||||||
"{{ namespace = {}; keys = {}; }}",
|
"{{ namespace = {}; keys = {}; }}",
|
||||||
quote(value.namespace.as_str()),
|
quote(value.namespace.as_str()),
|
||||||
render_strings(value.keys.iter().map(|key| key.as_str())),
|
render_strings(value.keys.iter().map(|key| key.as_str())),
|
||||||
)).collect::<Vec<_>>();
|
)
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
.subjects
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
format!(
|
||||||
" {{ subject_id = {}; execution = {}; delegation = {}; }}",
|
" {{ subject_id = {}; execution = {}; delegation = {}; }}",
|
||||||
quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation),
|
quote(&item.subject_id),
|
||||||
)).collect::<Vec<_>>().join(",\n");
|
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();
|
||||||
|
let namespace = host
|
||||||
|
.bind_namespace(&feature, NamespaceId::parse("file").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
host.declare(
|
||||||
|
&namespace,
|
||||||
|
NamespaceDeclaration {
|
||||||
permissions: vec![
|
permissions: vec![
|
||||||
PermissionDefinition {
|
PermissionDefinition {
|
||||||
key: PermissionKey::parse("file.read").unwrap(),
|
key: PermissionKey::parse("file.read").unwrap(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] },
|
children: if children {
|
||||||
|
vec![PermissionKey::parse("file.read.text").unwrap()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
PermissionDefinition {
|
PermissionDefinition {
|
||||||
key: PermissionKey::parse("file.read.text").unwrap(),
|
key: PermissionKey::parse("file.read.text").unwrap(),
|
||||||
description: String::new(), children: vec![],
|
description: String::new(),
|
||||||
|
children: vec![],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sets: vec![],
|
sets: vec![],
|
||||||
}).unwrap();
|
},
|
||||||
|
)
|
||||||
|
.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
@@ -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;
|
||||||
|
|||||||
@@ -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,11 +341,14 @@ 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 =
|
||||||
|
server_api::authorization::canonical_worker_operation(&permission.operation)
|
||||||
.unwrap_or(&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) {
|
||||||
@@ -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)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤t);
|
||||||
|
// Explicit removal makes old-receipt resurrection observable.
|
||||||
|
let removal = format!(
|
||||||
|
"{{ subjects = []; remove_subjects = [{:?}]; }}\n",
|
||||||
|
fixture.subject
|
||||||
|
);
|
||||||
|
fixture
|
||||||
|
.commit(&request(
|
||||||
|
¤t,
|
||||||
|
"later-policy-removal",
|
||||||
|
vec![update(¤t, &path, &removal)],
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let latest = fixture.durable();
|
||||||
|
let reconciled: Reconciliation = serde_json::from_value(
|
||||||
|
fixture
|
||||||
|
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reconciled,
|
||||||
|
Reconciliation::Adopted {
|
||||||
|
receipt: expected.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.commit(&adoption).await, expected);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.durable(),
|
||||||
|
latest,
|
||||||
|
"historical receipt must not resurrect removed policy or head"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
open_subjektiv_store(&fixture.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&fixture.subject)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
fixture.assert_legacy_sealed();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_rejects_same_operation_id_with_any_changed_request_fingerprint() {
|
||||||
|
let fixture = Fixture::new().await;
|
||||||
|
let draft = fixture.source().await;
|
||||||
|
let adoption = request(&draft, "fingerprint-migration", vec![]);
|
||||||
|
let receipt = fixture.commit(&adoption).await;
|
||||||
|
let before = fixture.durable();
|
||||||
|
let mut variants = Vec::new();
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed.expected_head = Some(receipt.history_head.clone());
|
||||||
|
variants.push(("head", changed));
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed.expected_catalog_digest.push_str("-different");
|
||||||
|
variants.push(("catalog", changed));
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed.expected_legacy_export = None;
|
||||||
|
variants.push(("legacy export", changed));
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed.source.base_digest.push_str("-different");
|
||||||
|
variants.push(("base content", changed));
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed
|
||||||
|
.source
|
||||||
|
.changes
|
||||||
|
.push(create_marker("not the same request"));
|
||||||
|
variants.push(("changes", changed));
|
||||||
|
let mut changed = adoption.clone();
|
||||||
|
changed.source.entrypoints.push("other.dcdl".into());
|
||||||
|
variants.push(("entrypoints", changed));
|
||||||
|
for (field, changed) in variants {
|
||||||
|
fixture
|
||||||
|
.owner_call("/reconcile", &changed, StatusCode::CONFLICT)
|
||||||
|
.await;
|
||||||
|
fixture
|
||||||
|
.owner_call("/commit", &changed, StatusCode::CONFLICT)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
fixture.durable(),
|
||||||
|
before,
|
||||||
|
"changed {field} must not adopt or modify a receipt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let exact: Reconciliation = serde_json::from_value(
|
||||||
|
fixture
|
||||||
|
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(exact, Reconciliation::Adopted { receipt });
|
||||||
|
assert_eq!(fixture.durable(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_owner_and_cookie_origin_denials_leave_all_authorities_and_legacy_writer_intact() {
|
||||||
|
let fixture = Fixture::new().await;
|
||||||
|
for canonical in [false, true] {
|
||||||
|
let source = fixture.source().await;
|
||||||
|
assert_eq!(source.source_stored, canonical);
|
||||||
|
let change = if canonical {
|
||||||
|
update(&source, MARKER, "second authorized value")
|
||||||
|
} else {
|
||||||
|
create_marker("first authorized value")
|
||||||
|
};
|
||||||
|
let adoption = request(
|
||||||
|
&source,
|
||||||
|
&format!("denied-cutover-{canonical}"),
|
||||||
|
vec![change],
|
||||||
|
);
|
||||||
|
let wire = serde_json::to_value(&adoption).unwrap();
|
||||||
|
let before = fixture.durable();
|
||||||
|
for (method, suffix) in [
|
||||||
|
("GET", ""),
|
||||||
|
("POST", "/preview"),
|
||||||
|
("POST", "/commit"),
|
||||||
|
("POST", "/reconcile"),
|
||||||
|
] {
|
||||||
|
let body = (method == "POST").then(|| wire.clone());
|
||||||
|
let anonymous = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(Fixture::path(suffix))
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
body.as_ref().map(Value::to_string).unwrap_or_default(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let runtime = runtime_source_request(
|
||||||
|
&fixture.runtime_identity,
|
||||||
|
None,
|
||||||
|
method,
|
||||||
|
&Fixture::path(suffix),
|
||||||
|
body.as_ref()
|
||||||
|
.map(|value| serde_json::to_vec(value).unwrap())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let mut runtime_with_owner = runtime_source_request(
|
||||||
|
&fixture.runtime_identity,
|
||||||
|
None,
|
||||||
|
method,
|
||||||
|
&Fixture::path(suffix),
|
||||||
|
body.as_ref()
|
||||||
|
.map(|value| serde_json::to_vec(value).unwrap())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
runtime_with_owner.headers_mut().insert(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", fixture.owner).parse().unwrap(),
|
||||||
|
);
|
||||||
|
for (principal, request) in [
|
||||||
|
(
|
||||||
|
"nonowner",
|
||||||
|
fixture.bearer(&fixture.foreign, method, suffix, body.clone()),
|
||||||
|
),
|
||||||
|
("unauthenticated", anonymous),
|
||||||
|
("runtime proof", runtime),
|
||||||
|
// A valid owner credential must not hide the Runtime source.
|
||||||
|
("runtime proof plus owner bearer", runtime_with_owner),
|
||||||
|
] {
|
||||||
|
let (status, response) = fixture.call(request).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"{principal} {method} {suffix}: {response}"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.durable(), before, "{principal} {suffix}");
|
||||||
|
if canonical {
|
||||||
|
fixture.assert_legacy_sealed();
|
||||||
|
} else {
|
||||||
|
fixture.assert_legacy_writable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if method == "POST" {
|
||||||
|
for origin in [None, Some("https://attacker.invalid")] {
|
||||||
|
let (status, response) = fixture
|
||||||
|
.call(fixture.browser(method, suffix, body.clone(), origin))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"cookie {origin:?} {suffix}: {response}"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.durable(), before);
|
||||||
|
if canonical {
|
||||||
|
fixture.assert_legacy_sealed();
|
||||||
|
} else {
|
||||||
|
fixture.assert_legacy_writable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A Browser GET does not require a mutation Origin, and the same
|
||||||
|
// valid adoption remains possible after every denied request.
|
||||||
|
let (status, response) = fixture.call(fixture.browser("GET", "", None, None)).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{response}");
|
||||||
|
assert_eq!(serde_json::from_value::<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
|
||||||
|
// fences this writer even when the Server adoption rolled back.
|
||||||
|
let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?;
|
||||||
|
let identity = legacy.database_identity().map_err(|_| unknown())?;
|
||||||
|
let state = legacy.with_permission_authority_guard(&identity, |authority| {
|
||||||
|
authority.require_unsealed()?;
|
||||||
|
Ok((|| {
|
||||||
project_repository_access_candidate(
|
project_repository_access_candidate(
|
||||||
&*persistence_api.store,
|
&*persistence_api.store,
|
||||||
&persistence_api.repository_secrets,
|
&persistence_api.repository_secrets,
|
||||||
&persistence_api.config.workspace_id,
|
&persistence_api.config.workspace_id,
|
||||||
&candidate,
|
&candidate,
|
||||||
)
|
).map_err(|_| unknown())?;
|
||||||
.map_err(|_| unknown())?;
|
persistence_api.config_store.commit_evaluated_workspace_config(
|
||||||
let state = persistence_api
|
&persistence_api.config.workspace_id, &candidate,
|
||||||
.config_store
|
).map_err(pre_error)
|
||||||
.commit_evaluated_workspace_config(
|
})())
|
||||||
&persistence_api.config.workspace_id,
|
}).map_err(|error| match error {
|
||||||
&candidate,
|
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
|
||||||
)
|
_ => unknown(),
|
||||||
.map_err(|_| 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t) {
|
if !(113..=121).contains(¤t) {
|
||||||
return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into()));
|
return Err(Error::Store(
|
||||||
|
"Configuration adoption migration requires a recognized schema 113..121".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
storage_formats::verify_history(conn, current)?;
|
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
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
path: change.path, content_type: change.content_type, content: change.content,
|
||||||
content_type: change.content_type,
|
|
||||||
content: change.content,
|
|
||||||
content_digest: "sha256:module",
|
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 = {
|
||||||
|
operation_id: crypto.randomUUID(),
|
||||||
|
expected_head: editingMetadata.history_head,
|
||||||
|
expected_catalog_digest: editingMetadata.catalog_digest,
|
||||||
|
expected_legacy_export: editingMetadata.expected_legacy_export,
|
||||||
|
source: {
|
||||||
base_digest: baseDigest,
|
base_digest: baseDigest,
|
||||||
changes: workingChanges,
|
changes: $state.snapshot(workingChanges),
|
||||||
entrypoints: entrypoints(),
|
entrypoints: entrypoints(),
|
||||||
});
|
},
|
||||||
workingChanges = [];
|
};
|
||||||
conflict = false;
|
const signature = draftSignature();
|
||||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
await previewConfigTree(adoptionWorkspaceId, request);
|
||||||
baseDigest = treeState.snapshot.digest;
|
pendingAdoption = { workspaceId: adoptionWorkspaceId, request, draftSignature: signature };
|
||||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
await submitPending(false);
|
||||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
|
||||||
diagnostics = [];
|
|
||||||
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
|
||||||
} 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;
|
||||||
|
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);
|
||||||
baseSnapshot = structuredClone(remote.snapshot);
|
baseSnapshot = structuredClone(remote.snapshot);
|
||||||
baseDigest = remote.snapshot.digest;
|
baseDigest = remote.snapshot.digest;
|
||||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
editingMetadata = observed;
|
||||||
try {
|
editingWorkspaceId = requestedWorkspace;
|
||||||
const candidate = await toolchain.applyChanges(localChanges);
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
"application/json",
|
||||||
);
|
);
|
||||||
assertEquals(JSON.parse(String(calls[3].init?.body)), {
|
assertEquals(JSON.parse(String(call.init?.body)), request);
|
||||||
base_digest: "sha256:base", changes: [], entrypoints: [],
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user