wip: retain atomic configuration adoption core before startup dependency integration
Local checkpoint only. HTTP wiring, focused migration retest, generated artifacts and review remain incomplete; not a reviewed or validated handoff.
This commit is contained in:
Generated
+1
@@ -7579,6 +7579,7 @@ dependencies = [
|
||||
"content-history",
|
||||
"content-history-adapters",
|
||||
"content-history-store",
|
||||
"feature-permissions",
|
||||
"feature-storage",
|
||||
"flate2",
|
||||
"flow",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
fn main() -> Result<(),Box<dyn std::error::Error>> {
|
||||
print!("{}", server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#[cfg(feature="typescript")]
|
||||
fn main() {
|
||||
print!("{}",server_api::normalized_typescript(server_api::configuration_adoption::configuration_typescript()));
|
||||
}
|
||||
#[cfg(not(feature="typescript"))]
|
||||
fn main() {
|
||||
eprintln!("generate_configuration_adoption_types requires --features typescript");
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Workspace-owner source adoption transport. The Server authenticates the owner
|
||||
//! before reading source, previewing, sealing migration data, adopting or reconciling.
|
||||
//! A result lookup is not a new adoption and absence is not proof of failure.
|
||||
use crate::{ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse};
|
||||
use feature_permissions::{PermissionAssignment, PermissionCatalog};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionRequest {
|
||||
pub operation_id: String,
|
||||
pub expected_head: Option<String>,
|
||||
pub expected_catalog_digest: String,
|
||||
pub expected_legacy_export: Option<String>,
|
||||
pub source: ConfigCommitRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(rename_all="snake_case")]
|
||||
pub enum RuntimePolicyApplication { NextCurrentPolicyCheck }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionReceipt {
|
||||
pub workspace_id:String,
|
||||
pub operation_id:String,
|
||||
pub history_head:String,
|
||||
pub state:WorkspaceConfigTreeResponse,
|
||||
pub source_stored:bool,
|
||||
pub policy_committed:bool,
|
||||
pub runtime_application:RuntimePolicyApplication,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationSubjectPolicy {
|
||||
pub subject_id:String,
|
||||
pub change_id:String,
|
||||
pub execution:PermissionAssignment,
|
||||
pub delegation:PermissionAssignment,
|
||||
/// Exact retained domain restrictions, not editable payloads on permission keys.
|
||||
pub legacy_constraints:Option<crate::authorization::SubjectPermissions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(tag="kind",rename_all="snake_case",deny_unknown_fields)]
|
||||
pub enum ConfigurationMigrationDiagnostic {
|
||||
Converted {subject_id:String,operation:String},
|
||||
DomainConstraint {subject_id:String,permission:crate::authorization::ScopedPermission},
|
||||
ExplicitConfigurationRequired {subject_id:String,permission:crate::authorization::ScopedPermission},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationSourceResponse {
|
||||
/// Before the first adoption this is an editable, evaluated migration draft.
|
||||
/// source_stored distinguishes it from the unchanged stored source.
|
||||
pub state:WorkspaceConfigTreeResponse,
|
||||
pub history_head:Option<String>,
|
||||
pub source_stored:bool,
|
||||
pub catalog:PermissionCatalog,
|
||||
pub catalog_digest:String,
|
||||
pub expected_legacy_export:Option<String>,
|
||||
pub policies:Vec<ConfigurationSubjectPolicy>,
|
||||
pub migration_diagnostics:Vec<ConfigurationMigrationDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationPreviewResponse {
|
||||
pub expected_head:Option<String>,
|
||||
pub state:WorkspaceConfigTreeResponse,
|
||||
pub changed_paths:Vec<String>,
|
||||
pub changed_subjects:Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature="typescript",derive(ts_rs::TS))]
|
||||
#[serde(tag="status",rename_all="snake_case",deny_unknown_fields)]
|
||||
pub enum ConfigurationReconciliation {
|
||||
Adopted {receipt:ConfigurationAdoptionReceipt},
|
||||
PendingTransfer {transfer_identity:String,export_digest:String},
|
||||
/// No matching evidence was observed. This is not permission to change the ID.
|
||||
NotObserved,
|
||||
}
|
||||
|
||||
#[api_macros::api(reqwest,axum,openapi)]
|
||||
pub trait ConfigurationAdoptionApi {
|
||||
#[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)]
|
||||
async fn configuration_source(&self,#[extension] context:ServerRequestContext,#[path] workspace_id:String)->Result<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)]
|
||||
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)]
|
||||
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)]
|
||||
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 ConfigurationAdoptionRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {}
|
||||
impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {}
|
||||
|
||||
pub fn configuration_contract_openapi() -> Result<api_macros::openapi::OpenApiDocument,api_macros::openapi::OpenApiError> {
|
||||
use sha2::{Digest,Sha256};
|
||||
let document=|source_digest:&str| configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||
title:"Workspace configuration adoption",version:env!("CARGO_PKG_VERSION"),source_digest,
|
||||
});
|
||||
let normalized=document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?.to_json()?;
|
||||
let digest:String=Sha256::digest(normalized.as_bytes()).iter().map(|byte|format!("{byte:02x}")).collect();
|
||||
document(&format!("sha256:{digest}"))
|
||||
}
|
||||
|
||||
#[cfg(feature="typescript")]
|
||||
pub fn configuration_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
let config=ts_rs::Config::default();
|
||||
let declarations=[
|
||||
ConfigurationAdoptionRequest::decl(&config), RuntimePolicyApplication::decl(&config),
|
||||
ConfigurationAdoptionReceipt::decl(&config), ConfigurationSubjectPolicy::decl(&config),
|
||||
ConfigurationMigrationDiagnostic::decl(&config), ConfigurationSourceResponse::decl(&config),
|
||||
ConfigurationPreviewResponse::decl(&config), ConfigurationReconciliation::decl(&config),
|
||||
];
|
||||
format!("// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions }} from \"./legacy-server-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",declarations.into_iter().map(|declaration|format!("export {declaration}")).collect::<Vec<_>>().join("\n\n"))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
|
||||
pub mod authorization;
|
||||
mod chat;
|
||||
pub mod feature_permissions;
|
||||
pub mod configuration_adoption;
|
||||
pub use chat::*;
|
||||
mod subject_body;
|
||||
pub use subject_body::*;
|
||||
|
||||
@@ -319,6 +319,11 @@ impl SubjektivStore {
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1
|
||||
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?;
|
||||
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
|
||||
tx.execute("INSERT OR IGNORE INTO subject_deleted_workers(subject_id,runtime_id,worker_id)
|
||||
SELECT subject_id,runtime_id,worker_id FROM canonical_worker_execution_grants WHERE subject_id=?1
|
||||
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?;
|
||||
}
|
||||
tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id)
|
||||
SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1
|
||||
UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1
|
||||
@@ -401,6 +406,9 @@ impl SubjektivStore {
|
||||
[subject],
|
||||
)?;
|
||||
}
|
||||
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
|
||||
tx.execute("DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?;
|
||||
}
|
||||
for table in DIRECT {
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE subject_id=?1"),
|
||||
|
||||
@@ -8,6 +8,10 @@ pub use deletion::{DeletionSubjectJob, SubjectDeletion};
|
||||
pub mod journal;
|
||||
mod legacy_migrations;
|
||||
pub mod permissions;
|
||||
pub use permissions::{
|
||||
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport,
|
||||
PermissionTransferReceipt, PermissionTransferSnapshot,
|
||||
};
|
||||
mod schema;
|
||||
mod subject_creation;
|
||||
use legacy_migrations::{
|
||||
@@ -95,6 +99,10 @@ pub enum SubjektivError {
|
||||
SubjectPermissionsConflict(String),
|
||||
#[error("permission operation `{0}` was reused with different input")]
|
||||
PermissionOperationConflict(String),
|
||||
#[error("legacy permission authority is permanently sealed")]
|
||||
PermissionAuthoritySealed,
|
||||
#[error("permission transfer conflicts with identity, export or retained receipt")]
|
||||
PermissionTransferConflict,
|
||||
#[error("authorization denied: {0}")]
|
||||
AuthorizationDenied(String),
|
||||
#[error("Worker execution grant binding conflicts for `{0}`")]
|
||||
@@ -689,6 +697,7 @@ pub const REGISTRATION: FeatureRegistration =
|
||||
#[derive(Clone)]
|
||||
pub struct SubjektivStore {
|
||||
database: FeatureDatabase,
|
||||
canonical_permission_source: Option<std::sync::Arc<dyn permissions::CanonicalPermissionSource>>,
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
@@ -746,7 +755,7 @@ impl SubjektivStore {
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self { database })
|
||||
Ok(Self { database, canonical_permission_source: None })
|
||||
}
|
||||
|
||||
pub fn scope_id(&self) -> &str {
|
||||
|
||||
@@ -12,6 +12,12 @@ use server_api::authorization::{
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) mod transfer;
|
||||
pub use transfer::{
|
||||
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport,
|
||||
PermissionTransferReceipt, PermissionTransferSnapshot,
|
||||
};
|
||||
|
||||
pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000;
|
||||
|
||||
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
@@ -23,9 +29,9 @@ impl SubjektivStore {
|
||||
/// Unset sources are `None` (deny), including migrated Subjects. This is an admin read,
|
||||
/// not an effective-authorization decision; retired Subjects retain their source evidence.
|
||||
pub fn subject_permissions(&self, subject_id: &str) -> Result<Option<SubjectPermissions>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
self.database.try_transaction(|connection| {
|
||||
require_subject_in_connection(connection, subject_id)?;
|
||||
source_in_connection(connection, subject_id)
|
||||
self.current_permission_source(connection, subject_id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +49,8 @@ impl SubjektivStore {
|
||||
/// Management replacement rejects new unsupported operation/resource pairs while
|
||||
/// retaining exact legacy entries in their original execution/delegation lane.
|
||||
/// Validation shares the existing CAS/receipt transaction so a stale editor cannot
|
||||
/// retain a removed legacy grant. Exact replay remains historical evidence.
|
||||
/// retain a removed legacy grant. Before sealing, exact replay remains historical evidence;
|
||||
/// once sealed, replacements reject even an otherwise exact historical receipt replay.
|
||||
pub fn set_managed_subject_permissions(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
@@ -71,6 +78,7 @@ impl SubjektivStore {
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
self.database.try_transaction(|tx| {
|
||||
transfer::require_unsealed(tx)?;
|
||||
require_subject(tx, subject_id)?;
|
||||
let receipt: Option<(String, String)> = tx.query_row(
|
||||
"SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1",
|
||||
@@ -129,7 +137,7 @@ impl SubjektivStore {
|
||||
};
|
||||
}
|
||||
require_active_subject(tx, &grant.subject_id)?;
|
||||
let source = source_in_connection(tx, &grant.subject_id)?
|
||||
let source = self.current_permission_source(tx, &grant.subject_id)?
|
||||
.ok_or_else(|| denied("Subject permissions are unset"))?;
|
||||
if source.change_id != grant.source_change_id {
|
||||
return Err(denied("Subject permission source is no longer current"));
|
||||
@@ -148,7 +156,12 @@ impl SubjektivStore {
|
||||
require_covered(&origin.redelegation, &grant.permissions)?;
|
||||
require_covered(&origin.redelegation, &grant.redelegation)?;
|
||||
}
|
||||
tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)",
|
||||
let table = if transfer::is_sealed(tx)? {
|
||||
// Canonical IDs must not be mirrored into legacy source history to satisfy its FK.
|
||||
crate::storage_formats::install_permission_transfer(tx)?;
|
||||
"canonical_worker_execution_grants"
|
||||
} else { "worker_execution_grants" };
|
||||
tx.execute(&format!("INSERT INTO {table}(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)"),
|
||||
params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -164,9 +177,12 @@ impl SubjektivStore {
|
||||
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
self.database.try_with_connection(|connection| {
|
||||
require_subject_in_connection(connection, subject_id)?;
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2",
|
||||
)?;
|
||||
let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
|
||||
"SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2"
|
||||
} else {
|
||||
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2"
|
||||
};
|
||||
let mut statement = connection.prepare(sql)?;
|
||||
let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?;
|
||||
let mut grants = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
@@ -216,9 +232,14 @@ fn grant_in_connection(
|
||||
// Absence must never become non-Subject fallback authority after purge.
|
||||
deletion::require_live_worker(connection, runtime, worker)?;
|
||||
|
||||
let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
|
||||
"SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) g WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=g.subject_id)"
|
||||
} else {
|
||||
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)"
|
||||
};
|
||||
let raw: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)",
|
||||
sql,
|
||||
params![runtime, worker],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! One-way preparation of a permission-domain transfer. No Server adoption or dual commit.
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Host connection to the canonical permission domain, not an ACL or grant issuer.
|
||||
/// Implementations must resolve the exact Workspace/Subject pair and propagate failures.
|
||||
/// Calls are synchronous under the FeatureDatabase guard; do not reenter this store.
|
||||
pub trait CanonicalPermissionSource: Send + Sync {
|
||||
fn subject_permissions(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
subject_id: &str,
|
||||
) -> Result<Option<SubjectPermissions>>;
|
||||
}
|
||||
|
||||
/// Serializable transfer DTO for FeatureStorage's typed identity, never a physical path.
|
||||
/// FeatureStorage owns identity validation; this DTO does not interpret storage metadata.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DatabaseIdentity {
|
||||
pub scope_id: String,
|
||||
pub feature_id: String,
|
||||
pub physical_id: String,
|
||||
}
|
||||
|
||||
impl From<&feature_storage::DatabaseIdentity> for DatabaseIdentity {
|
||||
fn from(identity: &feature_storage::DatabaseIdentity) -> Self {
|
||||
Self {
|
||||
scope_id: identity.scope.clone(),
|
||||
feature_id: identity.feature.clone(),
|
||||
physical_id: identity.physical.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseIdentity {
|
||||
fn storage_identity(&self) -> feature_storage::DatabaseIdentity {
|
||||
feature_storage::DatabaseIdentity {
|
||||
scope: self.scope_id.clone(),
|
||||
feature: self.feature_id.clone(),
|
||||
physical: self.physical_id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionSourceExport {
|
||||
pub subject_id: String,
|
||||
/// `None` is unset; `Some` with empty vectors is an explicit revocation.
|
||||
pub permissions: Option<SubjectPermissions>,
|
||||
}
|
||||
|
||||
/// Complete, Subject-ID-sorted policy snapshot. History and grants stay in the old database.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionTransferSnapshot {
|
||||
pub identity: DatabaseIdentity,
|
||||
pub sources: Vec<PermissionSourceExport>,
|
||||
/// SHA-256 of the versioned identity and sorted policy vector, excluding this field.
|
||||
pub export_digest: String,
|
||||
}
|
||||
|
||||
/// Immutable preparation evidence, not evidence that Server settings have been adopted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionTransferReceipt {
|
||||
pub transfer_id: String,
|
||||
pub snapshot: PermissionTransferSnapshot,
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
pub fn with_canonical_permission_source(mut self, source: Arc<dyn CanonicalPermissionSource>) -> Self {
|
||||
self.canonical_permission_source = Some(source);
|
||||
self
|
||||
}
|
||||
|
||||
/// Reads physical identity under the FeatureStorage connection guard and scope check.
|
||||
pub fn database_identity(&self) -> Result<DatabaseIdentity> {
|
||||
let identity = DatabaseIdentity::from(self.database.identity());
|
||||
self.permission_transfer_transaction(&identity, |_| Ok(identity.clone()))
|
||||
}
|
||||
|
||||
/// Reads one coherent pre-seal snapshot without installing schema or changing authority.
|
||||
/// After a transfer, returns the retained exact snapshot, never a changed/deleted live set.
|
||||
pub fn preview_permission_transfer(&self, expected: &DatabaseIdentity) -> Result<PermissionTransferSnapshot> {
|
||||
self.permission_transfer_transaction(expected, |tx| {
|
||||
if let Some(receipt) = retained_receipt(tx)? {
|
||||
require_receipt_identity(&receipt, expected)?;
|
||||
return Ok(receipt.snapshot);
|
||||
}
|
||||
require_unsealed(tx)?;
|
||||
export_in_connection(tx, expected.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Permanently seals the old source writer after preview/CAS. All changes, the exact export
|
||||
/// and its identity-bound receipt commit in one old-DB IMMEDIATE transaction. The Host must
|
||||
/// authorize/preview first and bind transfer_id to the complete Server request fingerprint.
|
||||
/// A successful seal does not adopt any Server configuration. Never call two commits atomic.
|
||||
pub fn seal_permission_sources(
|
||||
&self,
|
||||
expected: &DatabaseIdentity,
|
||||
transfer_id: &str,
|
||||
expected_export_digest: &str,
|
||||
) -> Result<PermissionTransferReceipt> {
|
||||
validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
self.permission_transfer_transaction(expected, |tx| {
|
||||
if let Some(receipt) = retained_receipt(tx)? {
|
||||
require_receipt_identity(&receipt, expected)?;
|
||||
if receipt.transfer_id != transfer_id || receipt.snapshot.export_digest != expected_export_digest {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
return Ok(receipt);
|
||||
}
|
||||
// A published Permission18 seal is not ours to rewrite or relabel as this receipt.
|
||||
require_unsealed(tx)?;
|
||||
let snapshot = export_in_connection(tx, expected.clone())?;
|
||||
if snapshot.export_digest != expected_export_digest {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
crate::storage_formats::install_permission_transfer(tx)?;
|
||||
if !has_table(tx, "permission_source_transfer_receipt")? {
|
||||
return Err(SubjektivError::InvalidRecord("permission transfer requires schema 1..17".into()));
|
||||
}
|
||||
let receipt = PermissionTransferReceipt { transfer_id: transfer_id.into(), snapshot };
|
||||
let raw = serde_json::to_string(&receipt)?;
|
||||
tx.execute("INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)",
|
||||
params![transfer_id, serde_json::to_string(&receipt.snapshot)?])?;
|
||||
tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?;
|
||||
Ok(receipt)
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_transfer_transaction<T>(
|
||||
&self,
|
||||
expected: &DatabaseIdentity,
|
||||
action: impl FnOnce(&Transaction<'_>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
if *expected != DatabaseIdentity::from(self.database.identity()) {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
// Validate the caller's native expected identity without holding a separate guard
|
||||
// across transaction entry. try_transaction rechecks the same immutable bound identity
|
||||
// before/after the operation and owns commit, including OutcomeUnknown errors.
|
||||
drop(self.database.guard(&expected.storage_identity())?);
|
||||
self.database.try_transaction(|tx| {
|
||||
require_store_scope(tx, self.scope_id())?;
|
||||
action(tx)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn current_permission_source(&self, connection: &Connection, subject: &str) -> Result<Option<SubjectPermissions>> {
|
||||
if !is_sealed(connection)? {
|
||||
return source_in_connection(connection, subject);
|
||||
}
|
||||
let provider = self.canonical_permission_source.as_ref()
|
||||
.ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?;
|
||||
let source = provider.subject_permissions(self.scope_id(), subject)?;
|
||||
if let Some(source) = &source {
|
||||
validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
validate_scope(self.scope_id(), &source.execution)?;
|
||||
validate_scope(self.scope_id(), &source.delegation)?;
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_table(connection: &Connection, name: &str) -> Result<bool> {
|
||||
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> {
|
||||
if !has_table(connection, "permission_authority_seal")? { return Ok(false); }
|
||||
Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM permission_authority_seal)", [], |row| row.get(0))?)
|
||||
}
|
||||
|
||||
pub(super) fn require_unsealed(connection: &Connection) -> Result<()> {
|
||||
if is_sealed(connection)? { return Err(SubjektivError::PermissionAuthoritySealed); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> {
|
||||
let stored: String = connection.query_row(
|
||||
"SELECT workspace_id FROM store_scope WHERE singleton=1",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
if stored != scope { return Err(SubjektivError::PermissionTransferConflict); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_receipt_identity(receipt: &PermissionTransferReceipt, expected: &DatabaseIdentity) -> Result<()> {
|
||||
if receipt.snapshot.identity != *expected { return Err(SubjektivError::PermissionTransferConflict); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retained_receipt(connection: &Connection) -> Result<Option<PermissionTransferReceipt>> {
|
||||
if !has_table(connection, "permission_source_transfer_receipt")? { return Ok(None); }
|
||||
let raw: Option<String> = connection.query_row(
|
||||
"SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1",
|
||||
[], |row| row.get(0),
|
||||
).optional()?;
|
||||
let receipt: Option<PermissionTransferReceipt> = raw.map(|raw| serde_json::from_str(&raw)).transpose()?;
|
||||
if receipt.is_some() && !is_sealed(connection)? { return Err(SubjektivError::PermissionTransferConflict); }
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
fn export_in_connection(connection: &Connection, identity: DatabaseIdentity) -> Result<PermissionTransferSnapshot> {
|
||||
let orphaned: bool = connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
if orphaned { return Err(SubjektivError::PermissionTransferConflict); }
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT s.subject_id,p.change_id,c.record_json FROM subjects s
|
||||
LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id
|
||||
LEFT JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id
|
||||
ORDER BY s.subject_id",
|
||||
)?;
|
||||
let mut rows = statement.query([])?;
|
||||
let mut sources = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let subject_id: String = row.get(0)?;
|
||||
validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
let change: Option<String> = row.get(1)?;
|
||||
let raw: Option<String> = row.get(2)?;
|
||||
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() {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
if let Some(source) = &permissions {
|
||||
validate_authorization_identifier(&source.change_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
validate_scope(&identity.scope_id, &source.execution)?;
|
||||
validate_scope(&identity.scope_id, &source.delegation)?;
|
||||
}
|
||||
sources.push(PermissionSourceExport { subject_id, permissions });
|
||||
}
|
||||
let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?)
|
||||
.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
Ok(PermissionTransferSnapshot { identity, sources, export_digest })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Permission transfer domain v1, independent of published common migration numbers.
|
||||
-- This receipt is preparation evidence only, never Server configuration adoption.
|
||||
CREATE TABLE permission_source_transfer_receipt (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
schema_version INTEGER NOT NULL CHECK(schema_version=1),
|
||||
receipt_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TRIGGER permission_source_transfer_no_replace BEFORE INSERT ON permission_source_transfer_receipt
|
||||
WHEN EXISTS(SELECT 1 FROM permission_source_transfer_receipt)
|
||||
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt already exists'); END;
|
||||
CREATE TRIGGER permission_source_transfer_no_update BEFORE UPDATE ON permission_source_transfer_receipt
|
||||
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is immutable'); END;
|
||||
CREATE TRIGGER permission_source_transfer_no_delete BEFORE DELETE ON permission_source_transfer_receipt
|
||||
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is retained'); END;
|
||||
-- Published Permission18's UPDATE/DELETE guards alone do not fence INSERT OR REPLACE.
|
||||
CREATE TRIGGER permission_authority_seal_no_replace BEFORE INSERT ON permission_authority_seal
|
||||
WHEN EXISTS(SELECT 1 FROM permission_authority_seal)
|
||||
BEGIN SELECT RAISE(ABORT, 'permission authority seal already exists'); END;
|
||||
CREATE TRIGGER subject_creation_receipts_no_replace BEFORE INSERT ON subject_creation_receipts
|
||||
WHEN EXISTS(SELECT 1 FROM subject_creation_receipts WHERE operation_id=NEW.operation_id OR subject_id=NEW.subject_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'Subject creation receipt already exists'); END;
|
||||
|
||||
-- Execution binding evidence only, not a policy mirror. Canonical source IDs cannot reference
|
||||
-- legacy subject_permission_changes. Preserve that table/FK and all old grant rows unchanged.
|
||||
CREATE TABLE canonical_worker_execution_grants (
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT,
|
||||
source_change_id TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
PRIMARY KEY(runtime_id,worker_id)
|
||||
);
|
||||
CREATE TRIGGER canonical_worker_execution_grants_no_replace BEFORE INSERT ON canonical_worker_execution_grants
|
||||
WHEN EXISTS(SELECT 1 FROM canonical_worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
|
||||
OR EXISTS(SELECT 1 FROM worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'Worker execution grant already exists'); END;
|
||||
CREATE TRIGGER canonical_worker_execution_grants_no_update BEFORE UPDATE ON canonical_worker_execution_grants
|
||||
BEGIN SELECT RAISE(ABORT, 'Worker execution grants are immutable'); END;
|
||||
CREATE TRIGGER canonical_worker_execution_grants_no_delete BEFORE DELETE ON canonical_worker_execution_grants
|
||||
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'Worker execution grants are retained'); END;
|
||||
CREATE TRIGGER canonical_worker_execution_grants_insert_guard BEFORE INSERT ON canonical_worker_execution_grants
|
||||
WHEN NOT EXISTS(SELECT 1 FROM permission_authority_seal)
|
||||
OR EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id)
|
||||
OR EXISTS(SELECT 1 FROM subject_deleted_workers WHERE
|
||||
(runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
|
||||
OR (runtime_id=json_extract(NEW.record_json,'$.origin_runtime_id')
|
||||
AND worker_id=json_extract(NEW.record_json,'$.origin_worker_id')))
|
||||
BEGIN SELECT RAISE(ABORT, 'canonical grant requires sealed authority and live Subject execution identities'); END;
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim.
|
||||
use super::*;
|
||||
use crate::tests::{open_store, role};
|
||||
use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Barrier, Mutex};
|
||||
|
||||
fn read_permission() -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: "file.read".into(),
|
||||
resource: PermissionResource::Workdir { working_directory_id: "wd".into(), path: ".".into(), recursive: true },
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(store: &SubjektivStore, subject: &str, operation: &str, delegation: Vec<ScopedPermission>) -> (SubjectPermissionsUpdate, SubjectPermissions) {
|
||||
let update = SubjectPermissionsUpdate {
|
||||
operation_id: operation.into(),
|
||||
expected_change_id: store.subject_permissions(subject).unwrap().map(|source| source.change_id),
|
||||
execution: vec![], delegation,
|
||||
};
|
||||
let source = store.set_subject_permissions(subject, &update).unwrap();
|
||||
(update, source)
|
||||
}
|
||||
|
||||
fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant {
|
||||
WorkerExecutionGrant {
|
||||
subject_id: subject.into(), source_change_id: source.into(),
|
||||
runtime_id: "runtime".into(), worker_id: worker.into(),
|
||||
origin_runtime_id: "runtime".into(), origin_worker_id: "host-authenticated-root".into(),
|
||||
profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(),
|
||||
permissions: vec![read_permission()], redelegation: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn counts(store: &SubjektivStore) -> Vec<i64> {
|
||||
store.database.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
["subject_permissions", "subject_permission_changes", "subject_permission_receipts", "worker_execution_grants"]
|
||||
.iter().map(|table| Ok(connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?)).collect()
|
||||
}).unwrap()
|
||||
}
|
||||
|
||||
fn seal(store: &SubjektivStore) -> PermissionTransferReceipt {
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
store.seal_permission_sources(&identity, "server-request-fingerprint", &preview.export_digest).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_component() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let identity = store.database_identity().unwrap();
|
||||
let native = store.database.identity();
|
||||
assert_eq!(identity.scope_id, native.scope);
|
||||
assert_eq!(identity.feature_id, native.feature);
|
||||
assert_eq!(identity.physical_id, native.physical);
|
||||
let encoded = serde_json::to_value(&identity).unwrap();
|
||||
assert_eq!(encoded, serde_json::json!({
|
||||
"scope_id": native.scope,
|
||||
"feature_id": native.feature,
|
||||
"physical_id": native.physical,
|
||||
}));
|
||||
let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap();
|
||||
let restored = decoded.storage_identity();
|
||||
assert_eq!(restored.scope, native.scope);
|
||||
assert_eq!(restored.feature, native.feature);
|
||||
assert_eq!(restored.physical, native.physical);
|
||||
drop(store.database.guard(&restored).unwrap());
|
||||
let preview = store.preview_permission_transfer(&decoded).unwrap();
|
||||
for component in ["scope", "feature", "physical"] {
|
||||
let mut wrong = decoded.clone();
|
||||
match component {
|
||||
"scope" => wrong.scope_id.push_str("-different"),
|
||||
"feature" => wrong.feature_id.push_str("-different"),
|
||||
"physical" => wrong.physical_id.push_str("-different"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(store.preview_permission_transfer(&wrong), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(matches!(store.seal_permission_sources(&wrong, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(matches!(store.permission_transfer_transaction::<()>(&wrong, |_| {
|
||||
panic!("an identity mismatch must not enter the SQL transaction body")
|
||||
}), Err(SubjektivError::PermissionTransferConflict)));
|
||||
}
|
||||
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_after_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let unset = store.create_subject(role()).unwrap().id;
|
||||
let empty = store.create_subject(role()).unwrap().id;
|
||||
let limited = store.create_subject(role()).unwrap().id;
|
||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&unset])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let (_, empty_source) = replace(&store, &empty, "empty", vec![]);
|
||||
let (old_update, limited_source) = replace(&store, &limited, "limited", vec![read_permission()]);
|
||||
let grant = child(&limited, &limited_source.change_id, "historical-child");
|
||||
store.bind_worker_execution_grant(&grant).unwrap();
|
||||
let before = counts(&store);
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
assert_eq!(preview.sources.len(), 3);
|
||||
assert!(preview.sources.windows(2).all(|pair| pair[0].subject_id < pair[1].subject_id));
|
||||
assert_eq!(preview.sources.iter().find(|row| row.subject_id == unset).unwrap().permissions, None);
|
||||
assert_eq!(preview.sources.iter().find(|row| row.subject_id == empty).unwrap().permissions, Some(empty_source));
|
||||
assert_eq!(preview.sources.iter().find(|row| row.subject_id == limited).unwrap().permissions, Some(limited_source.clone()));
|
||||
assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap());
|
||||
// Preview is physically read-only, including the domain schema.
|
||||
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap());
|
||||
let receipt = store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap();
|
||||
assert_eq!(receipt.snapshot, preview);
|
||||
assert_eq!(counts(&store), before);
|
||||
assert_eq!(store.worker_execution_grant("runtime", "historical-child").unwrap(), Some(grant.clone()));
|
||||
store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority.
|
||||
assert!(matches!(store.set_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed)));
|
||||
assert!(matches!(store.set_managed_subject_permissions(&limited, &old_update), Err(SubjektivError::PermissionAuthoritySealed)));
|
||||
assert!(matches!(store.seal_permission_sources(&identity, "different", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(matches!(store.seal_permission_sources(&identity, "transfer", "different-export"), Err(SubjektivError::PermissionTransferConflict)));
|
||||
let mut wrong_identity = identity.clone();
|
||||
wrong_identity.scope_id = "workspace-b".into();
|
||||
assert!(matches!(store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(matches!(store.preview_permission_transfer(&wrong_identity), Err(SubjektivError::PermissionTransferConflict)));
|
||||
drop(store);
|
||||
scope.shutdown().unwrap();
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert_eq!(identity, store.database_identity().unwrap());
|
||||
assert_eq!(receipt, store.seal_permission_sources(&identity, "transfer", &preview.export_digest).unwrap());
|
||||
assert_eq!(preview, store.preview_permission_transfer(&identity).unwrap());
|
||||
assert_eq!(counts(&store), before);
|
||||
assert!(matches!(store.subject_permissions(&limited), Err(SubjektivError::AuthorizationDenied(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
replace(&store, &subject, "old-writer-after-preview", vec![]);
|
||||
assert!(matches!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap());
|
||||
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| has_table(c, "permission_source_transfer_receipt")).unwrap());
|
||||
replace(&store, &subject, "still-writable", vec![]);
|
||||
let fresh = store.preview_permission_transfer(&identity).unwrap();
|
||||
assert_ne!(fresh.export_digest, preview.export_digest);
|
||||
store.seal_permission_sources(&identity, "new-request", &fresh.export_digest).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
store.database.transaction(crate::storage_formats::install_permission_transfer).unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(store.seal_permission_sources(&identity, "transfer", &preview.export_digest).is_err());
|
||||
assert!(!store.database.try_with_connection::<_, SubjektivError>(|c| is_sealed(c)).unwrap());
|
||||
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c)).unwrap().is_none());
|
||||
replace(&store, &subject, "writer-after-rollback", vec![]);
|
||||
drop(store);
|
||||
scope.shutdown().unwrap();
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert!(store.subject_permissions(&subject).unwrap().is_some());
|
||||
seal(&store);
|
||||
}
|
||||
|
||||
struct Canonical {
|
||||
sources: BTreeMap<String, Option<SubjectPermissions>>,
|
||||
calls: Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
impl CanonicalPermissionSource for Canonical {
|
||||
fn subject_permissions(&self, workspace: &str, 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]
|
||||
fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let (_, old) = replace(&store, &subject, "old", vec![read_permission()]);
|
||||
let receipt = seal(&store);
|
||||
assert!(matches!(store.subject_permissions(&subject), Err(SubjektivError::AuthorizationDenied(_))));
|
||||
assert!(matches!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")), Err(SubjektivError::AuthorizationDenied(_))));
|
||||
let before = counts(&store);
|
||||
let created = store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap();
|
||||
assert_eq!(created, store.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal").unwrap());
|
||||
let non_idempotent = store.create_subject(role()).unwrap();
|
||||
assert!(store.subject_permission_enrollment(&created.id).unwrap().is_some());
|
||||
assert!(store.subject_permission_enrollment(&non_idempotent.id).unwrap().is_some());
|
||||
assert!(store.subject_permission_enrollment(&subject).unwrap().is_none());
|
||||
assert_eq!(counts(&store), before);
|
||||
store.database.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
for id in [&created.id, &non_idempotent.id] {
|
||||
let raw: String = connection.query_row("SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", [id], |row| row.get(0))?;
|
||||
let enrollment: SubjectPermissions = serde_json::from_str(&raw)?;
|
||||
assert!(!enrollment.execution.is_empty());
|
||||
assert!(source_in_connection(connection, id)?.is_none());
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let canonical = SubjectPermissions { change_id: "canonical-change".into(), execution: vec![], delegation: vec![read_permission()] };
|
||||
let provider = Arc::new(Canonical {
|
||||
sources: BTreeMap::from([(subject.clone(), Some(canonical.clone())), (created.id.clone(), None)]),
|
||||
calls: Mutex::new(vec![]),
|
||||
});
|
||||
let store = store.with_canonical_permission_source(provider.clone());
|
||||
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(canonical.clone()));
|
||||
assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default.
|
||||
assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates.
|
||||
assert!(store.bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source")).is_err());
|
||||
store.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child")).unwrap();
|
||||
assert_eq!(store.list_subject_worker_execution_grants(&subject).unwrap(), vec![child(&subject, &canonical.change_id, "canonical-child")]);
|
||||
assert_eq!(store.worker_execution_grant("runtime", "canonical-child").unwrap(), Some(child(&subject, &canonical.change_id, "canonical-child")));
|
||||
for sql in [
|
||||
"INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants",
|
||||
"UPDATE canonical_worker_execution_grants SET source_change_id='forged'",
|
||||
"DELETE FROM canonical_worker_execution_grants",
|
||||
] {
|
||||
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err());
|
||||
}
|
||||
assert!(provider.calls.lock().unwrap().iter().all(|(workspace, id)| workspace == "workspace-a" && [subject.as_str(), created.id.as_str(), non_idempotent.id.as_str()].contains(&id.as_str())));
|
||||
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot);
|
||||
store.begin_subject_deletion(&subject, "delete-canonical-subject").unwrap();
|
||||
let workers = store.deletion_subject_workers(&subject).unwrap();
|
||||
assert!(workers.contains(&("runtime".into(), "canonical-child".into())));
|
||||
assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into())));
|
||||
assert!(store.finish_subject_deletion(&subject, "delete-canonical-subject").unwrap().completed);
|
||||
assert!(store.worker_execution_grant("runtime", "canonical-child").is_err());
|
||||
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_remains_possible() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
replace(&store, &subject, "old-receipt", vec![]);
|
||||
let receipt = seal(&store);
|
||||
// Reacquire a managed store handle; tests must not infer/open physical storage paths.
|
||||
let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap();
|
||||
assert!(old_writer.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute("UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1", [&subject])?;
|
||||
Ok(())
|
||||
}).is_err());
|
||||
drop(old_writer);
|
||||
for sql in [
|
||||
"INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions",
|
||||
"UPDATE subject_permissions SET change_id=change_id",
|
||||
"DELETE FROM subject_permissions",
|
||||
"INSERT OR REPLACE INTO subject_permission_changes SELECT * FROM subject_permission_changes",
|
||||
"UPDATE subject_permission_changes SET record_json=record_json",
|
||||
"DELETE FROM subject_permission_changes",
|
||||
"INSERT OR REPLACE INTO subject_permission_receipts SELECT * FROM subject_permission_receipts",
|
||||
"UPDATE subject_permission_receipts SET request_fingerprint=request_fingerprint",
|
||||
"DELETE FROM subject_permission_receipts",
|
||||
"INSERT INTO worker_execution_grants SELECT 'runtime','late-writer',subject_id,change_id,'{}' FROM subject_permissions LIMIT 1",
|
||||
"INSERT OR REPLACE INTO permission_authority_seal SELECT * FROM permission_authority_seal",
|
||||
"UPDATE permission_authority_seal SET transfer_id='changed'",
|
||||
"DELETE FROM permission_authority_seal",
|
||||
"INSERT OR REPLACE INTO permission_source_transfer_receipt SELECT * FROM permission_source_transfer_receipt",
|
||||
"UPDATE permission_source_transfer_receipt SET receipt_json='{}'",
|
||||
"DELETE FROM permission_source_transfer_receipt",
|
||||
] {
|
||||
assert!(store.database.try_with_connection::<_, SubjektivError>(|c| { c.execute_batch(sql)?; Ok(()) }).is_err(), "{sql}");
|
||||
}
|
||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?;
|
||||
tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&subject])?;
|
||||
tx.execute("DELETE FROM subject_permission_receipts WHERE subject_id=?1", [&subject])?;
|
||||
tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(store.preview_permission_transfer(&receipt.snapshot.identity).unwrap(), receipt.snapshot);
|
||||
assert_eq!(store.seal_permission_sources(&receipt.snapshot.identity, &receipt.transfer_id, &receipt.snapshot.export_digest).unwrap(), receipt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_export() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let writer = store.clone();
|
||||
let start = barrier.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
start.wait();
|
||||
writer.set_subject_permissions(&subject, &SubjectPermissionsUpdate {
|
||||
operation_id: "racing-old-writer".into(), expected_change_id: Some(initial.change_id), execution: vec![], delegation: vec![],
|
||||
})
|
||||
});
|
||||
barrier.wait();
|
||||
let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest);
|
||||
let written = thread.join().unwrap();
|
||||
match (sealed, written) {
|
||||
(Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => assert_eq!(receipt.snapshot, preview),
|
||||
(Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(!store.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap()),
|
||||
other => panic!("unexpected serialized outcomes: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
seal(&store);
|
||||
let provider = Arc::new(Canonical {
|
||||
sources: BTreeMap::from([(subject.clone(), Some(SubjectPermissions {
|
||||
change_id: "foreign-source".into(),
|
||||
execution: vec![ScopedPermission {
|
||||
operation: "file.read".into(),
|
||||
resource: PermissionResource::Workspace { workspace_id: "workspace-b".into() },
|
||||
}],
|
||||
delegation: vec![read_permission()],
|
||||
}))]),
|
||||
calls: Mutex::new(vec![]),
|
||||
});
|
||||
let store = store.with_canonical_permission_source(provider);
|
||||
assert!(store.subject_permissions(&subject).is_err());
|
||||
assert!(store.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child")).is_err());
|
||||
assert!(store.worker_execution_grant("runtime", "foreign-child").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_workspace() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let (_manager_a, _scope_a, store_a) = open_store(first.path(), "workspace-a");
|
||||
let (_manager_b, _scope_b, store_b) = open_store(second.path(), "workspace-a");
|
||||
let a = store_a.database_identity().unwrap();
|
||||
let b = store_b.database_identity().unwrap();
|
||||
assert_ne!(a, b);
|
||||
let preview = store_a.preview_permission_transfer(&a).unwrap();
|
||||
assert!(matches!(store_b.preview_permission_transfer(&a), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(matches!(store_b.seal_permission_sources(&a, "transfer", &preview.export_digest), Err(SubjektivError::PermissionTransferConflict)));
|
||||
assert!(!store_b.database.try_with_connection::<_, SubjektivError>(is_sealed).unwrap());
|
||||
}
|
||||
@@ -579,3 +579,95 @@ fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds(
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_transfer_extension_reopens_on_every_published_family_without_relabelling_receipts() {
|
||||
for format in PUBLISHED {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
published_fixture(temp.path(), format);
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
let saved = db.with_connection(|connection| Ok(receipts(connection))).unwrap();
|
||||
let old_seal = db.with_connection(|connection| {
|
||||
if format == Format::Permission18 {
|
||||
Ok(Some(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?))
|
||||
} else { Ok(None) }
|
||||
}).unwrap();
|
||||
db.transaction(install_permission_transfer).unwrap();
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(receipts(connection), saved);
|
||||
assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?);
|
||||
if let Some(raw) = &old_seal {
|
||||
assert_eq!(connection.query_row("SELECT export_json FROM permission_authority_seal", [], |row| row.get::<_, String>(0))?, *raw);
|
||||
}
|
||||
assert_foreign_keys(connection);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let store = SubjektivStore::from_database(db.clone()).unwrap();
|
||||
if old_seal.is_some() {
|
||||
let identity = store.database_identity().unwrap();
|
||||
assert!(matches!(store.preview_permission_transfer(&identity), Err(crate::SubjektivError::PermissionAuthoritySealed)));
|
||||
assert!(matches!(store.seal_permission_sources(&identity, "new-transfer", "export"), Err(crate::SubjektivError::PermissionAuthoritySealed)));
|
||||
struct CanonicalEmpty;
|
||||
impl crate::CanonicalPermissionSource for CanonicalEmpty {
|
||||
fn subject_permissions(&self, workspace: &str, _subject: &str) -> crate::Result<Option<server_api::authorization::SubjectPermissions>> {
|
||||
assert_eq!(workspace, SCOPE);
|
||||
Ok(Some(server_api::authorization::SubjectPermissions {
|
||||
change_id: "canonical-source".into(), execution: vec![], delegation: vec![],
|
||||
}))
|
||||
}
|
||||
}
|
||||
let connected = store.clone().with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty));
|
||||
let subject = connected.list_subjects(1).unwrap().items[0].id.clone();
|
||||
assert_eq!(connected.subject_permissions(&subject).unwrap().unwrap().change_id, "canonical-source");
|
||||
connected.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant {
|
||||
subject_id: subject, source_change_id: "canonical-source".into(),
|
||||
runtime_id: "runtime".into(), worker_id: "canonical-child".into(),
|
||||
origin_runtime_id: "runtime".into(), origin_worker_id: "root".into(),
|
||||
profile: "builtin:coder".into(), profile_digest: "resolved-profile".into(),
|
||||
permissions: vec![], redelegation: vec![],
|
||||
}).unwrap();
|
||||
}
|
||||
drop(store);
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(receipts(connection), saved);
|
||||
assert_eq!(domain_shape(connection)?, reference_shape_with_transfer(format, true)?);
|
||||
assert_foreign_keys(connection);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
let before_shape = db.with_connection(domain_shape).unwrap();
|
||||
let before_rows = db.with_connection(|connection| Ok(all_rows(connection))).unwrap();
|
||||
let result = db.transaction(|tx| {
|
||||
install_permission_transfer(tx)?;
|
||||
tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(domain_shape(connection)?, before_shape);
|
||||
assert_eq!(all_rows(connection), before_rows);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
db.transaction(install_permission_transfer).unwrap();
|
||||
db.with_connection(|connection| {
|
||||
connection.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
let scope = feature_storage::FeatureStorage::new(temp.path()).scope(SCOPE).unwrap();
|
||||
let registered = scope.register(REGISTRATION).unwrap();
|
||||
assert!(matches!(scope.open(®istered), Err(FeatureStorageError::MigrationHistory { .. })));
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
|
||||
@@ -81,7 +81,14 @@ pub(super) fn validate(
|
||||
applied: &[(u32, String)],
|
||||
) -> feature_storage::Result<()> {
|
||||
let format = recognize(applied)?;
|
||||
let expected = reference_shape(format)?;
|
||||
let transfer = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='permission_source_transfer_receipt')",
|
||||
[], |row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) {
|
||||
return Err(incompatible("permission transfer requires complete common 1..17"));
|
||||
}
|
||||
let expected = reference_shape_with_transfer(format, transfer)?;
|
||||
let actual = domain_shape(tx)?;
|
||||
if actual != expected {
|
||||
let difference = actual
|
||||
@@ -115,9 +122,14 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result<Vec<SchemaOb
|
||||
Ok(shape)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>> {
|
||||
reference_shape_with_transfer(format, false)
|
||||
}
|
||||
|
||||
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.
|
||||
static SHAPES: [OnceLock<Vec<SchemaObject>>; 25] = [const { OnceLock::new() }; 25];
|
||||
static SHAPES: [OnceLock<Vec<SchemaObject>>; 50] = [const { OnceLock::new() }; 50];
|
||||
let index = match format {
|
||||
Format::Common(count) => count,
|
||||
Format::Permission18 => 18,
|
||||
@@ -127,7 +139,7 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
|
||||
Format::Consolidation20 => 22,
|
||||
Format::Fixed20 => 23,
|
||||
Format::Cancellation20 => 24,
|
||||
};
|
||||
} + if transfer { 25 } else { 0 };
|
||||
if let Some(shape) = SHAPES[index].get() {
|
||||
return Ok(shape.clone());
|
||||
}
|
||||
@@ -184,11 +196,58 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
|
||||
tx.execute_batch(CONSOLIDATION_SQL)?;
|
||||
}
|
||||
}
|
||||
if transfer {
|
||||
install_transfer_shape(&tx, format == Format::Permission18)?;
|
||||
}
|
||||
let shape = domain_shape(&tx)?;
|
||||
let _ = SHAPES[index].set(shape.clone());
|
||||
Ok(shape)
|
||||
}
|
||||
|
||||
/// Installs a separate permission-domain v1 shape without changing common/published receipts.
|
||||
/// Existing published seals and their serialized exports are never rewritten or relabelled.
|
||||
/// The FeatureDatabase guard and caller's transaction cover validation and installation.
|
||||
pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
let mut statement = tx.prepare(&format!("SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"))?;
|
||||
let applied = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<std::result::Result<Vec<(u32, String)>, _>>()?;
|
||||
let format = recognize(&applied)?;
|
||||
if matches!(format, Format::Common(count) if count < COMMON_COUNT) {
|
||||
return Ok(());
|
||||
}
|
||||
validate(tx, &applied)?;
|
||||
let installed: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='permission_source_transfer_receipt')",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
if !installed {
|
||||
install_transfer_shape(tx, format == Format::Permission18)?;
|
||||
}
|
||||
validate(tx, &applied)
|
||||
}
|
||||
|
||||
fn install_transfer_shape(tx: &Transaction<'_>, published_permission: bool) -> feature_storage::Result<()> {
|
||||
if !published_permission {
|
||||
tx.execute_batch(PERMISSION_SEAL_SQL)?;
|
||||
}
|
||||
tx.execute_batch(include_str!("permissions/transfer.sql"))?;
|
||||
// Seal every legacy writer. New execution binding evidence has no legacy-source FK and
|
||||
// lives in the extension table, leaving published grants and their triggers intact.
|
||||
for table in PERMISSION_FENCE_TABLES {
|
||||
for action in ["INSERT", "UPDATE", "DELETE"] {
|
||||
let deletion_exception = if action == "DELETE" {
|
||||
" AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)"
|
||||
} else { "" };
|
||||
tx.execute_batch(&format!(
|
||||
"CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table}
|
||||
WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception}
|
||||
BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PERMISSION_FENCE_TABLES: [&str; 4] = [
|
||||
"subject_permissions",
|
||||
"subject_permission_changes",
|
||||
@@ -196,7 +255,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [
|
||||
"worker_execution_grants",
|
||||
];
|
||||
|
||||
// Frozen physical shape evidence, not target migrations. Permission SQL is from
|
||||
// Published DDL is retained byte-for-byte as physical shape evidence. Only the permission
|
||||
// fragment is also reused by the explicit separate-domain initializer above, never as migration 18.
|
||||
// Permission SQL is from
|
||||
// 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs.
|
||||
// Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03:
|
||||
// crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! New Subjects receive their self-scoped conversation source in the creation transaction.
|
||||
//! This path is never called by migration, launch, restore or permission reads. Receipts
|
||||
//! retain only the issued identity and input fingerprint across erasure, preventing replay
|
||||
//! Before sealing, new Subjects receive their self-scoped source in the creation transaction.
|
||||
//! After sealing, creation retains enrollment evidence only; current reads never revive defaults.
|
||||
//! This path is never called by migration, launch, restore or permission reads. Receipts retain
|
||||
//! identity, input fingerprint and any post-seal enrollment across erasure, preventing replay
|
||||
//! from recreating an erased Subject or restoring a revoked policy.
|
||||
use super::*;
|
||||
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
|
||||
@@ -18,6 +19,22 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
/// Creation-only enrollment evidence for the canonical domain. Never a current permission
|
||||
/// source or a read-time default; the Host must not apply it to an existing/unset policy.
|
||||
pub fn subject_permission_enrollment(&self, subject_id: &str) -> Result<Option<SubjectPermissions>> {
|
||||
self.database.try_transaction(|tx| {
|
||||
require_subject(tx, subject_id)?;
|
||||
if !permissions::transfer::has_table(tx, "permission_authority_seal")? {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw: Option<String> = tx.query_row(
|
||||
"SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1",
|
||||
[subject_id], |row| row.get::<_, Option<String>>(0),
|
||||
).optional()?.flatten();
|
||||
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)).transpose()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn create_subject_request(
|
||||
&self,
|
||||
role: SubjectRole,
|
||||
@@ -60,10 +77,20 @@ impl SubjektivStore {
|
||||
execution: subject_conversation_permissions(self.scope_id(), &record.id),
|
||||
delegation: vec![],
|
||||
};
|
||||
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||
if let Some(operation) = operation_id {
|
||||
let sealed = permissions::transfer::is_sealed(tx)?;
|
||||
if !sealed {
|
||||
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||
}
|
||||
if sealed {
|
||||
// Retain creation-only enrollment for the canonical domain, never a legacy
|
||||
// source write or a read-time default. Even non-idempotent creation has evidence.
|
||||
let enrollment_operation = operation_id.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("subject-enrollment:{}", record.id));
|
||||
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint,permission_enrollment_json) VALUES(?1,?2,?3,?4)",
|
||||
params![enrollment_operation,record.id,fingerprint,serde_json::to_string(&source)?])?;
|
||||
} else if let Some(operation) = operation_id {
|
||||
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
|
||||
}
|
||||
Ok(record)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Server-side registration of the compiled builtin operation declarations.
|
||||
//!
|
||||
//! This is a trusted builtin loader, not a wire-catalog importer. Namespace
|
||||
//! ownership is listed here explicitly; neither a permission spelling nor a
|
||||
//! management request chooses an owner. The existing operation descriptors own
|
||||
//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes
|
||||
//! and actual operation authorization remain independent of this metadata load.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use feature_permissions::{FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions,
|
||||
PermissionDefinition, PermissionHost, PermissionKey, PermissionReference, PermissionSetId,
|
||||
SetContribution, WorkspaceId};
|
||||
use super::super::FeatureInstallError;
|
||||
|
||||
const OWNERS: &[(&str, &str)] = &[
|
||||
("filesystem", "builtin:workdir"), ("bash", "builtin:bash"),
|
||||
("workdir", "builtin:workdir"), ("worker", "builtin:worker"),
|
||||
("subworker", "builtin:sub-worker-control"), ("ticket", "builtin:ticket"),
|
||||
("objective", "builtin:objective"), ("merge_request", "builtin:merge-request"),
|
||||
("repository", "builtin:repository"), ("drive", "builtin:drive"),
|
||||
("workspace_config", "builtin:workspace-config"), ("memory", "builtin:memory"),
|
||||
("journal", "builtin:journal-recording"), ("chat", "builtin:subjektiv-chat"),
|
||||
("session", "builtin:session-explore"), ("web", "builtin:web"),
|
||||
("notification", "builtin:notification"),
|
||||
];
|
||||
|
||||
pub fn builtin_permission_host(workspace: WorkspaceId) -> Result<PermissionHost, FeatureInstallError> {
|
||||
let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string());
|
||||
let mut host = PermissionHost::new(workspace);
|
||||
let mut features = BTreeMap::new();
|
||||
let operations = server_api::authorization::subject_permission_operations();
|
||||
for (namespace, owner) in OWNERS {
|
||||
let feature = match features.get(owner) {
|
||||
Some(feature) => feature,
|
||||
None => {
|
||||
let feature = host.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?).map_err(|e| invalid(&e))?;
|
||||
features.entry(owner).or_insert(feature)
|
||||
},
|
||||
};
|
||||
let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?;
|
||||
let handle = host.bind_namespace(feature, id.clone()).map_err(|e| invalid(&e))?;
|
||||
let permissions = operations.iter().filter(|operation| operation.operation.split('.').next() == Some(namespace))
|
||||
.map(|operation| Ok(PermissionDefinition {
|
||||
key:PermissionKey::parse(operation.operation.clone()).map_err(|e| invalid(&e))?,
|
||||
description:operation.description.clone(),children:vec![],
|
||||
})).collect::<Result<Vec<_>,FeatureInstallError>>()?;
|
||||
let keys = permissions.iter().map(|definition| definition.key.clone()).collect();
|
||||
host.declare(&handle,NamespaceDeclaration {
|
||||
permissions,
|
||||
sets:vec![SetContribution { set:PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?, children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:id,keys})] }],
|
||||
}).map_err(|e|invalid(&e))?;
|
||||
}
|
||||
// A new builtin operation needs an intentional namespace ownership decision.
|
||||
for operation in operations {
|
||||
if !OWNERS.iter().any(|(namespace,_)| operation.operation.split('.').next() == Some(*namespace)) {
|
||||
return Err(FeatureInstallError::InvalidDescriptor(format!("builtin operation has no namespace owner: {}",operation.operation)));
|
||||
}
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn builtin_keys_are_complete_exact_and_have_no_inferred_children() {
|
||||
let host=builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap();
|
||||
let catalog=host.catalog();
|
||||
let mut keys=std::collections::BTreeSet::new();
|
||||
for namespace in catalog.namespaces {
|
||||
for permission in namespace.declaration.permissions {
|
||||
assert!(permission.children.is_empty());
|
||||
assert!(keys.insert(permission.key.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
assert_eq!(keys,server_api::authorization::subject_permission_operations().into_iter().map(|o|o.operation).collect());
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,7 @@ impl FeaturePermissionRegistry {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[path = "builtin/permission_catalog.rs"]
|
||||
mod builtin_catalog;
|
||||
pub use builtin_catalog::builtin_permission_host;
|
||||
|
||||
@@ -21,6 +21,10 @@ axum = { workspace = true, features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
futures.workspace = true
|
||||
config-source.workspace = true
|
||||
content-history.workspace = true
|
||||
content-history-store.workspace = true
|
||||
content-history-adapters.workspace = true
|
||||
feature-permissions.workspace = true
|
||||
feature-storage.workspace = true
|
||||
workspace-drive.workspace = true
|
||||
subjektiv.workspace = true
|
||||
@@ -66,9 +70,6 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
|
||||
[dev-dependencies]
|
||||
content-history.workspace = true
|
||||
content-history-store.workspace = true
|
||||
content-history-adapters.workspace = true
|
||||
rusqlite = { workspace = true, features = ["hooks"] }
|
||||
feature-storage = { workspace = true, features = ["test-support"] }
|
||||
native-tls = "0.2"
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Owner-only adoption of Workspace source and Subject policy in the Server transaction.
|
||||
//!
|
||||
//! The old Feature database is exported and permanently sealed before its immutable
|
||||
//! transfer is adopted here. That preparation is not a distributed commit: failure
|
||||
//! after seal leaves an explicit, resumable transfer, never a legacy fallback.
|
||||
//! Normal saves never touch the old database. Object preparation, source,
|
||||
//! projection, policy, editing head and receipt commit in one caller-owned transaction.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{ConfigTreeSnapshot, WorkspaceConfigSchemaBundle};
|
||||
use content_history::HistoryId;
|
||||
use content_history_store::{Access, Authority, ScopeKey, ScopedStore};
|
||||
use feature_permissions::PermissionHost;
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
|
||||
use crate::policy_config::{self, AdoptedSubjectPolicy, PermissionSource};
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub(crate) const SCHEMA: &str = include_str!("config_adoption.sql");
|
||||
pub(crate) const MIGRATION_NAME: &str = "Atomic Workspace source and Subject policy adoption";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionRequest {
|
||||
pub operation_id: String,
|
||||
/// Editing history identity, not a content digest. Null is only the initial head.
|
||||
pub expected_head: Option<String>,
|
||||
pub expected_catalog_digest: String,
|
||||
pub expected_legacy_export: Option<String>,
|
||||
pub source: ConfigCommitRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimePolicyApplication {
|
||||
NextCurrentPolicyCheck,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionResult {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
pub history_head: String,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub source_stored: bool,
|
||||
pub policy_committed: bool,
|
||||
/// Consumers must reload current policy before their next protected operation.
|
||||
/// This is not confirmation that a running Worker has stopped or reloaded.
|
||||
pub runtime_application: RuntimePolicyApplication,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationPreview {
|
||||
pub expected_head: Option<String>,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub changed_paths: Vec<String>,
|
||||
pub changed_subjects: Vec<String>,
|
||||
}
|
||||
|
||||
/// Only the trusted migration boundary constructs this from a verified Feature
|
||||
/// export. The transfer identity binds Workspace, physical DB, export and the full
|
||||
/// adoption request. It is not an HTTP request or an authority credential.
|
||||
pub(crate) struct ConfigurationPolicyTransfer {
|
||||
pub transfer_identity: String,
|
||||
pub physical_identity: String,
|
||||
pub export_digest: String,
|
||||
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
}
|
||||
|
||||
pub(crate) struct CanonicalConfigurationSource {
|
||||
pub store: SqliteWorkspaceStore,
|
||||
pub host: PermissionHost,
|
||||
}
|
||||
|
||||
impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource {
|
||||
fn subject_permissions(&self, workspace: &str, subject: &str) -> subjektiv::Result<Option<server_api::authorization::SubjectPermissions>> {
|
||||
if self.host.catalog().workspace.as_str() != workspace {
|
||||
return Err(subjektiv::SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
self.store.adopted_subject_policy(workspace, subject)
|
||||
.and_then(|policy| policy.map(|policy| policy.legacy_read(&self.host)).transpose())
|
||||
.map_err(|error| subjektiv::SubjektivError::InvalidRecord(format!("canonical configuration policy unavailable: {error}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigurationCurrent {
|
||||
pub state: WorkspaceConfigState,
|
||||
pub history_head: Option<String>,
|
||||
pub canonical: bool,
|
||||
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigurationMigrationBaseline<'a> {
|
||||
pub original: &'a WorkspaceConfigState,
|
||||
pub state: &'a WorkspaceConfigState,
|
||||
pub policies: &'a BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
pub diagnostics: &'a [policy_config::MigrationDiagnostic],
|
||||
pub export_digest: &'a str,
|
||||
}
|
||||
|
||||
struct OwnerScope<'a>(&'a str);
|
||||
impl Authority for OwnerScope<'_> {
|
||||
fn allows(&self, key: &ScopeKey, access: Access) -> bool {
|
||||
key.workspace == self.0 && key.target == "configuration"
|
||||
&& matches!(access, Access::Read | Access::Write)
|
||||
}
|
||||
}
|
||||
|
||||
fn content_error(e: impl std::fmt::Display) -> Error {
|
||||
Error::Store(format!("configuration content: {e}"))
|
||||
}
|
||||
fn conflict(message: &str) -> Error {
|
||||
Error::WorkspaceConfigConflict(message.into())
|
||||
}
|
||||
fn json<T: Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|e| Error::Store(e.to_string()))
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
|
||||
serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string()))
|
||||
}
|
||||
fn scope(workspace: &str) -> ScopeKey {
|
||||
ScopeKey { workspace: workspace.into(), target: "configuration".into() }
|
||||
}
|
||||
fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> {
|
||||
let allowed: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",
|
||||
params![workspace, account], |r| r.get(0),
|
||||
)?;
|
||||
if !allowed { return Err(Error::WorkspacePermissionDenied("configuration management requires the Workspace owner".into())); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn head(conn: &Connection, workspace: &str) -> Result<Option<HistoryId>> {
|
||||
let bytes: Option<Vec<u8>> = conn.query_row(
|
||||
"SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'", [workspace], |r| r.get(0),
|
||||
).optional()?.flatten();
|
||||
bytes.map(|bytes| bytes.try_into().map(HistoryId::from_bytes)
|
||||
.map_err(|_| Error::RegistryInconsistency("invalid configuration editing head".into()))).transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn request_fingerprint(workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result<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> {
|
||||
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> {
|
||||
Ok(config_source::digest_bytes(json(&host.catalog())?.as_bytes()))
|
||||
}
|
||||
|
||||
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 result = BTreeMap::new();
|
||||
for row in query.query_map([workspace], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? {
|
||||
let (subject, raw) = row?;
|
||||
let policy: AdoptedSubjectPolicy = decode(&raw)?;
|
||||
if subject != policy.source.subject_id { return Err(Error::RegistryInconsistency("Subject policy identity mismatch".into())); }
|
||||
result.insert(subject, policy);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn receipt(conn: &Connection, workspace: &str, operation: &str) -> Result<Option<(String, ConfigurationAdoptionResult)>> {
|
||||
let row: Option<(String,String)> = conn.query_row(
|
||||
"SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2",
|
||||
params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)),
|
||||
).optional()?;
|
||||
row.map(|(fingerprint,result)| Ok((fingerprint,decode(&result)?))).transpose()
|
||||
}
|
||||
|
||||
fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec<String> {
|
||||
before.entries.keys().chain(after.entries.keys()).collect::<BTreeSet<_>>().into_iter()
|
||||
.filter(|path| before.entries.get(*path) != after.entries.get(*path))
|
||||
.map(|path| path.as_str().to_owned()).collect()
|
||||
}
|
||||
fn changed_subjects(before: &BTreeMap<String,AdoptedSubjectPolicy>, 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 {
|
||||
pub(crate) fn configuration_current(&self, workspace: &str, account: &str) -> Result<ConfigurationCurrent> {
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
require_owner(&tx, workspace, account)?;
|
||||
Ok(ConfigurationCurrent {
|
||||
state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?,
|
||||
history_head: head(&tx, workspace)?.map(|id| id.to_string()),
|
||||
canonical: canonical(&tx, workspace)?,
|
||||
policies: policies(&tx, workspace)?,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn configuration_request_result(&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest) -> Result<Option<ConfigurationAdoptionResult>> {
|
||||
let fingerprint = request_fingerprint(workspace, account, request)?;
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn, workspace, account)?;
|
||||
match receipt(conn, workspace, &request.operation_id)? {
|
||||
Some((saved, result)) if saved == fingerprint => Ok(Some(result)),
|
||||
Some(_) => Err(conflict("operation ID reused with another request")),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Authentication supplies `account`; it is never accepted from an adoption DTO.
|
||||
pub fn configuration_head(&self, workspace: &str, account: &str) -> Result<Option<String>> {
|
||||
self.with_conn(|conn| { require_owner(conn, workspace, account)?; Ok(head(conn,workspace)?.map(|id|id.to_string())) })
|
||||
}
|
||||
|
||||
/// Result lookup is a read. A historical receipt never reactivates policy.
|
||||
pub fn configuration_adoption_result(&self, workspace: &str, account: &str, operation: &str) -> Result<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(
|
||||
&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
mut require_subject: impl FnMut(&str) -> Result<()>,
|
||||
) -> Result<ConfigurationPreview> {
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn,workspace,account)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let (current,state,source) = prepare(&tx,workspace,request,schema,host,baseline,&mut require_subject)?;
|
||||
let before = match baseline { Some(baseline) => baseline.policies.clone(), None => policies(&tx,workspace)? };
|
||||
let mut after = before.clone();
|
||||
policy_config::apply(&mut after,&source,"preview");
|
||||
Ok(ConfigurationPreview {
|
||||
expected_head: request.expected_head.clone(),
|
||||
changed_paths: changed_paths(¤t.snapshot,&state.snapshot),
|
||||
changed_subjects: changed_subjects(&before,&after), state,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The transfer callback may seal the old DB only after all validation and
|
||||
/// current-head checks. If subsequent Server persistence fails, that specific
|
||||
/// transfer remains pending; callers reconcile/retry the same request, not a new ID.
|
||||
pub(crate) fn adopt_configuration(
|
||||
&self, workspace: &str, account: &str, request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
mut require_subject: impl FnMut(&str) -> Result<()>,
|
||||
mut prepare_transfer: impl FnMut(&str) -> Result<ConfigurationPolicyTransfer>,
|
||||
) -> Result<ConfigurationAdoptionResult> {
|
||||
let fingerprint = request_fingerprint(workspace, account, request)?;
|
||||
self.with_immediate_transaction(|tx| {
|
||||
require_owner(tx,workspace,account)?;
|
||||
if let Some((saved,result)) = receipt(tx,workspace,&request.operation_id)? {
|
||||
if saved != fingerprint { return Err(conflict("operation ID reused with another request")); }
|
||||
return Ok(result);
|
||||
}
|
||||
let (_,state,source) = prepare(tx,workspace,request,schema,host,baseline,&mut require_subject)?;
|
||||
let authority_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |r|r.get(0))?;
|
||||
if authority_exists && (baseline.is_some() || request.expected_legacy_export.is_some()) {
|
||||
return Err(conflict("legacy transfer is already adopted; reload current source"));
|
||||
}
|
||||
if !authority_exists && request.expected_legacy_export.is_none() {
|
||||
return Err(conflict("legacy export identity must be observed before transfer"));
|
||||
}
|
||||
let mut current_policies = if authority_exists { policies(tx,workspace)? } else {
|
||||
let transfer = prepare_transfer(&fingerprint)?;
|
||||
if transfer.transfer_identity != fingerprint || request.expected_legacy_export.as_deref() != Some(transfer.export_digest.as_str()) {
|
||||
return Err(conflict("transfer does not bind the complete adoption request and export"));
|
||||
}
|
||||
tx.execute("INSERT INTO workspace_configuration_authorities VALUES(?1,?2,?3,?4)",params![workspace,transfer.transfer_identity,transfer.physical_identity,transfer.export_digest])?;
|
||||
transfer.policies
|
||||
};
|
||||
// Client-selected operation IDs are not policy change identities: reusing
|
||||
// an old legacy change ID must never make a stale child grant current.
|
||||
policy_config::apply(&mut current_policies,&source,&format!("configuration:{fingerprint}"));
|
||||
crate::config_source::sync_adopted_config_references(tx, workspace, &state)?;
|
||||
let authority = OwnerScope(workspace);
|
||||
let content = ScopedStore::new(tx,scope(workspace),&authority);
|
||||
let prepared = content_history_adapters::settings::prepare(&state.snapshot).map_err(content_error)?;
|
||||
for (id,object) in &prepared.objects { content.put(*id,&object.encode()).map_err(content_error)?; }
|
||||
tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id=?1",[workspace])?;
|
||||
crate::config_source::insert_materialized_state(tx,workspace,&state,&chrono::Utc::now().to_rfc3339())?;
|
||||
tx.execute("DELETE FROM workspace_configuration_policies WHERE workspace_id=?1",[workspace])?;
|
||||
for (subject,policy) in ¤t_policies {
|
||||
tx.execute("INSERT INTO workspace_configuration_policies VALUES(?1,?2,?3)", params![workspace,subject,json(policy)?])?;
|
||||
}
|
||||
let new_head = content.adopt(prepared.root,head(tx,workspace)?,account.into(),chrono::Utc::now().timestamp_micros()).map_err(content_error)?;
|
||||
let result = ConfigurationAdoptionResult {
|
||||
workspace_id: workspace.into(), operation_id: request.operation_id.clone(),
|
||||
history_head: new_head.to_string(), state, source_stored: true, policy_committed: true,
|
||||
runtime_application: RuntimePolicyApplication::NextCurrentPolicyCheck,
|
||||
};
|
||||
tx.execute("INSERT INTO workspace_configuration_receipts VALUES(?1,?2,?3,?4)",params![workspace,request.operation_id,fingerprint,json(&result)?])?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn adopted_subject_policy(&self, workspace: &str, subject: &str) -> Result<Option<AdoptedSubjectPolicy>> {
|
||||
self.with_conn(|conn| {
|
||||
let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?;
|
||||
if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); }
|
||||
let raw: Option<String> = conn.query_row("SELECT policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 AND subject_id=?2",params![workspace,subject],|r|r.get(0)).optional()?;
|
||||
raw.map(|raw|decode(&raw)).transpose()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
tx: &Transaction<'_>, workspace: &str, request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle, host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
require_subject: &mut impl FnMut(&str) -> Result<()>,
|
||||
) -> Result<(WorkspaceConfigState,WorkspaceConfigState,PermissionSource)> {
|
||||
if let Some(baseline) = baseline {
|
||||
if baseline.diagnostics.iter().any(|diagnostic| matches!(diagnostic, policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. })) {
|
||||
return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into()));
|
||||
}
|
||||
}
|
||||
if host.catalog().workspace.as_str() != workspace {
|
||||
return Err(Error::WorkspacePermissionDenied("permission registry belongs to another Workspace".into()));
|
||||
}
|
||||
if catalog_digest(host)? != request.expected_catalog_digest { return Err(conflict("Feature catalog changed")); }
|
||||
if head(tx,workspace)?.map(|id|id.to_string()) != request.expected_head { return Err(conflict("editing history head changed")); }
|
||||
let current = crate::config_source::load_state(tx,workspace)?.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?;
|
||||
let editing_base = if let Some(baseline) = baseline {
|
||||
if *baseline.original != current || request.expected_legacy_export.as_deref() != Some(baseline.export_digest) {
|
||||
return Err(conflict("migration source or export changed"));
|
||||
}
|
||||
baseline.state
|
||||
} else { ¤t };
|
||||
if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); }
|
||||
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
|
||||
let candidate = crate::config_source::evaluate_candidate_in_transaction(
|
||||
tx,workspace,editing_base.clone(),crate::config_source::load_evaluation_identity(tx,workspace)?,
|
||||
&request.source.changes,schema,
|
||||
)?;
|
||||
crate::config_source::validate_workspace_config_candidate_projections(workspace,&candidate)?;
|
||||
let source = policy_config::source(&candidate.evaluation)?;
|
||||
policy_config::validate_source(host,&source,require_subject)?;
|
||||
let state = WorkspaceConfigState { snapshot:candidate.snapshot,contract:candidate.contract,projection_digest:candidate.evaluation.projection_digest };
|
||||
Ok((current,state,source))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_adoption_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE workspace_configuration_authorities (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
transfer_identity TEXT NOT NULL,
|
||||
legacy_physical_identity TEXT NOT NULL,
|
||||
legacy_export_digest TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE workspace_configuration_policies (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
|
||||
subject_id TEXT NOT NULL,
|
||||
policy_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, subject_id)
|
||||
);
|
||||
CREATE TABLE workspace_configuration_receipts (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, operation_id)
|
||||
);
|
||||
CREATE TRIGGER workspace_configuration_authorities_no_update BEFORE UPDATE ON workspace_configuration_authorities BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration authority transfers are immutable');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_authorities_no_delete BEFORE DELETE ON workspace_configuration_authorities BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration authority transfers are retained');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_receipts_no_update BEFORE UPDATE ON workspace_configuration_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration adoption receipts are immutable');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_receipts_no_delete BEFORE DELETE ON workspace_configuration_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration adoption receipts are retained');
|
||||
END;
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Adoption component tests use the real Server SQLite transaction and content
|
||||
//! store. The legacy transfer callback is empty in this fixture; physical Feature
|
||||
//! transfer and authenticated HTTP are separate integration coverage.
|
||||
use super::*;
|
||||
use crate::config_source::{MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider};
|
||||
use crate::policy_config::PermissionConfigSchemaProvider;
|
||||
use config_source::{ConfigTreeChange, VirtualPath};
|
||||
use feature_permissions::*;
|
||||
|
||||
const WORKSPACE: &str = "adoption-ws";
|
||||
const OWNER: &str = "adoption-owner";
|
||||
|
||||
fn host(children: bool) -> PermissionHost {
|
||||
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
|
||||
let feature = host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()).unwrap();
|
||||
let namespace = host.bind_namespace(&feature,NamespaceId::parse("ticket").unwrap()).unwrap();
|
||||
host.declare(&namespace, NamespaceDeclaration {
|
||||
permissions: vec![
|
||||
PermissionDefinition { key: PermissionKey::parse("ticket.hoge").unwrap(), description:String::new(), children: if children {vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]} else {vec![]} },
|
||||
PermissionDefinition { key: PermissionKey::parse("ticket.hoge.fuga").unwrap(), description:String::new(), children: vec![] },
|
||||
],
|
||||
sets: vec![SetContribution { set:PermissionSetId::parse("ticket").unwrap(),children:vec![PermissionReference::Namespace(NamespacePermissions {namespace:NamespaceId::parse("ticket").unwrap(),keys:vec![PermissionKey::parse("ticket.hoge").unwrap()]})] }],
|
||||
}).unwrap();
|
||||
host
|
||||
}
|
||||
fn schema() -> WorkspaceConfigSchemaBundle {
|
||||
WorkspaceConfigSchemaBundle::compose([
|
||||
PermissionConfigSchemaProvider.contribution().unwrap(),
|
||||
crate::prompt_settings::PromptConfigSchemaProvider.contribution().unwrap(),
|
||||
]).unwrap()
|
||||
}
|
||||
fn open(path: &std::path::Path) -> SqliteWorkspaceStore { SqliteWorkspaceStore::open_for_test(path).unwrap() }
|
||||
fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = open(&dir.path().join("server.sqlite"));
|
||||
db.with_immediate_transaction(|tx| {
|
||||
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
db.ensure_workspace_config_materialized_with_schema(WORKSPACE,"now",schema()).unwrap();
|
||||
(dir,db)
|
||||
}
|
||||
fn request(db: &SqliteWorkspaceStore, host: &PermissionHost, id: &str, source: Option<&str>) -> ConfigurationAdoptionRequest {
|
||||
let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap();
|
||||
let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap();
|
||||
let changes = source.map(|source| vec![ConfigTreeChange::Update {
|
||||
path: path.clone(), expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(), content:source.into(),
|
||||
}]).unwrap_or_default();
|
||||
ConfigurationAdoptionRequest {
|
||||
operation_id:id.into(), expected_legacy_export:db.configuration_head(WORKSPACE,OWNER).unwrap().is_none().then(||"fixture-empty".into()), expected_head:db.configuration_head(WORKSPACE,OWNER).unwrap(),expected_catalog_digest:catalog_digest(host).unwrap(),
|
||||
source:ConfigCommitRequest {base_digest:current.snapshot.digest,changes,entrypoints:vec![path]},
|
||||
}
|
||||
}
|
||||
fn adopt(db: &SqliteWorkspaceStore, host: &PermissionHost, request: &ConfigurationAdoptionRequest) -> Result<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 B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#;
|
||||
|
||||
#[test]
|
||||
fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() {
|
||||
let (_dir,db) = fixture(); let host=host(false);
|
||||
let initial=request(&db,&host,"initial",Some(A));
|
||||
let first=adopt(&db,&host,&initial).unwrap();
|
||||
let stale=request(&db,&host,"stale-a",Some(B));
|
||||
adopt(&db,&host,&request(&db,&host,"to-b",Some(B))).unwrap();
|
||||
let final_a=adopt(&db,&host,&request(&db,&host,"back-a",Some(A))).unwrap();
|
||||
assert_eq!(first.state.snapshot,final_a.state.snapshot);
|
||||
assert_ne!(first.history_head,final_a.history_head);
|
||||
assert!(adopt(&db,&host,&stale).is_err());
|
||||
// Exact replay precedes stale checks and returns only the saved old result.
|
||||
assert_eq!(adopt(&db,&host,&initial).unwrap(),first);
|
||||
assert_eq!(db.configuration_adoption_result(WORKSPACE,OWNER,"initial").unwrap(),Some(first));
|
||||
assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),Some(final_a.history_head));
|
||||
let mut changed=initial; changed.source.changes.clear();
|
||||
assert!(adopt(&db,&host,&changed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() {
|
||||
for (table,event) in [
|
||||
("content_objects","INSERT"), ("workspace_config_entries","INSERT"),
|
||||
("workspace_configuration_policies","INSERT"), ("content_scopes","UPDATE"),
|
||||
("workspace_configuration_receipts","INSERT"),
|
||||
] {
|
||||
let (dir,db)=fixture(); let host=host(false);
|
||||
let before=adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap();
|
||||
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap();
|
||||
let candidate=request(&db,&host,"failing",Some(B));
|
||||
db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap();
|
||||
assert!(adopt(&db,&host,&candidate).is_err(),"{table}");
|
||||
drop(db);
|
||||
let reopened=open(&dir.path().join("server.sqlite"));
|
||||
assert_eq!(reopened.load_workspace_config(WORKSPACE).unwrap(),Some(before.state),"{table}");
|
||||
assert_eq!(reopened.configuration_head(WORKSPACE,OWNER).unwrap(),Some(before.history_head),"{table}");
|
||||
assert_eq!(reopened.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),policy,"{table}");
|
||||
assert!(reopened.configuration_adoption_result(WORKSPACE,OWNER,"failing").unwrap().is_none(),"{table}");
|
||||
adopt(&reopened,&host,&candidate).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() {
|
||||
let (_dir,db)=fixture(); let host=host(false);
|
||||
let initial=db.load_workspace_config(WORKSPACE).unwrap();
|
||||
for source in [
|
||||
"not valid Decodal {",
|
||||
r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#,
|
||||
r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#,
|
||||
r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#,
|
||||
r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#,
|
||||
r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#,
|
||||
] {
|
||||
let request=request(&db,&host,"bad",Some(source));
|
||||
let result=db.adopt_configuration(WORKSPACE,OWNER,&request,schema(),&host,None,|s| if s=="subject-a" {Ok(())} else {Err(Error::InvalidInput("unknown Subject".into()))},|_|panic!("validation failure must precede seal"));
|
||||
assert!(result.is_err(),"{source}");
|
||||
assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(),initial);
|
||||
assert_eq!(db.configuration_head(WORKSPACE,OWNER).unwrap(),None);
|
||||
}
|
||||
let request=request(&db,&host,"other-owner",None);
|
||||
assert!(db.adopt_configuration(WORKSPACE,"non-owner",&request,schema(),&host,None,|_|Ok(()),|_|panic!("owner refusal must precede seal")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() {
|
||||
let (_dir,db)=fixture(); let host=host(false);
|
||||
adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap();
|
||||
let original=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap();
|
||||
let preview=db.preview_configuration_adoption(WORKSPACE,OWNER,&request(&db,&host,"noop",None),schema(),&host,None,|_|Ok(())).unwrap();
|
||||
assert!(preview.changed_paths.is_empty());
|
||||
assert!(preview.changed_subjects.is_empty());
|
||||
adopt(&db,&host,&request(&db,&host,"noop",None)).unwrap();
|
||||
assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original);
|
||||
adopt(&db,&host,&request(&db,&host,"omit",Some("{} as WorkspaceConfigSchema"))).unwrap();
|
||||
assert_eq!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap(),original);
|
||||
adopt(&db,&host,&request(&db,&host,"remove",Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#))).unwrap();
|
||||
assert!(db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() {
|
||||
for children in [false,true] {
|
||||
let (_dir,db)=fixture(); let host=host(children);
|
||||
adopt(&db,&host,&request(&db,&host,"initial",Some(A))).unwrap();
|
||||
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap();
|
||||
let resolution=host.resolve(Some(&policy.source.execution.assignment()));
|
||||
assert_eq!(resolution.projection().allowed.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),children);
|
||||
assert!(host.resolve(Some(&policy.source.delegation.assignment())).projection().allowed.is_empty());
|
||||
adopt(&db,&host,&request(&db,&host,"multi",Some(B))).unwrap();
|
||||
let policy=db.adopted_subject_policy(WORKSPACE,"subject-a").unwrap().unwrap();
|
||||
assert_eq!(policy.source.execution.namespaces[0].keys[0].as_str(),"ticket.hoge.fuga");
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,9 @@ impl SqliteWorkspaceStore {
|
||||
) -> Result<WorkspaceConfigState> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
if crate::config_adoption::canonical(&tx, workspace_id)? {
|
||||
return Err(config_conflict("configuration authority is adopted; use owner adoption with the observed editing head and operation ID"));
|
||||
}
|
||||
let workspace_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||
[workspace_id],
|
||||
@@ -490,7 +493,7 @@ fn format_candidate_sources(
|
||||
}
|
||||
|
||||
// Read persisted provenance, not load_state's upgraded in-memory contract.
|
||||
fn load_evaluation_identity(
|
||||
pub(crate) fn load_evaluation_identity(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
) -> Result<(String, String)> {
|
||||
@@ -509,7 +512,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
|
||||
.any(|provider| provider.provider_id == "builtin:plugins")
|
||||
}
|
||||
|
||||
fn evaluate_candidate_in_transaction(
|
||||
pub(crate) fn evaluate_candidate_in_transaction(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
current: WorkspaceConfigState,
|
||||
@@ -566,6 +569,21 @@ fn evaluate_candidate(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sync_adopted_config_references(
|
||||
tx: &rusqlite::Transaction<'_>, workspace: &str, state: &WorkspaceConfigState,
|
||||
) -> Result<()> {
|
||||
if has_plugin_provider(&state.contract.schema_bundle) {
|
||||
let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config(
|
||||
tx, workspace, &state.snapshot, state.contract.schema_bundle.clone(),
|
||||
)?;
|
||||
if contract != state.contract || evaluation.projection_digest != state.projection_digest {
|
||||
return Err(config_conflict("Plugin catalog/schema changed during adoption"));
|
||||
}
|
||||
crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_state(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
@@ -792,7 +810,7 @@ fn config_not_materialized() -> Error {
|
||||
Error::RegistryInconsistency("workspace config tree is not materialized".to_string())
|
||||
}
|
||||
|
||||
fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
|
||||
pub(crate) fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
|
||||
if entrypoints == [main_config_path()] {
|
||||
Ok(())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Read-only preparation of an editable Workspace permission migration draft.
|
||||
//!
|
||||
//! The authenticated owner boundary lives in Server/core. This helper checks the
|
||||
//! guarded stores and exact Workspace attribution, but neither saves the draft nor
|
||||
//! seals the legacy writer. Adoption must check `original`, the actual editing
|
||||
//! head and export identity/digest again, and reject blocking diagnostics before
|
||||
//! sealing. A successful preview alone is not permission authority.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigTreeChange, ConfigTreeSnapshot, SnapshotEnvironment,
|
||||
ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use feature_permissions::{NamespacePermissions, PermissionHost};
|
||||
use subjektiv::{PermissionTransferSnapshot, SubjektivStore};
|
||||
|
||||
use crate::config_source::{
|
||||
ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigState,
|
||||
validate_workspace_config_candidate_projections,
|
||||
};
|
||||
use crate::policy_config::{
|
||||
self, AdoptedSubjectPolicy, AssignmentSource, MigrationDiagnostic, PermissionSource,
|
||||
};
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub(crate) struct MigrationSeed {
|
||||
pub original: WorkspaceConfigState,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub snapshot: PermissionTransferSnapshot,
|
||||
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
pub diagnostics: Vec<MigrationDiagnostic>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview_migration(
|
||||
store: &SqliteWorkspaceStore,
|
||||
legacy: &SubjektivStore,
|
||||
workspace: &str,
|
||||
schema: WorkspaceConfigSchemaBundle,
|
||||
host: &PermissionHost,
|
||||
) -> Result<MigrationSeed> {
|
||||
if legacy.scope_id() != workspace || host.catalog().workspace.as_str() != workspace {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
// This API validates native physical identity under the FeatureDatabase guard.
|
||||
// Do not replace it with path inspection or SQL against the legacy database.
|
||||
let identity = legacy.database_identity().map_err(legacy_error)?;
|
||||
if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
let original = store.with_conn(|conn| {
|
||||
// with_conn also owns the managed Server catalog identity/retention guard.
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND state='active')",
|
||||
[workspace],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
crate::config_source::load_state(conn, workspace)?.ok_or_else(|| {
|
||||
Error::RegistryInconsistency("workspace config tree is not materialized".into())
|
||||
})
|
||||
})?;
|
||||
let snapshot = legacy.preview_permission_transfer(&identity).map_err(legacy_error)?;
|
||||
if snapshot.identity != identity {
|
||||
return Err(Error::WorkspaceConfigConflict("legacy export identity changed".into()));
|
||||
}
|
||||
let (policies, diagnostics) = migrate_policies(host, &snapshot)?;
|
||||
let source = PermissionSource {
|
||||
subjects: policies.values().map(|policy| policy.source.clone()).collect(),
|
||||
remove_subjects: vec![],
|
||||
};
|
||||
let changes = draft_changes(&original, &source)?;
|
||||
// Caller's real application provider bundle (including PromptSchema) is
|
||||
// required: a migration-specific substitute cannot validate other settings.
|
||||
let candidate = store.evaluate_workspace_config_candidate_with_schema(
|
||||
workspace,
|
||||
&ConfigCommitRequest {
|
||||
base_digest: original.snapshot.digest.clone(),
|
||||
changes,
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)?],
|
||||
},
|
||||
schema,
|
||||
)?;
|
||||
validate_workspace_config_candidate_projections(workspace, &candidate)?;
|
||||
let evaluated_source = policy_config::source(&candidate.evaluation)?;
|
||||
if evaluated_source != source {
|
||||
return Err(Error::InvalidInput(
|
||||
"migration import did not produce the exact generated Subject assignments".into(),
|
||||
));
|
||||
}
|
||||
let subjects: BTreeSet<_> = snapshot.sources.iter().map(|item| item.subject_id.as_str()).collect();
|
||||
policy_config::validate_source(host, &evaluated_source, |subject| {
|
||||
if subjects.contains(subject) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::SubjektivSubjectNotFound(subject.into()))
|
||||
}
|
||||
})?;
|
||||
let state = WorkspaceConfigState {
|
||||
snapshot: candidate.snapshot,
|
||||
contract: candidate.contract,
|
||||
projection_digest: candidate.evaluation.projection_digest,
|
||||
};
|
||||
Ok(MigrationSeed { original, state, snapshot, policies, diagnostics })
|
||||
}
|
||||
|
||||
fn legacy_error(error: subjektiv::SubjektivError) -> Error {
|
||||
Error::Store(format!("legacy permission migration preview: {error}"))
|
||||
}
|
||||
|
||||
fn path(value: &str) -> Result<VirtualPath> {
|
||||
VirtualPath::parse(value).map_err(|error| Error::InvalidInput(error.to_string()))
|
||||
}
|
||||
|
||||
fn migrate_policies(
|
||||
host: &PermissionHost,
|
||||
snapshot: &PermissionTransferSnapshot,
|
||||
) -> Result<(BTreeMap<String, AdoptedSubjectPolicy>, Vec<MigrationDiagnostic>)> {
|
||||
let mut policies = BTreeMap::new();
|
||||
let mut diagnostics = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
for item in &snapshot.sources {
|
||||
if !seen.insert(&item.subject_id) {
|
||||
return Err(Error::RegistryInconsistency("duplicate Subject in legacy export".into()));
|
||||
}
|
||||
// None stays unset. In particular, never enroll it in a default Set.
|
||||
let Some(legacy) = &item.permissions else { continue };
|
||||
let (policy, mut converted) = policy_config::migrate_subject(host, &item.subject_id, legacy)?;
|
||||
policies.insert(item.subject_id.clone(), policy);
|
||||
// ExplicitConfigurationRequired must survive preview, even though the
|
||||
// unsupported operation is absent from the editable assignment. Core must
|
||||
// block adoption on it; this is not a silently successful conversion.
|
||||
diagnostics.append(&mut converted);
|
||||
}
|
||||
Ok((policies, diagnostics))
|
||||
}
|
||||
|
||||
fn draft_changes(original: &WorkspaceConfigState, source: &PermissionSource) -> Result<Vec<ConfigTreeChange>> {
|
||||
// Evaluate with the existing open schema, not application defaults. This
|
||||
// distinguishes an explicit `permissions = {}` (including imports/dotted
|
||||
// fields) from an omitted field without introducing another Decodal parser.
|
||||
let open_contract = ToolchainContract::with_schema_bundle(
|
||||
original.contract.schema_version,
|
||||
original.contract.entrypoints.clone(),
|
||||
original.contract.import_policy_version,
|
||||
WorkspaceConfigSchemaBundle::empty(),
|
||||
);
|
||||
let raw = SnapshotEnvironment::new(original.snapshot.clone())
|
||||
.evaluate_contract(&open_contract)
|
||||
.map_err(|diagnostics| Error::InvalidInput(format!(
|
||||
"cannot inspect current configuration before permission migration: {diagnostics:?}"
|
||||
)))?;
|
||||
if raw.projections.iter().any(|projection| projection.data_json.get("permissions").is_some()) {
|
||||
return Err(Error::InvalidInput(
|
||||
"current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(),
|
||||
));
|
||||
}
|
||||
let generated_path = unused_source_path(&original.snapshot)?;
|
||||
let main_path = path(MAIN_CONFIG_ENTRYPOINT)?;
|
||||
let main = original.snapshot.get(&main_path).ok_or_else(|| {
|
||||
Error::RegistryInconsistency("workspace config tree has no main.dcdl".into())
|
||||
})?;
|
||||
if main.content_type != ConfigContentType::Decodal {
|
||||
return Err(Error::InvalidInput("main.dcdl is not Decodal source".into()));
|
||||
}
|
||||
// Use the existing syntax formatter to supply a final field separator when
|
||||
// needed. It preserves comments; no local lexer or alternate language rules.
|
||||
let formatted_main = SnapshotEnvironment::new(original.snapshot.clone())
|
||||
.format(&main.content)
|
||||
.map_err(|error| Error::InvalidInput(format!("cannot format migration entrypoint: {error}")))?;
|
||||
Ok(vec![
|
||||
ConfigTreeChange::Create {
|
||||
path: generated_path.clone(),
|
||||
content_type: ConfigContentType::Decodal,
|
||||
content: render_permission_source(source),
|
||||
},
|
||||
ConfigTreeChange::Update {
|
||||
path: main_path,
|
||||
expected_digest: main.content_digest.clone(),
|
||||
content: append_import(&formatted_main, &generated_path)?,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn unused_source_path(snapshot: &ConfigTreeSnapshot) -> Result<VirtualPath> {
|
||||
// With N entries, one of these N+1 names is unused. Never overwrite even an
|
||||
// unreferenced preexisting migration file.
|
||||
for suffix in 0..=snapshot.entries.len() {
|
||||
let name = if suffix == 0 {
|
||||
"permissions/migrated.dcdl".to_owned()
|
||||
} else {
|
||||
format!("permissions/migrated-{suffix}.dcdl")
|
||||
};
|
||||
let candidate = path(&name)?;
|
||||
if snapshot.get(&candidate).is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
unreachable!("N entries cannot occupy N+1 distinct migration paths")
|
||||
}
|
||||
|
||||
fn append_import(source: &str, imported: &VirtualPath) -> Result<String> {
|
||||
// Materialized main.dcdl is a literal outer object with a trailing schema
|
||||
// assertion. Limit this edit to that existing representation: no rewrites of
|
||||
// arbitrary expressions, no JSON round-trip, no replacement of other fields.
|
||||
let trimmed = source.trim_end();
|
||||
let object = trimmed.strip_suffix("as WorkspaceConfigSchema")
|
||||
.map(str::trim_end).unwrap_or(trimmed);
|
||||
if !object.starts_with('{') || !object.ends_with('}') {
|
||||
return Err(Error::InvalidInput(
|
||||
"permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(),
|
||||
));
|
||||
}
|
||||
let close = object.len() - 1;
|
||||
let import = quote(&format!("./{}", imported.as_str()));
|
||||
Ok(format!("{}\n permissions = import {import};\n{}", &source[..close], &source[close..]))
|
||||
}
|
||||
|
||||
// A deliberately specific, payload-free renderer. JSON quoting is used only for
|
||||
// string literals, never as a renderer for the configuration/assignment objects.
|
||||
fn quote(value: &str) -> String {
|
||||
serde_json::to_string(value).expect("a string serializes")
|
||||
}
|
||||
|
||||
fn render_strings<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
|
||||
format!("[{}]", values.into_iter().map(quote).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
|
||||
fn render_namespaces(values: &[NamespacePermissions]) -> String {
|
||||
let items = values.iter().map(|value| format!(
|
||||
"{{ namespace = {}; keys = {}; }}",
|
||||
quote(value.namespace.as_str()),
|
||||
render_strings(value.keys.iter().map(|key| key.as_str())),
|
||||
)).collect::<Vec<_>>();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
|
||||
fn render_assignment(source: &AssignmentSource) -> String {
|
||||
format!(
|
||||
"{{ sets = {}; namespaces = {}; exclude_sets = {}; exclude_namespaces = {}; revoked = {}; }}",
|
||||
render_strings(source.sets.iter().map(|set| set.as_str())),
|
||||
render_namespaces(&source.namespaces),
|
||||
render_strings(source.exclude_sets.iter().map(|set| set.as_str())),
|
||||
render_namespaces(&source.exclude_namespaces),
|
||||
source.revoked,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_permission_source(source: &PermissionSource) -> String {
|
||||
let subjects = source.subjects.iter().map(|item| format!(
|
||||
" {{ subject_id = {}; execution = {}; delegation = {}; }}",
|
||||
quote(&item.subject_id), render_assignment(&item.execution), render_assignment(&item.delegation),
|
||||
)).collect::<Vec<_>>().join(",\n");
|
||||
format!(
|
||||
"{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n",
|
||||
render_strings(source.remove_subjects.iter().map(String::as_str)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{ConfigEntry, EvaluationResult};
|
||||
use feature_permissions::{
|
||||
FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition,
|
||||
PermissionKey, PermissionSetId, WorkspaceId,
|
||||
};
|
||||
use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions};
|
||||
use subjektiv::{DatabaseIdentity, PermissionSourceExport};
|
||||
|
||||
const WORKSPACE: &str = "migration-workspace";
|
||||
|
||||
fn host(children: bool) -> PermissionHost {
|
||||
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
|
||||
let feature = host.register_feature(FeatureIdentity::parse("builtin:file").unwrap()).unwrap();
|
||||
let namespace = host.bind_namespace(&feature, NamespaceId::parse("file").unwrap()).unwrap();
|
||||
host.declare(&namespace, NamespaceDeclaration {
|
||||
permissions: vec![
|
||||
PermissionDefinition {
|
||||
key: PermissionKey::parse("file.read").unwrap(),
|
||||
description: String::new(),
|
||||
children: if children { vec![PermissionKey::parse("file.read.text").unwrap()] } else { vec![] },
|
||||
},
|
||||
PermissionDefinition {
|
||||
key: PermissionKey::parse("file.read.text").unwrap(),
|
||||
description: String::new(), children: vec![],
|
||||
},
|
||||
],
|
||||
sets: vec![],
|
||||
}).unwrap();
|
||||
host
|
||||
}
|
||||
|
||||
fn limited(operation: &str) -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: "one-workdir".into(), path: "only-this.txt".into(), recursive: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn export(sources: Vec<PermissionSourceExport>) -> PermissionTransferSnapshot {
|
||||
PermissionTransferSnapshot {
|
||||
identity: DatabaseIdentity {
|
||||
scope_id: WORKSPACE.into(), feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(),
|
||||
physical_id: "typed-test-identity".into(),
|
||||
},
|
||||
sources, export_digest: "not-used-by-pure-conversion-tests".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current(main: &str, extra: Vec<ConfigEntry>) -> WorkspaceConfigState {
|
||||
let mut current = crate::config_source::initial_state().unwrap();
|
||||
let mut entries = vec![ConfigEntry::new(
|
||||
path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal, main,
|
||||
).unwrap()];
|
||||
entries.extend(extra);
|
||||
current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap();
|
||||
current
|
||||
}
|
||||
|
||||
fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult {
|
||||
let contract = crate::config_source::initial_state().unwrap().contract;
|
||||
SnapshotEnvironment::new(snapshot).evaluate_contract(&contract).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() {
|
||||
let empty = SubjectPermissions { change_id: "explicit-empty".into(), execution: vec![], delegation: vec![] };
|
||||
let limited = SubjectPermissions {
|
||||
change_id: "keep-limited-change".into(), execution: vec![limited("file.read")],
|
||||
delegation: vec![limited("file.read.text")],
|
||||
};
|
||||
let snapshot = export(vec![
|
||||
PermissionSourceExport { subject_id: "unset".into(), permissions: None },
|
||||
PermissionSourceExport { subject_id: "empty".into(), permissions: Some(empty.clone()) },
|
||||
PermissionSourceExport { subject_id: "limited".into(), permissions: Some(limited.clone()) },
|
||||
]);
|
||||
let host = host(false);
|
||||
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap();
|
||||
assert!(!policies.contains_key("unset"));
|
||||
assert_eq!(policies["empty"].change_id, empty.change_id);
|
||||
assert_eq!(policies["empty"].source.execution, AssignmentSource::default());
|
||||
assert_eq!(policies["empty"].source.delegation, AssignmentSource::default());
|
||||
assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited);
|
||||
assert_eq!(diagnostics.len(), 2);
|
||||
assert!(diagnostics.iter().all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() {
|
||||
for (host, permission) in [(host(false), limited("file.unknown")), (host(true), limited("file.read"))] {
|
||||
let legacy = SubjectPermissions {
|
||||
change_id: "keep-change".into(), execution: vec![permission.clone()], delegation: vec![],
|
||||
};
|
||||
let snapshot = export(vec![PermissionSourceExport { subject_id: "blocked".into(), permissions: Some(legacy.clone()) }]);
|
||||
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap();
|
||||
assert_eq!(diagnostics, vec![MigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id: "blocked".into(), permission,
|
||||
}]);
|
||||
assert_eq!(policies["blocked"].legacy_constraints, Some(legacy));
|
||||
assert!(policies["blocked"].source.execution.namespaces.is_empty());
|
||||
assert!(policies["blocked"].source.execution.sets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_round_trips_sets_namespaces_exclusions_revocation_and_quoted_strings() {
|
||||
let assignment = AssignmentSource {
|
||||
sets: vec![PermissionSetId::parse("shared").unwrap()],
|
||||
namespaces: vec![NamespacePermissions {
|
||||
namespace: NamespaceId::parse("file").unwrap(),
|
||||
keys: vec![PermissionKey::parse("file.read.text").unwrap()],
|
||||
}],
|
||||
exclude_sets: vec![PermissionSetId::parse("excluded").unwrap()],
|
||||
exclude_namespaces: vec![NamespacePermissions {
|
||||
namespace: NamespaceId::parse("file").unwrap(),
|
||||
keys: vec![PermissionKey::parse("file.read").unwrap()],
|
||||
}],
|
||||
revoked: true,
|
||||
};
|
||||
let source = PermissionSource {
|
||||
subjects: vec![policy_config::SubjectAssignmentSource {
|
||||
subject_id: "subject-\"quoted\\日本語".into(), execution: assignment,
|
||||
delegation: AssignmentSource::default(),
|
||||
}],
|
||||
remove_subjects: vec!["removed".into()],
|
||||
};
|
||||
let rendered = render_permission_source(&source);
|
||||
assert!(rendered.contains("sets = [\"shared\"]"));
|
||||
assert!(rendered.contains("keys = [\"file.read.text\"]"));
|
||||
assert!(!rendered.contains("legacy_constraints"));
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([
|
||||
ConfigEntry::new(path(MAIN_CONFIG_ENTRYPOINT).unwrap(), ConfigContentType::Decodal,
|
||||
format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema")).unwrap(),
|
||||
]).unwrap();
|
||||
assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_preserves_main_comments_other_settings_and_existing_migration_files() {
|
||||
let main = "{\n // permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n";
|
||||
let occupied = ConfigEntry::new(path("permissions/migrated.dcdl").unwrap(), ConfigContentType::Decodal, "{ keep = true; }\n").unwrap();
|
||||
let original = current(main, vec![occupied.clone()]);
|
||||
let source = PermissionSource::default();
|
||||
let changes = draft_changes(&original, &source).unwrap();
|
||||
let draft = original.snapshot.apply(&changes).unwrap();
|
||||
assert_eq!(draft.get(&occupied.path), Some(&occupied));
|
||||
assert!(draft.get(&path("permissions/migrated-1.dcdl").unwrap()).is_some());
|
||||
let updated_main = &draft.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content;
|
||||
assert!(updated_main.contains("// permissions in a comment are not a field"));
|
||||
assert!(updated_main.contains("brace } and permissions"));
|
||||
assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";"));
|
||||
assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n"));
|
||||
let before = evaluate(original.snapshot.clone());
|
||||
let after = evaluate(draft);
|
||||
assert_eq!(after.projections[0].data_json["other"], before.projections[0].data_json["other"]);
|
||||
assert_eq!(policy_config::source(&after).unwrap(), source);
|
||||
assert_eq!(original.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap()).unwrap().content, main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_explicit_permissions_are_not_merged_or_discarded_even_when_empty() {
|
||||
for main in [
|
||||
"{ permissions = {}; } as WorkspaceConfigSchema",
|
||||
"{ permissions.subjects = []; } as WorkspaceConfigSchema",
|
||||
"{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema",
|
||||
] {
|
||||
let existing = ConfigEntry::new(path("permissions/existing.dcdl").unwrap(), ConfigContentType::Decodal, "{ subjects = []; }").unwrap();
|
||||
let original = current(main, vec![existing]);
|
||||
let before = original.clone();
|
||||
let error = draft_changes(&original, &PermissionSource::default()).unwrap_err();
|
||||
assert!(error.to_string().contains("explicitly defines permissions"), "{error}");
|
||||
assert_eq!(original, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ pub mod authority;
|
||||
pub mod backend_job;
|
||||
pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_adoption;
|
||||
mod configuration_migration;
|
||||
pub mod policy_config;
|
||||
pub mod config_source;
|
||||
#[cfg(test)]
|
||||
mod content_history_tests;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Subject assignments in the existing Workspace virtual configuration.
|
||||
//!
|
||||
//! An omitted Subject is unchanged; removal is explicit. Assignments contain only
|
||||
//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions
|
||||
//! remain separate domain constraints, never payloads on permission keys.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{ConfigSchemaContribution, EvaluationResult};
|
||||
use feature_permissions::{
|
||||
NamespaceId, NamespacePermissions, PermissionAssignment, PermissionDiagnostic, PermissionHost,
|
||||
PermissionKey, PermissionReference, PermissionSetId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::authorization::{ScopedPermission, SubjectPermissions};
|
||||
|
||||
use crate::config_source::WorkspaceConfigSchemaProvider;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
permissions = {
|
||||
subjects = [...{
|
||||
subject_id = String;
|
||||
execution = {
|
||||
sets = [...String] default [];
|
||||
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
exclude_sets = [...String] default [];
|
||||
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
revoked = Bool default false;
|
||||
} default {};
|
||||
delegation = {
|
||||
sets = [...String] default [];
|
||||
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
exclude_sets = [...String] default [];
|
||||
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
revoked = Bool default false;
|
||||
} default {};
|
||||
}] default [];
|
||||
remove_subjects = [...String] default [];
|
||||
};
|
||||
}"#;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PermissionConfigSchemaProvider;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for PermissionConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new("builtin:permissions", "permissions", "1", SCHEMA)
|
||||
.map_err(|e| Error::Config(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AssignmentSource {
|
||||
#[serde(default)]
|
||||
pub sets: Vec<PermissionSetId>,
|
||||
#[serde(default)]
|
||||
pub namespaces: Vec<NamespacePermissions>,
|
||||
#[serde(default)]
|
||||
pub exclude_sets: Vec<PermissionSetId>,
|
||||
#[serde(default)]
|
||||
pub exclude_namespaces: Vec<NamespacePermissions>,
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
impl AssignmentSource {
|
||||
pub fn assignment(&self) -> PermissionAssignment {
|
||||
let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| {
|
||||
sets.iter().cloned().map(PermissionReference::Set)
|
||||
.chain(namespaces.iter().cloned().map(PermissionReference::Namespace)).collect()
|
||||
};
|
||||
PermissionAssignment {
|
||||
grants: references(&self.sets, &self.namespaces),
|
||||
exclusions: references(&self.exclude_sets, &self.exclude_namespaces),
|
||||
revoked: self.revoked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectAssignmentSource {
|
||||
pub subject_id: String,
|
||||
#[serde(default)]
|
||||
pub execution: AssignmentSource,
|
||||
#[serde(default)]
|
||||
pub delegation: AssignmentSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionSource {
|
||||
#[serde(default)]
|
||||
pub subjects: Vec<SubjectAssignmentSource>,
|
||||
#[serde(default)]
|
||||
pub remove_subjects: Vec<String>,
|
||||
}
|
||||
|
||||
/// Retained exact legacy restrictions belong to the domain, not the editable source.
|
||||
/// Keeping the original change identity preserves the distinction between live and
|
||||
/// already stale child grants during representation-only migration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdoptedSubjectPolicy {
|
||||
pub source: SubjectAssignmentSource,
|
||||
pub change_id: String,
|
||||
pub legacy_constraints: Option<SubjectPermissions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
|
||||
pub enum MigrationDiagnostic {
|
||||
Converted { subject_id: String, operation: String },
|
||||
DomainConstraint { subject_id: String, permission: ScopedPermission },
|
||||
ExplicitConfigurationRequired { subject_id: String, permission: ScopedPermission },
|
||||
}
|
||||
|
||||
pub fn source(evaluation: &EvaluationResult) -> Result<PermissionSource> {
|
||||
let value = evaluation.projections.first()
|
||||
.and_then(|p| p.data_json.get("permissions"))
|
||||
.cloned().unwrap_or_else(|| serde_json::json!({}));
|
||||
serde_json::from_value(value).map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}")))
|
||||
}
|
||||
|
||||
fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> {
|
||||
// Validate even revoked assignments. A revocation must not hide a misspelled
|
||||
// reference which would become active on a later edit.
|
||||
let mut assignment = source.assignment();
|
||||
assignment.revoked = false;
|
||||
let resolution = host.resolve(Some(&assignment));
|
||||
if resolution.projection().diagnostics.iter().any(|d| matches!(d,
|
||||
PermissionDiagnostic::UnknownKey(_) | PermissionDiagnostic::UnknownNamespace(_)
|
||||
| PermissionDiagnostic::UnknownSet(_))) {
|
||||
return Err(Error::InvalidInput(format!("invalid permission references: {:?}", resolution.projection().diagnostics)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_source(
|
||||
host: &PermissionHost,
|
||||
source: &PermissionSource,
|
||||
mut require_subject: impl FnMut(&str) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let mut subjects = BTreeSet::new();
|
||||
for item in &source.subjects {
|
||||
server_api::authorization::validate_authorization_identifier(&item.subject_id)
|
||||
.map_err(Error::InvalidInput)?;
|
||||
if !subjects.insert(&item.subject_id) {
|
||||
return Err(Error::InvalidInput("duplicate Subject assignment".into()));
|
||||
}
|
||||
require_subject(&item.subject_id)?;
|
||||
validate_assignment(host, &item.execution)?;
|
||||
validate_assignment(host, &item.delegation)?;
|
||||
}
|
||||
for subject in &source.remove_subjects {
|
||||
server_api::authorization::validate_authorization_identifier(subject)
|
||||
.map_err(Error::InvalidInput)?;
|
||||
if !subjects.insert(subject) {
|
||||
return Err(Error::InvalidInput("Subject both assigned and removed, or removed twice".into()));
|
||||
}
|
||||
require_subject(subject)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A patch deliberately does not replace the entire policy map. In particular a
|
||||
/// schema default empty list never deletes omitted policies or reinstates defaults.
|
||||
pub fn apply(
|
||||
policies: &mut BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
source: &PermissionSource,
|
||||
change_id: &str,
|
||||
) {
|
||||
for subject in &source.remove_subjects { policies.remove(subject); }
|
||||
for item in &source.subjects {
|
||||
match policies.get_mut(&item.subject_id) {
|
||||
Some(previous) if previous.source == *item => {},
|
||||
Some(previous) => {
|
||||
previous.source = item.clone();
|
||||
previous.change_id = change_id.to_owned();
|
||||
},
|
||||
None => {
|
||||
policies.insert(item.subject_id.clone(), AdoptedSubjectPolicy {
|
||||
source: item.clone(), change_id: change_id.to_owned(), legacy_constraints: None,
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert exact legacy operations without expanding explicit children. Restrictions
|
||||
/// which cannot be represented by the owning domain must be diagnosed before seal.
|
||||
/// This routine does not issue a grant or install a policy.
|
||||
pub fn migrate_subject(
|
||||
host: &PermissionHost,
|
||||
subject_id: &str,
|
||||
legacy: &SubjectPermissions,
|
||||
) -> Result<(AdoptedSubjectPolicy, Vec<MigrationDiagnostic>)> {
|
||||
let mut diagnostics = vec![];
|
||||
let mut lane = |permissions: &[ScopedPermission]| -> Result<AssignmentSource> {
|
||||
let mut by_namespace: BTreeMap<NamespaceId, BTreeSet<PermissionKey>> = BTreeMap::new();
|
||||
for permission in permissions {
|
||||
let operation = server_api::authorization::canonical_worker_operation(&permission.operation).unwrap_or(&permission.operation);
|
||||
let key = PermissionKey::parse(operation.to_owned()).map_err(Error::InvalidInput)?;
|
||||
let assignment = PermissionAssignment { grants: vec![PermissionReference::Namespace(NamespacePermissions {
|
||||
namespace: key.namespace(), keys: vec![key.clone()],
|
||||
})], exclusions: vec![], revoked: false };
|
||||
let resolution = host.resolve(Some(&assignment));
|
||||
let exact = resolution.projection().diagnostics.is_empty()
|
||||
&& resolution.projection().allowed.len() == 1
|
||||
&& resolution.projection().allowed.contains_key(&key);
|
||||
if !exact {
|
||||
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id: subject_id.into(), permission: permission.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
by_namespace.entry(key.namespace()).or_default().insert(key);
|
||||
diagnostics.push(MigrationDiagnostic::DomainConstraint {
|
||||
subject_id: subject_id.into(), permission: permission.clone(),
|
||||
});
|
||||
}
|
||||
Ok(AssignmentSource { namespaces: by_namespace.into_iter().map(|(namespace, keys)| NamespacePermissions {
|
||||
namespace, keys: keys.into_iter().collect(),
|
||||
}).collect(), ..Default::default() })
|
||||
};
|
||||
let execution = lane(&legacy.execution)?;
|
||||
let delegation = lane(&legacy.delegation)?;
|
||||
Ok((AdoptedSubjectPolicy {
|
||||
source: SubjectAssignmentSource { subject_id: subject_id.into(), execution, delegation },
|
||||
change_id: legacy.change_id.clone(), legacy_constraints: Some(legacy.clone()),
|
||||
}, diagnostics))
|
||||
}
|
||||
|
||||
impl AdoptedSubjectPolicy {
|
||||
/// Read bridge for the old scoped-operation consumer. New payload-free
|
||||
/// consumers resolve `source` through the current Feature Host and also enforce
|
||||
/// retained domain constraints. This bridge cannot turn a key into an all-target
|
||||
/// legacy resource: an operation without a representable constraint is unavailable.
|
||||
pub fn legacy_read(&self, host: &PermissionHost) -> Result<SubjectPermissions> {
|
||||
let execution = host.resolve(Some(&self.source.execution.assignment()));
|
||||
let delegation = host.resolve(Some(&self.source.delegation.assignment()));
|
||||
let filter = |allowed: &feature_permissions::PermissionResolution, constraints: &[ScopedPermission]| -> Result<Vec<ScopedPermission>> {
|
||||
let mut represented = BTreeSet::new();
|
||||
let mut output = vec![];
|
||||
for permission in constraints {
|
||||
let operation = server_api::authorization::canonical_worker_operation(&permission.operation)
|
||||
.unwrap_or(&permission.operation);
|
||||
if let Ok(key) = PermissionKey::parse(operation.to_owned()) {
|
||||
if allowed.allowed.contains_key(&key) {
|
||||
represented.insert(key);
|
||||
output.push(permission.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if allowed.allowed.keys().any(|key| !represented.contains(key)) {
|
||||
return Err(Error::InvalidInput("current key policy requires the payload-free operation consumer; no legacy all-target fallback is available".into()));
|
||||
}
|
||||
Ok(output)
|
||||
};
|
||||
let empty = SubjectPermissions { change_id:self.change_id.clone(),execution:vec![],delegation:vec![] };
|
||||
let retained = self.legacy_constraints.as_ref().unwrap_or(&empty);
|
||||
Ok(SubjectPermissions {
|
||||
change_id:self.change_id.clone(),
|
||||
execution:filter(execution.projection(),&retained.execution)?,
|
||||
delegation:filter(delegation.projection(),&retained.delegation)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,8 +110,10 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) {
|
||||
verify_history(conn, current).unwrap();
|
||||
if current >= 114 {
|
||||
let plan = published_plan(conn, current).unwrap().unwrap();
|
||||
assert!(plan.migrations.is_empty());
|
||||
assert_eq!(plan.target_schema_version, current);
|
||||
assert_eq!(plan.migrations.len(), 1);
|
||||
assert_eq!(plan.migrations[0].version, 122);
|
||||
assert_eq!(plan.migrations[0].name, crate::config_adoption::MIGRATION_NAME);
|
||||
assert_eq!(plan.target_schema_version, 122);
|
||||
}
|
||||
assert_eq!(rows, saved_rows(conn));
|
||||
assert_eq!(shape, snapshot(conn).unwrap());
|
||||
@@ -195,11 +197,16 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
// Production store owner, not a separate test migration implementation.
|
||||
super::super::prepare_connection(&conn).unwrap();
|
||||
assert_eq!(
|
||||
super::super::current_schema_version(&conn).unwrap(),
|
||||
current
|
||||
);
|
||||
assert_eq!(saved_rows(&conn), expected);
|
||||
assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122);
|
||||
for (table, rows) in &expected {
|
||||
if table != "__yoi_schema_migrations" {
|
||||
assert_eq!(saved_rows(&conn).into_iter().find(|(name, _)| name == table).unwrap().1, *rows, "{table}, from {current}");
|
||||
}
|
||||
}
|
||||
let mut history = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
assert_eq!(history.pop(), Some((122, crate::config_adoption::MIGRATION_NAME.into())));
|
||||
assert_eq!(history, super::super::workspace_schema_migration_history(&source).unwrap());
|
||||
verify_history(&conn, 122).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,7 +297,7 @@ fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabe
|
||||
let (_temp, conn) = retained_to(103, alternate);
|
||||
let original = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
super::super::prepare_connection(&conn).unwrap();
|
||||
verify_history(&conn, 113).unwrap();
|
||||
verify_history(&conn, super::super::LATEST_SCHEMA_VERSION).unwrap();
|
||||
let updated = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
assert_eq!(&updated[..original.len()], &original);
|
||||
let name: String = conn
|
||||
|
||||
@@ -71,6 +71,26 @@ fn invalid(message: impl std::fmt::Display) -> Error {
|
||||
/// Every other row, gap, mixed family, unknown suffix and missing object fails.
|
||||
pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> {
|
||||
let rows = super::workspace_schema_migration_history(conn)?;
|
||||
verify_history_records(conn, current, &rows)
|
||||
}
|
||||
|
||||
fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> {
|
||||
if current == 122 {
|
||||
verify_configuration_shape(conn)?;
|
||||
if rows == [(122, super::SCHEMA_BASELINE_NAME.to_owned())] {
|
||||
require_shape(conn, Stage::Base)?;
|
||||
return verify_103_features(conn, current, rows);
|
||||
}
|
||||
if rows.last().map(|(version, name)| (*version, name.as_str()))
|
||||
!= Some((122, crate::config_adoption::MIGRATION_NAME))
|
||||
{
|
||||
return Err(invalid("unrecognized configuration adoption receipt"));
|
||||
}
|
||||
let prefix = &rows[..rows.len() - 1];
|
||||
let prior = prefix.last().map(|row| row.0).ok_or_else(|| invalid("missing configuration predecessor"))?;
|
||||
if !(113..=121).contains(&prior) { return Err(invalid("invalid configuration predecessor")); }
|
||||
return verify_history_records(conn, prior, prefix);
|
||||
}
|
||||
let Some((baseline, name)) = rows.first() else {
|
||||
return Err(invalid("missing migration receipts"));
|
||||
};
|
||||
@@ -191,11 +211,28 @@ pub(super) fn published_plan(
|
||||
verify_history(conn, current)?;
|
||||
Ok(Some(super::WorkspaceSchemaMigrationPlan {
|
||||
current_schema_version: current,
|
||||
target_schema_version: current,
|
||||
migrations: vec![],
|
||||
target_schema_version: 122,
|
||||
migrations: if current < 122 {
|
||||
vec![super::WorkspaceSchemaMigrationStep {
|
||||
version: 122,
|
||||
name: crate::config_adoption::MIGRATION_NAME.to_owned(),
|
||||
}]
|
||||
} else { vec![] },
|
||||
}))
|
||||
}
|
||||
|
||||
fn verify_configuration_shape(conn: &Connection) -> Result<()> {
|
||||
let expected_db = Connection::open_in_memory()?;
|
||||
expected_db.execute_batch(content_history_store::SCHEMA)?;
|
||||
expected_db.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
let expected = snapshot(&expected_db)?;
|
||||
let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect();
|
||||
let actual: Shape = snapshot(conn)?.into_iter()
|
||||
.filter(|(_, object)| tables.contains(&object.1)).collect();
|
||||
if actual != expected { return Err(invalid("configuration adoption receipt/shape mismatch")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// sqlite_schema programs, not table/column presence alone. SQL literal bytes,
|
||||
// CHECK/FK clauses, indexes and trigger programs remain part of the contract.
|
||||
type Shape = BTreeMap<String, (String, String, Vec<String>)>;
|
||||
@@ -233,7 +270,7 @@ fn reference_base() -> Result<Connection> {
|
||||
merge_request::migrate(&conn).map_err(invalid)?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
super::create_latest_workspace_schema(&tx)?;
|
||||
super::create_workspace_schema_through_v113(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
#[path = "storage_formats.rs"]
|
||||
mod storage_formats;
|
||||
|
||||
const LATEST_SCHEMA_VERSION: i64 = 113;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 122;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Ticket work-decomposition parent links",
|
||||
apply: migrate_ticket_parents_v112_to_v113,
|
||||
},
|
||||
Migration {
|
||||
version: 122,
|
||||
name: crate::config_adoption::MIGRATION_NAME,
|
||||
apply: migrate_configuration_adoption_to_v122,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -16069,6 +16074,20 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if !(113..=121).contains(¤t) {
|
||||
return Err(Error::Store("Configuration adoption migration requires a recognized schema 113..121".into()));
|
||||
}
|
||||
storage_formats::verify_history(conn, current)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(content_history_store::SCHEMA)?;
|
||||
tx.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)", [crate::config_adoption::MIGRATION_NAME])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 112 {
|
||||
return Err(Error::Store(
|
||||
@@ -16165,6 +16184,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_through_v113(conn)?;
|
||||
conn.execute_batch(content_history_store::SCHEMA)?;
|
||||
conn.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace_schema_through_v113(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
assignment_delivery::install_post_inputs(conn)?;
|
||||
@@ -17083,7 +17109,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result<Vec<(i64, Str
|
||||
}
|
||||
|
||||
fn verify_schema_history(conn: &Connection, current: i64) -> Result<()> {
|
||||
if current > LATEST_SCHEMA_VERSION {
|
||||
if current > 113 {
|
||||
return storage_formats::verify_history(conn, current);
|
||||
}
|
||||
let rows = workspace_schema_migration_history(conn)?;
|
||||
@@ -18878,9 +18904,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn current_schema_accepts_every_retained_canonical_provenance() {
|
||||
for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION {
|
||||
for baseline_version in (OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION)) {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
merge_request::migrate(&conn).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap();
|
||||
create_latest_workspace_schema(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)",
|
||||
params![baseline_version, SCHEMA_BASELINE_NAME],
|
||||
|
||||
Reference in New Issue
Block a user