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:
2026-11-04 15:54:35 +09:00
parent c63a750943
commit f814e808ec
27 changed files with 2484 additions and 41 deletions
Generated
+1
View File
@@ -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"))
}
+1
View File
@@ -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::*;
+8
View File
@@ -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"),
+10 -1
View File
@@ -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 {
+30 -9
View File
@@ -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(&registered), Err(FeatureStorageError::MigrationHistory { .. })));
scope.shutdown().unwrap();
}
+65 -4
View File
@@ -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,
+34 -7
View File
@@ -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());
}
}
+4
View File
@@ -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;
+4 -3
View File
@@ -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(&current.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 &current_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 { &current };
if editing_base.snapshot.digest != request.source.base_digest { return Err(conflict("source content changed")); }
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
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");
}
}
+21 -3
View File
@@ -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);
}
}
}
+3
View File
@@ -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
+40 -3
View File
@@ -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)
}
+33 -3
View File
@@ -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(&current) {
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],