chore: retain pinned configuration and Session prerequisites
Preserve the already-applied dependency tree from T838 source 9f8aad79a3, including its T840 prerequisites. This is dependency attribution for the incomplete T841 checkpoint, not adoption of later dependency changes, inherited approval, integration, or a target-branch update.
This commit is contained in:
Generated
+1
@@ -7579,6 +7579,7 @@ dependencies = [
|
||||
"content-history",
|
||||
"content-history-adapters",
|
||||
"content-history-store",
|
||||
"feature-permissions",
|
||||
"feature-storage",
|
||||
"flate2",
|
||||
"flow",
|
||||
|
||||
@@ -3,6 +3,48 @@
|
||||
//! Causes contain only fixed enum values. Stored or transferred attachments are
|
||||
//! observations, never permission to disclose: Browser/Tool projections require
|
||||
//! the caller's current visibility decision and fail closed without it.
|
||||
//!
|
||||
//! # Projection boundary
|
||||
//!
|
||||
//! The same closed cause is mapped explicitly into the shared error-contract codec.
|
||||
//! The transport boundary does not convert an internal Session denial into an HTTP
|
||||
//! verdict about a Browser operator. Public consumers must resolve current viewer
|
||||
//! authority before projecting; a saved attachment cannot carry that authority.
|
||||
//!
|
||||
//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool |
|
||||
//! | --- | --- | --- |
|
||||
//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose |
|
||||
//! | Explanation | Fixed local template | Fixed local template, not received text |
|
||||
//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references |
|
||||
//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause |
|
||||
//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority |
|
||||
//!
|
||||
//! A denied current visibility decision fails projection instead of substituting
|
||||
//! an invented cause. Unknown codec versions and corrupt attachments stay distinct
|
||||
//! from known causes. Missing legacy attachments do not certify legacy diagnostics.
|
||||
//!
|
||||
//! # Failure investigation
|
||||
//!
|
||||
//! A known denial identifies the operation and principal class, not an instruction
|
||||
//! to grant rights. Check the current Subject execution policy and the authenticated
|
||||
//! Runtime/Worker binding at the Session-record authorization boundary. Compare
|
||||
//! existing Session attribution and saved Worker metadata before deciding whether
|
||||
//! any state repair is needed. Operator authority and Subject execution policy are
|
||||
//! different decisions; neither can be derived from an internal HTTP status alone.
|
||||
//!
|
||||
//! Transport and unknown outcomes do not establish non-execution. Reconcile the
|
||||
//! existing Session attribution, Worker aggregate and Runtime operation result;
|
||||
//! a lost response may coexist with a committed write. A conflict invalidates the
|
||||
//! old metadata expectation. Do not retry the startup, create another Worker, or
|
||||
//! enlarge permissions solely because a failure was displayed. Inspect primary,
|
||||
//! secondary, persistence and resource facts independently: rollback failure must
|
||||
//! not replace the refusal, and persistence uncertainty must not be called saved.
|
||||
//!
|
||||
//! The real authorization/factory fixture is owned by workspace-server's normal
|
||||
//! `session_registration_connected` tests. Its signed Session callback revokes the
|
||||
//! real policy at a synchronization point; a real retention pin forces independent
|
||||
//! cleanup failure. Leaf transport/projection tests do not substitute for that
|
||||
//! product connection, and the fixture claims neither a process crash nor restore.
|
||||
|
||||
use error_contract::{
|
||||
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
|
||||
|
||||
@@ -105,10 +105,25 @@ pub struct RuntimeApiError {
|
||||
status: u16,
|
||||
}
|
||||
|
||||
/// Transient, allowlisted startup observations copied from the execution owner.
|
||||
/// This is not an operation result, lifecycle record, or permission to retry.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerStartupFailure {
|
||||
pub primary: StartupFailureAttachment,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub secondary: Vec<StartupSecondaryFailure>,
|
||||
pub resources: Option<StartupResourceFacts>,
|
||||
pub persistence: Option<StartupPersistenceOutcome>,
|
||||
pub io_kind: Option<StartupIoKind>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct RuntimeApiErrorDetail {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub startup: Option<WorkerStartupFailure>,
|
||||
}
|
||||
|
||||
impl RuntimeApiError {
|
||||
@@ -117,6 +132,7 @@ impl RuntimeApiError {
|
||||
error: RuntimeApiErrorDetail {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
startup: None,
|
||||
},
|
||||
status,
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API
|
||||
|
||||
- [`ServerApi`] — HTTP operations and transport metadata
|
||||
- [`ServerRequestContext`] — Request context passed to Server authentication adapters
|
||||
- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation
|
||||
- [`RequestActor`] — Human identity and authentication method
|
||||
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#[cfg(feature = "typescript")]
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(
|
||||
server_api::configuration_adoption::configuration_typescript()
|
||||
)
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "typescript"))]
|
||||
fn main() {
|
||||
eprintln!("generate_configuration_adoption_types requires --features typescript");
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Workspace-owner source adoption transport. The Server authenticates the owner
|
||||
//! before reading source, previewing, sealing migration data, adopting or reconciling.
|
||||
//! A result lookup is not a new adoption and absence is not proof of failure.
|
||||
use crate::{
|
||||
ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse,
|
||||
};
|
||||
use feature_permissions::{PermissionAssignment, PermissionCatalog};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionRequest {
|
||||
pub operation_id: String,
|
||||
pub expected_head: Option<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")
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,10 @@ pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||
pub mod authorization;
|
||||
mod chat;
|
||||
pub mod configuration_adoption;
|
||||
pub mod feature_permissions;
|
||||
pub use chat::*;
|
||||
pub use configuration_adoption::*;
|
||||
mod subject_body;
|
||||
pub use subject_body::*;
|
||||
mod chat_transport;
|
||||
|
||||
@@ -319,6 +319,11 @@ impl SubjektivStore {
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1
|
||||
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?;
|
||||
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
|
||||
tx.execute("INSERT OR IGNORE INTO subject_deleted_workers(subject_id,runtime_id,worker_id)
|
||||
SELECT subject_id,runtime_id,worker_id FROM canonical_worker_execution_grants WHERE subject_id=?1
|
||||
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?;
|
||||
}
|
||||
tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id)
|
||||
SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1
|
||||
UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1
|
||||
@@ -401,6 +406,12 @@ impl SubjektivStore {
|
||||
[subject],
|
||||
)?;
|
||||
}
|
||||
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
|
||||
tx.execute(
|
||||
"DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1",
|
||||
[subject],
|
||||
)?;
|
||||
}
|
||||
for table in DIRECT {
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE subject_id=?1"),
|
||||
|
||||
@@ -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,10 @@ impl SubjektivStore {
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self { database })
|
||||
Ok(Self {
|
||||
database,
|
||||
canonical_permission_source: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scope_id(&self) -> &str {
|
||||
|
||||
@@ -12,6 +12,12 @@ use server_api::authorization::{
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) mod transfer;
|
||||
pub use transfer::{
|
||||
CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport,
|
||||
PermissionTransferReceipt, PermissionTransferSnapshot,
|
||||
};
|
||||
|
||||
pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000;
|
||||
|
||||
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
@@ -23,9 +29,9 @@ impl SubjektivStore {
|
||||
/// Unset sources are `None` (deny), including migrated Subjects. This is an admin read,
|
||||
/// not an effective-authorization decision; retired Subjects retain their source evidence.
|
||||
pub fn subject_permissions(&self, subject_id: &str) -> Result<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,12 +232,13 @@ fn grant_in_connection(
|
||||
// Absence must never become non-Subject fallback authority after purge.
|
||||
deletion::require_live_worker(connection, runtime, worker)?;
|
||||
|
||||
let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
|
||||
"SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) g WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=g.subject_id)"
|
||||
} else {
|
||||
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)"
|
||||
};
|
||||
let raw: Option<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)",
|
||||
params![runtime, worker],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.query_row(sql, params![runtime, worker], |row| row.get(0))
|
||||
.optional()?;
|
||||
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
|
||||
.transpose()
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
//! One-way preparation of a permission-domain transfer. No Server adoption or dual commit.
|
||||
use super::*;
|
||||
use rusqlite::TransactionBehavior;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Host connection to the canonical permission domain, not an ACL or grant issuer.
|
||||
/// Implementations must resolve the exact Workspace/Subject pair and propagate failures.
|
||||
/// Calls are synchronous under the FeatureDatabase guard; do not reenter this store.
|
||||
pub trait CanonicalPermissionSource: Send + Sync {
|
||||
fn subject_permissions(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
subject_id: &str,
|
||||
) -> Result<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,
|
||||
}
|
||||
|
||||
/// Short-lived Feature authority fence for a synchronous Host operation.
|
||||
///
|
||||
/// The IMMEDIATE transaction is acquired before the Host enters its Server transaction,
|
||||
/// serializing a legacy logical-source check through Server commit against cutover even
|
||||
/// across processes. Sealing commits only the Feature transaction, not the Server operation.
|
||||
/// The outer native connection/lifecycle guard remains held until the action returns;
|
||||
/// callbacks must not reenter this store, including through a canonical source provider.
|
||||
/// No raw connection or physical storage path is exposed.
|
||||
pub struct PermissionAuthorityGuard<'connection> {
|
||||
transaction: RefCell<Option<Transaction<'connection>>>,
|
||||
expected: DatabaseIdentity,
|
||||
}
|
||||
|
||||
impl PermissionAuthorityGuard<'_> {
|
||||
/// Gate a legacy logical-source writer before entering its Server transaction.
|
||||
/// The physical write lock remains held through the enclosing action's return.
|
||||
pub fn require_unsealed(&self) -> Result<()> {
|
||||
let transaction = self.transaction.borrow();
|
||||
let tx = transaction
|
||||
.as_ref()
|
||||
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
|
||||
require_unsealed(tx)
|
||||
}
|
||||
|
||||
/// Validate Subject metadata and deletion fences only, without canonical-provider calls.
|
||||
/// All such validation must precede sealing; this guard is unusable after a seal attempt.
|
||||
pub fn require_subject(&self, subject: &str) -> Result<()> {
|
||||
validate_authorization_identifier(subject).map_err(SubjektivError::InvalidRecord)?;
|
||||
let transaction = self.transaction.borrow();
|
||||
let tx = transaction
|
||||
.as_ref()
|
||||
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
|
||||
require_subject_in_connection(tx, subject)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the exact Feature seal independently of the enclosing action's result.
|
||||
/// Exact receipt retries use the same identity, fingerprint and export checks, and
|
||||
/// commit their read-only transaction too. Failure drops the transaction to roll back;
|
||||
/// success releases the SQLite write lock but not the outer native connection guard.
|
||||
pub fn seal_permission_sources(
|
||||
&self,
|
||||
transfer_id: &str,
|
||||
expected_export_digest: &str,
|
||||
) -> Result<PermissionTransferReceipt> {
|
||||
let tx = self
|
||||
.transaction
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
|
||||
let receipt =
|
||||
seal_in_transaction(&tx, &self.expected, transfer_id, expected_export_digest)?;
|
||||
tx.commit()?;
|
||||
Ok(receipt)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
/// Hold the native Feature connection/lifecycle fence across a synchronous Host action.
|
||||
/// An IMMEDIATE Feature transaction is entered before invoking the action (and thus before
|
||||
/// any nested Server transaction). Without a seal it rolls back on every return path.
|
||||
/// A successful seal survives even an action error: this is not a distributed transaction.
|
||||
/// Callbacks must use this guard for validation and must not reenter Feature storage.
|
||||
/// Hosts can retain Server error/status types by returning `Ok(server_result)` as `T`.
|
||||
pub fn with_permission_authority_guard<T>(
|
||||
&self,
|
||||
expected: &DatabaseIdentity,
|
||||
action: impl FnOnce(&PermissionAuthorityGuard<'_>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
if self.database.identity() != &expected.storage_identity() {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
// try_with_connection verifies the native physical identity before/after the action
|
||||
// and retains its connection mutex and lifecycle lease even after the seal commits.
|
||||
self.database.try_with_connection(|connection| {
|
||||
let tx = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
|
||||
require_store_scope(&tx, self.scope_id())?;
|
||||
let guard = PermissionAuthorityGuard {
|
||||
transaction: RefCell::new(Some(tx)),
|
||||
expected: expected.clone(),
|
||||
};
|
||||
action(&guard)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_canonical_permission_source(
|
||||
mut self,
|
||||
source: Arc<dyn CanonicalPermissionSource>,
|
||||
) -> Self {
|
||||
self.canonical_permission_source = Some(source);
|
||||
self
|
||||
}
|
||||
|
||||
/// 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())
|
||||
})
|
||||
}
|
||||
|
||||
/// Observe saved transfer evidence without exporting, sealing or changing policy.
|
||||
pub fn permission_transfer_receipt(
|
||||
&self,
|
||||
expected: &DatabaseIdentity,
|
||||
) -> Result<Option<PermissionTransferReceipt>> {
|
||||
self.permission_transfer_transaction(expected, |tx| {
|
||||
let receipt = retained_receipt(tx)?;
|
||||
if let Some(receipt) = &receipt {
|
||||
require_receipt_identity(receipt, expected)?;
|
||||
} else {
|
||||
require_unsealed(tx)?;
|
||||
}
|
||||
Ok(receipt)
|
||||
})
|
||||
}
|
||||
|
||||
/// Permanently seals the old source writer after preview/CAS. All changes, the exact export
|
||||
/// 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> {
|
||||
self.permission_transfer_transaction(expected, |tx| {
|
||||
seal_in_transaction(tx, expected, transfer_id, expected_export_digest)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared exact seal/retry logic. The caller owns the transaction and its commit boundary.
|
||||
fn seal_in_transaction(
|
||||
tx: &Transaction<'_>,
|
||||
expected: &DatabaseIdentity,
|
||||
transfer_id: &str,
|
||||
expected_export_digest: &str,
|
||||
) -> Result<PermissionTransferReceipt> {
|
||||
validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?;
|
||||
if let Some(receipt) = retained_receipt(tx)? {
|
||||
require_receipt_identity(&receipt, expected)?;
|
||||
if receipt.transfer_id != transfer_id
|
||||
|| receipt.snapshot.export_digest != expected_export_digest
|
||||
{
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
return Ok(receipt);
|
||||
}
|
||||
// A published Permission18 seal is not ours to rewrite or relabel as this receipt.
|
||||
require_unsealed(tx)?;
|
||||
let snapshot = export_in_connection(tx, expected.clone())?;
|
||||
if snapshot.export_digest != expected_export_digest {
|
||||
return Err(SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
crate::storage_formats::install_permission_transfer(tx)?;
|
||||
if !has_table(tx, "permission_source_transfer_receipt")? {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"permission transfer requires schema 1..17".into(),
|
||||
));
|
||||
}
|
||||
let receipt = PermissionTransferReceipt {
|
||||
transfer_id: transfer_id.into(),
|
||||
snapshot,
|
||||
};
|
||||
let raw = serde_json::to_string(&receipt)?;
|
||||
tx.execute(
|
||||
"INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)",
|
||||
params![transfer_id, serde_json::to_string(&receipt.snapshot)?],
|
||||
)?;
|
||||
tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
pub(crate) fn has_table(connection: &Connection, name: &str) -> Result<bool> {
|
||||
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,935 @@
|
||||
//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim.
|
||||
use super::*;
|
||||
use crate::tests::{open_store, role};
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Barrier, Mutex};
|
||||
|
||||
fn read_permission() -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: "file.read".into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: "wd".into(),
|
||||
path: ".".into(),
|
||||
recursive: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(
|
||||
store: &SubjektivStore,
|
||||
subject: &str,
|
||||
operation: &str,
|
||||
delegation: Vec<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.with_permission_authority_guard::<()>(&wrong, |_| {
|
||||
panic!("an identity mismatch must not enter the guarded action")
|
||||
}),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.permission_transfer_transaction::<()>(&wrong, |_| {
|
||||
panic!("an identity mismatch must not enter the SQL transaction body")
|
||||
}),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
!store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| has_table(
|
||||
c,
|
||||
"permission_source_transfer_receipt"
|
||||
))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_after_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let unset = store.create_subject(role()).unwrap().id;
|
||||
let empty = store.create_subject(role()).unwrap().id;
|
||||
let limited = store.create_subject(role()).unwrap().id;
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute(
|
||||
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||
[&unset],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (_, empty_source) = replace(&store, &empty, "empty", vec![]);
|
||||
let (old_update, limited_source) =
|
||||
replace(&store, &limited, "limited", vec![read_permission()]);
|
||||
let grant = child(&limited, &limited_source.change_id, "historical-child");
|
||||
store.bind_worker_execution_grant(&grant).unwrap();
|
||||
let before = counts(&store);
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
assert_eq!(preview.sources.len(), 3);
|
||||
assert!(
|
||||
preview
|
||||
.sources
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].subject_id < pair[1].subject_id)
|
||||
);
|
||||
assert_eq!(
|
||||
preview
|
||||
.sources
|
||||
.iter()
|
||||
.find(|row| row.subject_id == unset)
|
||||
.unwrap()
|
||||
.permissions,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
preview
|
||||
.sources
|
||||
.iter()
|
||||
.find(|row| row.subject_id == empty)
|
||||
.unwrap()
|
||||
.permissions,
|
||||
Some(empty_source)
|
||||
);
|
||||
assert_eq!(
|
||||
preview
|
||||
.sources
|
||||
.iter()
|
||||
.find(|row| row.subject_id == limited)
|
||||
.unwrap()
|
||||
.permissions,
|
||||
Some(limited_source.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
preview,
|
||||
store.preview_permission_transfer(&identity).unwrap()
|
||||
);
|
||||
// Preview is physically read-only, including the domain schema.
|
||||
assert!(
|
||||
!store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| has_table(
|
||||
c,
|
||||
"permission_source_transfer_receipt"
|
||||
))
|
||||
.unwrap()
|
||||
);
|
||||
let receipt = store
|
||||
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.snapshot, preview);
|
||||
assert_eq!(counts(&store), before);
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_execution_grant("runtime", "historical-child")
|
||||
.unwrap(),
|
||||
Some(grant.clone())
|
||||
);
|
||||
store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority.
|
||||
assert!(matches!(
|
||||
store.set_subject_permissions(&limited, &old_update),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.set_managed_subject_permissions(&limited, &old_update),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.seal_permission_sources(&identity, "different", &preview.export_digest),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.seal_permission_sources(&identity, "transfer", "different-export"),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
let mut wrong_identity = identity.clone();
|
||||
wrong_identity.scope_id = "workspace-b".into();
|
||||
assert!(matches!(
|
||||
store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.preview_permission_transfer(&wrong_identity),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
drop(store);
|
||||
scope.shutdown().unwrap();
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert_eq!(identity, store.database_identity().unwrap());
|
||||
assert_eq!(
|
||||
receipt,
|
||||
store
|
||||
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
preview,
|
||||
store.preview_permission_transfer(&identity).unwrap()
|
||||
);
|
||||
assert_eq!(counts(&store), before);
|
||||
assert!(matches!(
|
||||
store.subject_permissions(&limited),
|
||||
Err(SubjektivError::AuthorizationDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
replace(&store, &subject, "old-writer-after-preview", vec![]);
|
||||
assert!(matches!(
|
||||
store.seal_permission_sources(&identity, "transfer", &preview.export_digest),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(
|
||||
!store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| is_sealed(c))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| has_table(
|
||||
c,
|
||||
"permission_source_transfer_receipt"
|
||||
))
|
||||
.unwrap()
|
||||
);
|
||||
replace(&store, &subject, "still-writable", vec![]);
|
||||
let fresh = store.preview_permission_transfer(&identity).unwrap();
|
||||
assert_ne!(fresh.export_digest, preview.export_digest);
|
||||
store
|
||||
.seal_permission_sources(&identity, "new-request", &fresh.export_digest)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
store
|
||||
.database
|
||||
.transaction(crate::storage_formats::install_permission_transfer)
|
||||
.unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| is_sealed(c))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
replace(&store, &subject, "writer-after-rollback", vec![]);
|
||||
drop(store);
|
||||
scope.shutdown().unwrap();
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert!(store.subject_permissions(&subject).unwrap().is_some());
|
||||
seal(&store);
|
||||
}
|
||||
|
||||
struct Canonical {
|
||||
sources: BTreeMap<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 authority_guard_gates_legacy_callback_and_releases_an_unsealed_transaction() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let legacy_write = || {
|
||||
store.with_permission_authority_guard(&identity, |guard| {
|
||||
assert!(
|
||||
!guard.transaction.borrow().as_ref().unwrap().is_autocommit(),
|
||||
"the Feature transaction must already be active on callback entry"
|
||||
);
|
||||
guard.require_unsealed()?;
|
||||
guard.require_subject(&subject)?;
|
||||
// Stand-in for a logical Server write/commit. Never reenter Feature storage here.
|
||||
calls.set(calls.get() + 1);
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
legacy_write().unwrap();
|
||||
assert_eq!(calls.get(), 1);
|
||||
store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
assert!(connection.is_autocommit());
|
||||
assert!(!has_table(
|
||||
connection,
|
||||
"permission_source_transfer_receipt"
|
||||
)?);
|
||||
assert!(!is_sealed(connection)?);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// A logical write does not mutate Feature, and an early callback failure does not seal.
|
||||
let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.require_unsealed()?;
|
||||
Err(SubjektivError::InvalidRecord("callback before seal".into()))
|
||||
});
|
||||
assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_))));
|
||||
replace(&store, &subject, "writer-after-guard-return", vec![]);
|
||||
seal(&store);
|
||||
assert!(matches!(
|
||||
legacy_write(),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
assert_eq!(calls.get(), 1, "a sealed legacy callback must not execute");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_guard_metadata_checks_do_not_call_canonical_source_and_honor_deletion() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let deleting = store.create_subject(role()).unwrap().id;
|
||||
seal(&store);
|
||||
store
|
||||
.begin_subject_deletion(&deleting, "deleting-before-guard")
|
||||
.unwrap();
|
||||
let provider = Arc::new(Canonical {
|
||||
sources: BTreeMap::new(), // Any provider call would fail, and could reenter Server.
|
||||
calls: Mutex::new(vec![]),
|
||||
});
|
||||
let store = store.with_canonical_permission_source(provider.clone());
|
||||
let identity = store.database_identity().unwrap();
|
||||
store
|
||||
.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.require_subject(&subject)?; // Metadata still exists even when authority is sealed.
|
||||
assert!(matches!(
|
||||
guard.require_subject("missing-subject"),
|
||||
Err(SubjektivError::SubjectNotFound(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
guard.require_subject(&deleting),
|
||||
Err(SubjektivError::SubjectNotFound(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
guard.require_unsealed(),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(provider.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_guard_seal_survives_callback_failure_and_exact_retry_preserves_server_error() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
let before = counts(&store);
|
||||
let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.require_subject(&subject)?;
|
||||
let receipt =
|
||||
guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?;
|
||||
assert_eq!(receipt.snapshot, preview);
|
||||
assert!(
|
||||
guard.transaction.borrow().is_none(),
|
||||
"seal must end the Feature transaction before adoption"
|
||||
);
|
||||
assert!(matches!(
|
||||
guard.require_subject(&subject),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
assert!(matches!(
|
||||
guard.require_unsealed(),
|
||||
Err(SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
Err(SubjektivError::InvalidRecord(
|
||||
"callback after committed seal".into(),
|
||||
))
|
||||
});
|
||||
assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_))));
|
||||
let receipt = store
|
||||
.permission_transfer_receipt(&identity)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(receipt.transfer_id, "server-fingerprint");
|
||||
assert_eq!(receipt.snapshot, preview);
|
||||
assert_eq!(counts(&store), before);
|
||||
let nested: Result<std::result::Result<(), u16>> =
|
||||
store.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.require_subject(&subject)?;
|
||||
assert_eq!(
|
||||
guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?,
|
||||
receipt
|
||||
);
|
||||
assert!(
|
||||
guard.transaction.borrow().is_none(),
|
||||
"receipt retry must also end its transaction"
|
||||
);
|
||||
// Preserve the Server error/status independently of the Feature result.
|
||||
Ok(Err(409))
|
||||
});
|
||||
assert!(matches!(nested, Ok(Err(409))));
|
||||
assert_eq!(
|
||||
store.permission_transfer_receipt(&identity).unwrap(),
|
||||
Some(receipt)
|
||||
);
|
||||
for (transfer, digest) in [
|
||||
("another-request", preview.export_digest.as_str()),
|
||||
("server-fingerprint", "changed-export"),
|
||||
] {
|
||||
assert!(matches!(
|
||||
store.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.seal_permission_sources(transfer, digest)
|
||||
}),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
}
|
||||
assert_eq!(counts(&store), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_guard_seal_sql_failure_rolls_back_before_callback_returns() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
store.create_subject(role()).unwrap();
|
||||
let identity = store.database_identity().unwrap();
|
||||
let preview = store.preview_permission_transfer(&identity).unwrap();
|
||||
store
|
||||
.database
|
||||
.transaction(crate::storage_formats::install_permission_transfer)
|
||||
.unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
connection.execute_batch("CREATE TEMP TRIGGER fail_guard_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected guarded receipt failure'); END;")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store
|
||||
.with_permission_authority_guard(&identity, |guard| {
|
||||
assert!(
|
||||
guard
|
||||
.seal_permission_sources("transfer", &preview.export_digest)
|
||||
.is_err()
|
||||
);
|
||||
assert!(guard.transaction.borrow().is_none());
|
||||
// Failure consumes the transaction; a caught error must not enable an unfenced writer.
|
||||
assert!(guard.require_unsealed().is_err());
|
||||
assert!(
|
||||
guard
|
||||
.seal_permission_sources("transfer", &preview.export_digest)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|connection| {
|
||||
assert!(connection.is_autocommit());
|
||||
assert!(!is_sealed(connection)?);
|
||||
assert!(retained_receipt(connection)?.is_none());
|
||||
connection.execute_batch("DROP TRIGGER fail_guard_transfer")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.with_permission_authority_guard(&identity, |guard| {
|
||||
guard.require_unsealed()?;
|
||||
guard.seal_permission_sources("transfer", &preview.export_digest)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_workspace() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let (_manager_a, _scope_a, store_a) = open_store(first.path(), "workspace-a");
|
||||
let (_manager_b, _scope_b, store_b) = open_store(second.path(), "workspace-a");
|
||||
let a = store_a.database_identity().unwrap();
|
||||
let b = store_b.database_identity().unwrap();
|
||||
assert_ne!(a, b);
|
||||
let preview = store_a.preview_permission_transfer(&a).unwrap();
|
||||
assert!(matches!(
|
||||
store_b.preview_permission_transfer(&a),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(matches!(
|
||||
store_b.seal_permission_sources(&a, "transfer", &preview.export_digest),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(matches!(
|
||||
store_b.with_permission_authority_guard::<()>(&a, |_| {
|
||||
panic!("a different physical database must not enter the guarded action")
|
||||
}),
|
||||
Err(SubjektivError::PermissionTransferConflict)
|
||||
));
|
||||
assert!(
|
||||
!store_b
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(is_sealed)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
@@ -579,3 +579,159 @@ fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds(
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_transfer_extension_reopens_on_every_published_family_without_relabelling_receipts() {
|
||||
for format in PUBLISHED {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
published_fixture(temp.path(), format);
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
let saved = db
|
||||
.with_connection(|connection| Ok(receipts(connection)))
|
||||
.unwrap();
|
||||
let old_seal = db
|
||||
.with_connection(|connection| {
|
||||
if format == Format::Permission18 {
|
||||
Ok(Some(connection.query_row(
|
||||
"SELECT export_json FROM permission_authority_seal",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
db.transaction(install_permission_transfer).unwrap();
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(receipts(connection), saved);
|
||||
assert_eq!(
|
||||
domain_shape(connection)?,
|
||||
reference_shape_with_transfer(format, true)?
|
||||
);
|
||||
if let Some(raw) = &old_seal {
|
||||
assert_eq!(
|
||||
connection.query_row(
|
||||
"SELECT export_json FROM permission_authority_seal",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)?,
|
||||
*raw
|
||||
);
|
||||
}
|
||||
assert_foreign_keys(connection);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let store = SubjektivStore::from_database(db.clone()).unwrap();
|
||||
if old_seal.is_some() {
|
||||
let identity = store.database_identity().unwrap();
|
||||
assert!(matches!(
|
||||
store.preview_permission_transfer(&identity),
|
||||
Err(crate::SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.seal_permission_sources(&identity, "new-transfer", "export"),
|
||||
Err(crate::SubjektivError::PermissionAuthoritySealed)
|
||||
));
|
||||
struct CanonicalEmpty;
|
||||
impl crate::CanonicalPermissionSource for CanonicalEmpty {
|
||||
fn subject_permissions(
|
||||
&self,
|
||||
workspace: &str,
|
||||
_subject: &str,
|
||||
) -> crate::Result<Option<server_api::authorization::SubjectPermissions>>
|
||||
{
|
||||
assert_eq!(workspace, SCOPE);
|
||||
Ok(Some(server_api::authorization::SubjectPermissions {
|
||||
change_id: "canonical-source".into(),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
}))
|
||||
}
|
||||
}
|
||||
let connected = store
|
||||
.clone()
|
||||
.with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty));
|
||||
let subject = connected.list_subjects(1).unwrap().items[0].id.clone();
|
||||
assert_eq!(
|
||||
connected
|
||||
.subject_permissions(&subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
"canonical-source"
|
||||
);
|
||||
connected
|
||||
.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant {
|
||||
subject_id: subject,
|
||||
source_change_id: "canonical-source".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "canonical-child".into(),
|
||||
origin_runtime_id: "runtime".into(),
|
||||
origin_worker_id: "root".into(),
|
||||
profile: "builtin:coder".into(),
|
||||
profile_digest: "resolved-profile".into(),
|
||||
permissions: vec![],
|
||||
redelegation: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
drop(store);
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(receipts(connection), saved);
|
||||
assert_eq!(
|
||||
domain_shape(connection)?,
|
||||
reference_shape_with_transfer(format, true)?
|
||||
);
|
||||
assert_foreign_keys(connection);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (scope, db) = open(temp.path(), REGISTRATION);
|
||||
let before_shape = db.with_connection(domain_shape).unwrap();
|
||||
let before_rows = db
|
||||
.with_connection(|connection| Ok(all_rows(connection)))
|
||||
.unwrap();
|
||||
let result = db.transaction(|tx| {
|
||||
install_permission_transfer(tx)?;
|
||||
tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
db.with_connection(|connection| {
|
||||
assert_eq!(domain_shape(connection)?, before_shape);
|
||||
assert_eq!(all_rows(connection), before_rows);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
db.transaction(install_permission_transfer).unwrap();
|
||||
db.with_connection(|connection| {
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(db);
|
||||
scope.shutdown().unwrap();
|
||||
let scope = feature_storage::FeatureStorage::new(temp.path())
|
||||
.scope(SCOPE)
|
||||
.unwrap();
|
||||
let registered = scope.register(REGISTRATION).unwrap();
|
||||
assert!(matches!(
|
||||
scope.open(®istered),
|
||||
Err(FeatureStorageError::MigrationHistory { .. })
|
||||
));
|
||||
scope.shutdown().unwrap();
|
||||
}
|
||||
|
||||
@@ -81,7 +81,16 @@ pub(super) fn validate(
|
||||
applied: &[(u32, String)],
|
||||
) -> feature_storage::Result<()> {
|
||||
let format = recognize(applied)?;
|
||||
let expected = reference_shape(format)?;
|
||||
let transfer = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='permission_source_transfer_receipt')",
|
||||
[], |row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) {
|
||||
return Err(incompatible(
|
||||
"permission transfer requires complete common 1..17",
|
||||
));
|
||||
}
|
||||
let expected = reference_shape_with_transfer(format, transfer)?;
|
||||
let actual = domain_shape(tx)?;
|
||||
if actual != expected {
|
||||
let difference = actual
|
||||
@@ -115,9 +124,17 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result<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 +144,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 +201,66 @@ 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 +268,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [
|
||||
"worker_execution_grants",
|
||||
];
|
||||
|
||||
// Frozen physical shape evidence, not target migrations. Permission SQL is from
|
||||
// Published DDL is retained byte-for-byte as physical shape evidence. Only the permission
|
||||
// fragment is also reused by the explicit separate-domain initializer above, never as migration 18.
|
||||
// Permission SQL is from
|
||||
// 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs.
|
||||
// Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03:
|
||||
// crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! New Subjects receive their self-scoped conversation source in the creation transaction.
|
||||
//! This path is never called by migration, launch, restore or permission reads. Receipts
|
||||
//! retain only the issued identity and input fingerprint across erasure, preventing replay
|
||||
//! Before sealing, new Subjects receive their self-scoped source in the creation transaction.
|
||||
//! After sealing, creation retains enrollment evidence only; current reads never revive defaults.
|
||||
//! This path is never called by migration, launch, restore or permission reads. Receipts retain
|
||||
//! identity, input fingerprint and any post-seal enrollment across erasure, preventing replay
|
||||
//! from recreating an erased Subject or restoring a revoked policy.
|
||||
use super::*;
|
||||
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
|
||||
@@ -18,6 +19,25 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
/// Creation-only enrollment evidence for the canonical domain. Never a current permission
|
||||
/// source or a read-time default; the Host must not apply it to an existing/unset policy.
|
||||
pub fn subject_permission_enrollment(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<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 +80,20 @@ impl SubjektivStore {
|
||||
execution: subject_conversation_permissions(self.scope_id(), &record.id),
|
||||
delegation: vec![],
|
||||
};
|
||||
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||
if let Some(operation) = operation_id {
|
||||
let sealed = permissions::transfer::is_sealed(tx)?;
|
||||
if !sealed {
|
||||
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||
}
|
||||
if sealed {
|
||||
// Retain creation-only enrollment for the canonical domain, never a legacy
|
||||
// source write or a read-time default. Even non-idempotent creation has evidence.
|
||||
let enrollment_operation = operation_id.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("subject-enrollment:{}", record.id));
|
||||
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint,permission_enrollment_json) VALUES(?1,?2,?3,?4)",
|
||||
params![enrollment_operation,record.id,fingerprint,serde_json::to_string(&source)?])?;
|
||||
} else if let Some(operation) = operation_id {
|
||||
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
|
||||
}
|
||||
Ok(record)
|
||||
|
||||
@@ -542,6 +542,8 @@ pub struct RuntimeHttpErrorResponse {
|
||||
pub struct RuntimeHttpErrorDetail {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub startup: Option<runtime_api::WorkerStartupFailure>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -2325,6 +2327,7 @@ struct RuntimeHttpRestError {
|
||||
status: StatusCode,
|
||||
code: String,
|
||||
message: String,
|
||||
startup: Option<runtime_api::WorkerStartupFailure>,
|
||||
}
|
||||
|
||||
impl RuntimeHttpRestError {
|
||||
@@ -2333,13 +2336,28 @@ impl RuntimeHttpRestError {
|
||||
status,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
startup: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime(error: RuntimeError) -> Self {
|
||||
let status = status_for_runtime_error(&error);
|
||||
let code = code_for_runtime_error(&error);
|
||||
Self::new(status, code, error.to_string())
|
||||
let startup = match &error {
|
||||
RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result),
|
||||
_ => None,
|
||||
};
|
||||
let message = if startup.is_some() {
|
||||
"Worker startup failed".to_owned()
|
||||
} else {
|
||||
error.to_string()
|
||||
};
|
||||
Self {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
startup,
|
||||
}
|
||||
}
|
||||
|
||||
fn working_directory_join(_error: tokio::task::JoinError) -> Self {
|
||||
@@ -2375,12 +2393,31 @@ impl IntoResponse for RuntimeHttpRestError {
|
||||
error: RuntimeHttpErrorDetail {
|
||||
code: self.code,
|
||||
message: self.message,
|
||||
startup: self.startup,
|
||||
},
|
||||
};
|
||||
(self.status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit transfer mapping, not another execution/lifecycle result. HTTP request
|
||||
/// authentication has already established this internal Runtime caller's scope.
|
||||
fn startup_transfer(
|
||||
result: &crate::execution::WorkerExecutionResult,
|
||||
) -> Option<runtime_api::WorkerStartupFailure> {
|
||||
let primary = worker::startup_failure::reproject_internal_startup_failure(
|
||||
result.startup_failure.as_ref()?,
|
||||
)
|
||||
.ok()?;
|
||||
Some(runtime_api::WorkerStartupFailure {
|
||||
primary,
|
||||
secondary: result.secondary_failures.clone(),
|
||||
resources: result.startup_resources,
|
||||
persistence: result.startup_failure_persistence,
|
||||
io_kind: result.startup_io_kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||
match error {
|
||||
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
|
||||
|
||||
@@ -224,7 +224,10 @@ where
|
||||
}
|
||||
|
||||
fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError {
|
||||
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message)
|
||||
let mut response =
|
||||
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message);
|
||||
response.error.startup = error.startup;
|
||||
response
|
||||
}
|
||||
|
||||
impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Server-side registration of the compiled builtin operation declarations.
|
||||
//!
|
||||
//! This is a trusted builtin loader, not a wire-catalog importer. Namespace
|
||||
//! ownership is listed here explicitly; neither a permission spelling nor a
|
||||
//! management request chooses an owner. The existing operation descriptors own
|
||||
//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes
|
||||
//! and actual operation authorization remain independent of this metadata load.
|
||||
|
||||
use super::super::FeatureInstallError;
|
||||
use feature_permissions::{
|
||||
FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, PermissionDefinition,
|
||||
PermissionHost, PermissionKey, PermissionReference, PermissionSetId, SetContribution,
|
||||
WorkspaceId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const OWNERS: &[(&str, &str)] = &[
|
||||
("filesystem", "builtin:workdir"),
|
||||
("bash", "builtin:bash"),
|
||||
("workdir", "builtin:workdir"),
|
||||
("worker", "builtin:worker"),
|
||||
("subworker", "builtin:sub-worker-control"),
|
||||
("ticket", "builtin:ticket"),
|
||||
("objective", "builtin:objective"),
|
||||
("merge_request", "builtin:merge-request"),
|
||||
("repository", "builtin:repository"),
|
||||
("drive", "builtin:drive"),
|
||||
("workspace_config", "builtin:workspace-config"),
|
||||
("memory", "builtin:memory"),
|
||||
("journal", "builtin:journal-recording"),
|
||||
("chat", "builtin:subjektiv-chat"),
|
||||
("session", "builtin:session-explore"),
|
||||
("web", "builtin:web"),
|
||||
("notification", "builtin:notification"),
|
||||
];
|
||||
|
||||
pub fn builtin_permission_host(
|
||||
workspace: WorkspaceId,
|
||||
) -> Result<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();
|
||||
// MCP's scalar startup permission is the retained T-787 exception, not a
|
||||
// payload-free operation key. Do not invent mcp.* authority or a Set for it.
|
||||
// Legacy migration retains it as an explicit-configuration-required diagnostic.
|
||||
let operations = server_api::authorization::subject_permission_operations()
|
||||
.into_iter()
|
||||
.filter(|operation| operation.operation != "mcp")
|
||||
.collect::<Vec<_>>();
|
||||
for (namespace, owner) in OWNERS {
|
||||
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!(!keys.contains("mcp"));
|
||||
assert_eq!(
|
||||
keys,
|
||||
server_api::authorization::subject_permission_operations()
|
||||
.into_iter()
|
||||
.filter(|operation| operation.operation != "mcp")
|
||||
.map(|o| o.operation)
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,7 @@ impl FeaturePermissionRegistry {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[path = "builtin/permission_catalog.rs"]
|
||||
mod builtin_catalog;
|
||||
pub use builtin_catalog::builtin_permission_host;
|
||||
|
||||
@@ -106,6 +106,20 @@ pub fn runtime_startup_attachment(
|
||||
StartupFailureAttachment::from_projected(&projected)
|
||||
}
|
||||
|
||||
/// The internal HTTP/Host transfer admits only implemented startup domains.
|
||||
/// Unknown/corrupt envelopes cannot be blindly forwarded or classified from text.
|
||||
pub fn reproject_internal_startup_failure(
|
||||
failure: &StartupFailureAttachment,
|
||||
) -> Result<StartupFailureAttachment, error_contract::ContractError> {
|
||||
if let Ok(projected) = failure
|
||||
.project_for::<InternalTransfer, protocol::session_failure::SessionRegistrationCause>(&())
|
||||
{
|
||||
return StartupFailureAttachment::from_projected(&projected);
|
||||
}
|
||||
let projected = failure.project_for::<InternalTransfer, RuntimeStartupCause>(&())?;
|
||||
StartupFailureAttachment::from_projected(&projected)
|
||||
}
|
||||
|
||||
/// Dispatch by the domain attested in process, not by parsing attachment fields.
|
||||
pub(crate) fn persist_binding_startup_failure<S: session_store::WorkerMetadataStore>(
|
||||
store: &S,
|
||||
|
||||
@@ -897,6 +897,10 @@ pub(crate) fn test_subject_authorization_response(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "subjektiv_session_registration_tests.rs"]
|
||||
mod session_registration_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The real authorization/factory fixture lives in workspace-server. This leaf
|
||||
//! exercises only WorkspaceClient transfer failures and response validation; it
|
||||
//! must not substitute simulated policy decisions for that connected fixture.
|
||||
use super::*;
|
||||
use crate::{WorkspaceClientError, WorkspaceResponse};
|
||||
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause as Cause};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Client {
|
||||
reply: Reply,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum Reply {
|
||||
Response(u16, String),
|
||||
Unavailable,
|
||||
Request,
|
||||
}
|
||||
impl WorkspaceClient for Client {
|
||||
fn kind(&self) -> &str {
|
||||
"registration-transfer-test"
|
||||
}
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(request.method, WorkspaceRequestMethod::Post);
|
||||
assert_eq!(request.path, "/api/w/workspace/subjektiv/sessions");
|
||||
match &self.reply {
|
||||
Reply::Response(status, body) => Ok(WorkspaceResponse {
|
||||
status: *status,
|
||||
body: body.clone(),
|
||||
}),
|
||||
Reply::Unavailable => Err(WorkspaceClientError::Unavailable(
|
||||
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
|
||||
)),
|
||||
Reply::Request => Err(WorkspaceClientError::Request(
|
||||
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn client(reply: Reply) -> Client {
|
||||
Client {
|
||||
reply,
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
fn failure_body(cause: Cause) -> String {
|
||||
let mut error = RepositoryApiError::new(
|
||||
403,
|
||||
"ignored",
|
||||
"TEST_ONLY_CREDENTIAL /private/path raw_http_body",
|
||||
vec![],
|
||||
);
|
||||
error.failure =
|
||||
Some(protocol::session_failure::session_registration_attachment(cause).unwrap());
|
||||
serde_json::to_string(&error).unwrap()
|
||||
}
|
||||
fn assert_failure(client: Client, expected: Cause) -> SubjektivHostError {
|
||||
let error = record_backend_session(&client, "session", true).unwrap_err();
|
||||
assert_eq!(
|
||||
client.calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"classification grants no replay"
|
||||
);
|
||||
assert_eq!(error.session_registration_cause(), expected);
|
||||
let attachment = protocol::session_failure::session_registration_attachment(
|
||||
error.session_registration_cause(),
|
||||
)
|
||||
.unwrap();
|
||||
let encoded = serde_json::to_string(&attachment).unwrap();
|
||||
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
|
||||
assert!(!encoded.contains(secret));
|
||||
assert!(!error.to_string().contains(secret));
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_registration_http_reuses_typed_causes_not_status_or_prose() {
|
||||
for cause in [
|
||||
Cause::denied(SessionPrincipal::Operator),
|
||||
Cause::denied(SessionPrincipal::Subject),
|
||||
Cause::attribution_missing(),
|
||||
Cause::binding_mismatch(),
|
||||
Cause::outcome_unknown(),
|
||||
] {
|
||||
for status in [403, 409, 500] {
|
||||
assert_failure(client(Reply::Response(status, failure_body(cause))), cause);
|
||||
}
|
||||
}
|
||||
// A legacy 403 without a domain attestation is not a certified denial.
|
||||
let body = serde_json::to_string(&RepositoryApiError::new(
|
||||
403,
|
||||
"permission_denied",
|
||||
"session.record denied",
|
||||
vec![],
|
||||
))
|
||||
.unwrap();
|
||||
assert_failure(client(Reply::Response(403, body)), Cause::outcome_unknown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_registration_transport_and_unknown_keep_error_sources_without_disclosure_or_retry() {
|
||||
for (reply, cause) in [
|
||||
(Reply::Unavailable, Cause::transport()),
|
||||
(Reply::Request, Cause::outcome_unknown()),
|
||||
] {
|
||||
let error = assert_failure(client(reply), cause);
|
||||
assert!(
|
||||
std::error::Error::source(&error)
|
||||
.unwrap()
|
||||
.downcast_ref::<WorkspaceClientError>()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
let legacy =
|
||||
SubjektivHostError::PermissionDenied("session.record denied for secret subject".into());
|
||||
assert_eq!(
|
||||
legacy.session_registration_cause(),
|
||||
Cause::outcome_unknown()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_registration_rejects_malformed_incomplete_and_mismatched_responses() {
|
||||
for (status, body) in [
|
||||
(403, "not json"),
|
||||
(200, "not json"),
|
||||
(200, "{}"),
|
||||
(200, r#"{"session_id":"session","subject_id":""}"#),
|
||||
(
|
||||
200,
|
||||
r#"{"session_id":"other-session","subject_id":"subject"}"#,
|
||||
),
|
||||
] {
|
||||
assert_failure(
|
||||
client(Reply::Response(status, body.into())),
|
||||
Cause::invalid_response(),
|
||||
);
|
||||
}
|
||||
let allowed = client(Reply::Response(
|
||||
200,
|
||||
r#"{"session_id":"session","subject_id":"subject"}"#.into(),
|
||||
));
|
||||
let output = record_backend_session(&allowed, "session", true).unwrap();
|
||||
assert_eq!(output.session_id, "session");
|
||||
assert_eq!(output.subject_id, "subject");
|
||||
assert_eq!(allowed.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -9,3 +9,4 @@ Backend that manages persistent Workspace resources and authorization and provid
|
||||
- [`hosts`] / [`worker_source`] — Runtime delegation and request source verification
|
||||
- [`backend_job`] — Immutable input, attempt, and structured result boundaries
|
||||
- [`workspace_catalog`] — Workspace creation and replay contracts
|
||||
- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Owner-only adoption of Workspace source and Subject policy in the Server transaction.
|
||||
//!
|
||||
//! The old Feature database is exported and permanently sealed before its immutable
|
||||
//! transfer is adopted here. That preparation is not a distributed commit: failure
|
||||
//! after seal leaves an explicit, resumable transfer, never a legacy fallback.
|
||||
//! Normal saves never touch the old database. Object preparation, source,
|
||||
//! projection, policy, editing head and receipt commit in one caller-owned transaction.
|
||||
//!
|
||||
//! # Editing and reconciliation
|
||||
//!
|
||||
//! Owner authentication precedes all source, preview, adoption and reconciliation
|
||||
//! operations. The public transport contract belongs to
|
||||
//! [`server_api::configuration_adoption`]. It extends the existing virtual tree;
|
||||
//! imports resolve within one snapshot and no host filesystem path grants access.
|
||||
//! Before cutover, retrieval returns an evaluated editable migration draft, not a
|
||||
//! saved change. Preview is read-only and reports changed paths and Subjects.
|
||||
//!
|
||||
//! Content identity and editing history are distinct. Adoption compares the
|
||||
//! observed history head as well as the source digest, so A -> B -> A cannot
|
||||
//! authorize an old editor. The request fingerprint binds Workspace, authenticated
|
||||
//! owner and the entire request (including catalog, export, head and changes).
|
||||
//! Reuse of an operation ID with different input is a conflict. Exact replay
|
||||
//! returns historical evidence before stale-head checks; it never reapplies policy.
|
||||
//!
|
||||
//! After a lost response, retain the full request and reconcile it with that same
|
||||
//! ID. An adopted receipt proves that request's stored result, not today's head.
|
||||
//! A pending transfer proves the immutable legacy seal, not Server adoption.
|
||||
//! Absence of either is not evidence of failure or permission to generate a new
|
||||
//! ID. Retry the exact request; read current source before a distinct edit.
|
||||
//!
|
||||
//! # Cutover boundary
|
||||
//!
|
||||
//! Initial cutover and legacy logical config writes acquire the guarded Feature
|
||||
//! authority transaction before Server storage. Sealing commits independently;
|
||||
//! the outer Feature connection guard remains held through the Server result.
|
||||
//! After seal, both old policy writes and old logical source writes reject,
|
||||
//! including when Server persistence rolls back. Reopen/retry uses the same
|
||||
//! identity-bound export and request. No in-memory mutex is the durable fence.
|
||||
//! Validation (including blocking migration diagnostics) precedes seal. Failure
|
||||
//! there retains legacy authority and the previous source/policy/head/receipt.
|
||||
//! A failure after seal retains the old Server source but fails closed for runtime
|
||||
//! reads until the same transfer is adopted. This is not a distributed atomic
|
||||
//! commit or a guarantee for arbitrary machine loss at every instruction.
|
||||
//!
|
||||
//! Existing explicit permissions are never automatically merged or replaced,
|
||||
//! even when empty. Unsupported operations or child-expanding conversions block
|
||||
//! adoption rather than dropping entries. Exact limited legacy constraints and
|
||||
//! change identities remain in the domain, separate from editable permission keys.
|
||||
//! History and child grants are retained, not regenerated as new authorizations.
|
||||
//!
|
||||
//! A receipt separately confirms source storage and policy commit. Its typed
|
||||
//! runtime application means the next current-policy check, not interruption,
|
||||
//! reload or termination of an already-running Worker. Legacy consumers cannot
|
||||
//! turn a new unrepresented key into an all-target grant.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{ConfigTreeSnapshot, WorkspaceConfigSchemaBundle};
|
||||
use content_history::HistoryId;
|
||||
use content_history_store::{Access, Authority, ScopeKey, ScopedStore};
|
||||
use feature_permissions::PermissionHost;
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
|
||||
use crate::policy_config::{self, AdoptedSubjectPolicy, PermissionSource};
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub(crate) const SCHEMA: &str = include_str!("config_adoption.sql");
|
||||
pub(crate) const MIGRATION_NAME: &str = "Atomic Workspace source and Subject policy adoption";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionRequest {
|
||||
pub operation_id: String,
|
||||
/// Editing history identity, not a content digest. Null is only the initial head.
|
||||
pub expected_head: Option<String>,
|
||||
pub expected_catalog_digest: String,
|
||||
pub expected_legacy_export: Option<String>,
|
||||
pub source: ConfigCommitRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimePolicyApplication {
|
||||
NextCurrentPolicyCheck,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationAdoptionResult {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
pub history_head: String,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub source_stored: bool,
|
||||
pub policy_committed: bool,
|
||||
/// Consumers must reload current policy before their next protected operation.
|
||||
/// This is not confirmation that a running Worker has stopped or reloaded.
|
||||
pub runtime_application: RuntimePolicyApplication,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationPreview {
|
||||
pub expected_head: Option<String>,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub changed_paths: Vec<String>,
|
||||
pub changed_subjects: Vec<String>,
|
||||
}
|
||||
|
||||
/// Only the trusted migration boundary constructs this from a verified Feature
|
||||
/// export. The transfer identity binds Workspace, physical DB, export and the full
|
||||
/// adoption request. It is not an HTTP request or an authority credential.
|
||||
pub(crate) struct ConfigurationPolicyTransfer {
|
||||
pub transfer_identity: String,
|
||||
pub physical_identity: String,
|
||||
pub export_digest: String,
|
||||
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
}
|
||||
|
||||
pub(crate) struct CanonicalConfigurationSource {
|
||||
pub store: SqliteWorkspaceStore,
|
||||
pub host: PermissionHost,
|
||||
}
|
||||
|
||||
impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource {
|
||||
fn subject_permissions(
|
||||
&self,
|
||||
workspace: &str,
|
||||
subject: &str,
|
||||
) -> subjektiv::Result<Option<server_api::authorization::SubjectPermissions>> {
|
||||
if self.host.catalog().workspace.as_str() != workspace {
|
||||
return Err(subjektiv::SubjektivError::PermissionTransferConflict);
|
||||
}
|
||||
self.store
|
||||
.adopted_subject_policy(workspace, subject)
|
||||
.and_then(|policy| {
|
||||
policy
|
||||
.map(|policy| policy.legacy_read(&self.host))
|
||||
.transpose()
|
||||
})
|
||||
.map_err(|error| {
|
||||
subjektiv::SubjektivError::InvalidRecord(format!(
|
||||
"canonical configuration policy unavailable: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigurationCurrent {
|
||||
pub state: WorkspaceConfigState,
|
||||
pub history_head: Option<String>,
|
||||
pub canonical: bool,
|
||||
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigurationMigrationBaseline<'a> {
|
||||
pub original: &'a WorkspaceConfigState,
|
||||
pub state: &'a WorkspaceConfigState,
|
||||
pub policies: &'a BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
pub diagnostics: &'a [policy_config::MigrationDiagnostic],
|
||||
pub export_digest: &'a str,
|
||||
}
|
||||
|
||||
struct OwnerScope<'a>(&'a str);
|
||||
impl Authority for OwnerScope<'_> {
|
||||
fn allows(&self, key: &ScopeKey, access: Access) -> bool {
|
||||
key.workspace == self.0
|
||||
&& key.target == "configuration"
|
||||
&& matches!(access, Access::Read | Access::Write)
|
||||
}
|
||||
}
|
||||
|
||||
fn content_error(e: impl std::fmt::Display) -> Error {
|
||||
Error::Store(format!("configuration content: {e}"))
|
||||
}
|
||||
fn conflict(message: &str) -> Error {
|
||||
Error::WorkspaceConfigConflict(message.into())
|
||||
}
|
||||
fn json<T: Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|e| Error::Store(e.to_string()))
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
|
||||
serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string()))
|
||||
}
|
||||
fn scope(workspace: &str) -> ScopeKey {
|
||||
ScopeKey {
|
||||
workspace: workspace.into(),
|
||||
target: "configuration".into(),
|
||||
}
|
||||
}
|
||||
fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> {
|
||||
let allowed: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",
|
||||
params![workspace, account], |r| r.get(0),
|
||||
)?;
|
||||
if !allowed {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"configuration management requires the Workspace owner".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn head(conn: &Connection, workspace: &str) -> Result<Option<HistoryId>> {
|
||||
let bytes: Option<Vec<u8>> = conn
|
||||
.query_row(
|
||||
"SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'",
|
||||
[workspace],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
bytes
|
||||
.map(|bytes| {
|
||||
bytes.try_into().map(HistoryId::from_bytes).map_err(|_| {
|
||||
Error::RegistryInconsistency("invalid configuration editing head".into())
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn request_fingerprint(
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
) -> Result<String> {
|
||||
server_api::authorization::validate_authorization_identifier(&request.operation_id)
|
||||
.map_err(Error::InvalidInput)?;
|
||||
Ok(config_source::digest_bytes(
|
||||
json(&(workspace, account, request))?.as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",
|
||||
[workspace],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn catalog_digest(host: &PermissionHost) -> Result<String> {
|
||||
Ok(config_source::digest_bytes(
|
||||
json(&host.catalog())?.as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
fn policies(conn: &Connection, workspace: &str) -> Result<BTreeMap<String, AdoptedSubjectPolicy>> {
|
||||
let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?;
|
||||
let mut result = BTreeMap::new();
|
||||
for row in query.query_map([workspace], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
})? {
|
||||
let (subject, raw) = row?;
|
||||
let policy: AdoptedSubjectPolicy = decode(&raw)?;
|
||||
if subject != policy.source.subject_id {
|
||||
return Err(Error::RegistryInconsistency(
|
||||
"Subject policy identity mismatch".into(),
|
||||
));
|
||||
}
|
||||
result.insert(subject, policy);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn receipt(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
operation: &str,
|
||||
) -> Result<Option<(String, ConfigurationAdoptionResult)>> {
|
||||
let row: Option<(String,String)> = conn.query_row(
|
||||
"SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2",
|
||||
params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)),
|
||||
).optional()?;
|
||||
row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?)))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec<String> {
|
||||
before
|
||||
.entries
|
||||
.keys()
|
||||
.chain(after.entries.keys())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.filter(|path| before.entries.get(*path) != after.entries.get(*path))
|
||||
.map(|path| path.as_str().to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn changed_subjects(
|
||||
before: &BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
after: &BTreeMap<String, AdoptedSubjectPolicy>,
|
||||
) -> Vec<String> {
|
||||
before
|
||||
.keys()
|
||||
.chain(after.keys())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.filter(|subject| before.get(*subject) != after.get(*subject))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub(crate) fn configuration_current(
|
||||
&self,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
) -> Result<ConfigurationCurrent> {
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
require_owner(&tx, workspace, account)?;
|
||||
Ok(ConfigurationCurrent {
|
||||
state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| {
|
||||
Error::InvalidInput("Workspace source not materialized".into())
|
||||
})?,
|
||||
history_head: head(&tx, workspace)?.map(|id| id.to_string()),
|
||||
canonical: canonical(&tx, workspace)?,
|
||||
policies: policies(&tx, workspace)?,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn configuration_request_result(
|
||||
&self,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
) -> Result<Option<ConfigurationAdoptionResult>> {
|
||||
let fingerprint = request_fingerprint(workspace, account, request)?;
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn, workspace, account)?;
|
||||
match receipt(conn, workspace, &request.operation_id)? {
|
||||
Some((saved, result)) if saved == fingerprint => Ok(Some(result)),
|
||||
Some(_) => Err(conflict("operation ID reused with another request")),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Authentication supplies `account`; it is never accepted from an adoption DTO.
|
||||
pub fn configuration_head(&self, workspace: &str, account: &str) -> Result<Option<String>> {
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn, workspace, account)?;
|
||||
Ok(head(conn, workspace)?.map(|id| id.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Result lookup is a read. A historical receipt never reactivates policy.
|
||||
pub fn configuration_adoption_result(
|
||||
&self,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
operation: &str,
|
||||
) -> Result<Option<ConfigurationAdoptionResult>> {
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn, workspace, account)?;
|
||||
Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn preview_configuration_adoption(
|
||||
&self,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle,
|
||||
host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
mut require_subject: impl FnMut(&str) -> Result<()>,
|
||||
) -> Result<ConfigurationPreview> {
|
||||
self.with_conn(|conn| {
|
||||
require_owner(conn, workspace, account)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let (current, state, source) = prepare(
|
||||
&tx,
|
||||
workspace,
|
||||
request,
|
||||
schema,
|
||||
host,
|
||||
baseline,
|
||||
&mut require_subject,
|
||||
)?;
|
||||
let before = match baseline {
|
||||
Some(baseline) => baseline.policies.clone(),
|
||||
None => policies(&tx, workspace)?,
|
||||
};
|
||||
let mut after = before.clone();
|
||||
policy_config::apply(&mut after, &source, "preview");
|
||||
Ok(ConfigurationPreview {
|
||||
expected_head: request.expected_head.clone(),
|
||||
changed_paths: changed_paths(¤t.snapshot, &state.snapshot),
|
||||
changed_subjects: changed_subjects(&before, &after),
|
||||
state,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The transfer callback may seal the old DB only after all validation and
|
||||
/// current-head checks. If subsequent Server persistence fails, that specific
|
||||
/// transfer remains pending; callers reconcile/retry the same request, not a new ID.
|
||||
pub(crate) fn adopt_configuration(
|
||||
&self,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle,
|
||||
host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
mut require_subject: impl FnMut(&str) -> Result<()>,
|
||||
mut prepare_transfer: impl FnMut(&str) -> Result<ConfigurationPolicyTransfer>,
|
||||
) -> Result<ConfigurationAdoptionResult> {
|
||||
let fingerprint = request_fingerprint(workspace, account, request)?;
|
||||
self.with_immediate_transaction(|tx| {
|
||||
require_owner(tx,workspace,account)?;
|
||||
if let Some((saved,result)) = receipt(tx,workspace,&request.operation_id)? {
|
||||
if saved != fingerprint { return Err(conflict("operation ID reused with another request")); }
|
||||
return Ok(result);
|
||||
}
|
||||
let (_,state,source) = prepare(tx,workspace,request,schema,host,baseline,&mut require_subject)?;
|
||||
let authority_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |r|r.get(0))?;
|
||||
if authority_exists && (baseline.is_some() || request.expected_legacy_export.is_some()) {
|
||||
return Err(conflict("legacy transfer is already adopted; reload current source"));
|
||||
}
|
||||
if !authority_exists && request.expected_legacy_export.is_none() {
|
||||
return Err(conflict("legacy export identity must be observed before transfer"));
|
||||
}
|
||||
let mut current_policies = if authority_exists { policies(tx,workspace)? } else {
|
||||
let transfer = prepare_transfer(&fingerprint)?;
|
||||
if transfer.transfer_identity != fingerprint || request.expected_legacy_export.as_deref() != Some(transfer.export_digest.as_str()) {
|
||||
return Err(conflict("transfer does not bind the complete adoption request and export"));
|
||||
}
|
||||
tx.execute("INSERT INTO workspace_configuration_authorities VALUES(?1,?2,?3,?4)",params![workspace,transfer.transfer_identity,transfer.physical_identity,transfer.export_digest])?;
|
||||
transfer.policies
|
||||
};
|
||||
// Client-selected operation IDs are not policy change identities: reusing
|
||||
// an old legacy change ID must never make a stale child grant current.
|
||||
policy_config::apply(&mut current_policies,&source,&format!("configuration:{fingerprint}"));
|
||||
crate::config_source::sync_adopted_config_references(tx, workspace, &state)?;
|
||||
let authority = OwnerScope(workspace);
|
||||
let content = ScopedStore::new(tx,scope(workspace),&authority);
|
||||
let prepared = content_history_adapters::settings::prepare(&state.snapshot).map_err(content_error)?;
|
||||
for (id,object) in &prepared.objects { content.put(*id,&object.encode()).map_err(content_error)?; }
|
||||
tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id=?1",[workspace])?;
|
||||
crate::config_source::insert_materialized_state(tx,workspace,&state,&chrono::Utc::now().to_rfc3339())?;
|
||||
tx.execute("DELETE FROM workspace_configuration_policies WHERE workspace_id=?1",[workspace])?;
|
||||
for (subject,policy) in ¤t_policies {
|
||||
tx.execute("INSERT INTO workspace_configuration_policies VALUES(?1,?2,?3)", params![workspace,subject,json(policy)?])?;
|
||||
}
|
||||
let new_head = content.adopt(prepared.root,head(tx,workspace)?,account.into(),chrono::Utc::now().timestamp_micros()).map_err(content_error)?;
|
||||
let result = ConfigurationAdoptionResult {
|
||||
workspace_id: workspace.into(), operation_id: request.operation_id.clone(),
|
||||
history_head: new_head.to_string(), state, source_stored: true, policy_committed: true,
|
||||
runtime_application: RuntimePolicyApplication::NextCurrentPolicyCheck,
|
||||
};
|
||||
tx.execute("INSERT INTO workspace_configuration_receipts VALUES(?1,?2,?3,?4)",params![workspace,request.operation_id,fingerprint,json(&result)?])?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn adopted_subject_policy(
|
||||
&self,
|
||||
workspace: &str,
|
||||
subject: &str,
|
||||
) -> Result<Option<AdoptedSubjectPolicy>> {
|
||||
self.with_conn(|conn| {
|
||||
let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?;
|
||||
if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); }
|
||||
let raw: Option<String> = conn.query_row("SELECT policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 AND subject_id=?2",params![workspace,subject],|r|r.get(0)).optional()?;
|
||||
raw.map(|raw|decode(&raw)).transpose()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
schema: WorkspaceConfigSchemaBundle,
|
||||
host: &PermissionHost,
|
||||
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
|
||||
require_subject: &mut impl FnMut(&str) -> Result<()>,
|
||||
) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> {
|
||||
if let Some(baseline) = baseline {
|
||||
if baseline.diagnostics.iter().any(|diagnostic| {
|
||||
matches!(
|
||||
diagnostic,
|
||||
policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. }
|
||||
)
|
||||
}) {
|
||||
return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into()));
|
||||
}
|
||||
}
|
||||
if host.catalog().workspace.as_str() != workspace {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"permission registry belongs to another Workspace".into(),
|
||||
));
|
||||
}
|
||||
if catalog_digest(host)? != request.expected_catalog_digest {
|
||||
return Err(conflict("Feature catalog changed"));
|
||||
}
|
||||
if head(tx, workspace)?.map(|id| id.to_string()) != request.expected_head {
|
||||
return Err(conflict("editing history head changed"));
|
||||
}
|
||||
let current = crate::config_source::load_state(tx, workspace)?
|
||||
.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?;
|
||||
let editing_base = if let Some(baseline) = baseline {
|
||||
if *baseline.original != current
|
||||
|| request.expected_legacy_export.as_deref() != Some(baseline.export_digest)
|
||||
{
|
||||
return Err(conflict("migration source or export changed"));
|
||||
}
|
||||
baseline.state
|
||||
} else {
|
||||
¤t
|
||||
};
|
||||
if editing_base.snapshot.digest != request.source.base_digest {
|
||||
return Err(conflict("source content changed"));
|
||||
}
|
||||
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
|
||||
let candidate = crate::config_source::evaluate_candidate_in_transaction(
|
||||
tx,
|
||||
workspace,
|
||||
editing_base.clone(),
|
||||
crate::config_source::load_evaluation_identity(tx, workspace)?,
|
||||
&request.source.changes,
|
||||
schema,
|
||||
)?;
|
||||
crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
|
||||
let source = policy_config::source(&candidate.evaluation)?;
|
||||
policy_config::validate_source(host, &source, require_subject)?;
|
||||
let state = WorkspaceConfigState {
|
||||
snapshot: candidate.snapshot,
|
||||
contract: candidate.contract,
|
||||
projection_digest: candidate.evaluation.projection_digest,
|
||||
};
|
||||
Ok((current, state, source))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_adoption_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE workspace_configuration_authorities (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
transfer_identity TEXT NOT NULL,
|
||||
legacy_physical_identity TEXT NOT NULL,
|
||||
legacy_export_digest TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE workspace_configuration_policies (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
|
||||
subject_id TEXT NOT NULL,
|
||||
policy_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, subject_id)
|
||||
);
|
||||
CREATE TABLE workspace_configuration_receipts (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, operation_id)
|
||||
);
|
||||
CREATE TRIGGER workspace_configuration_authorities_no_update BEFORE UPDATE ON workspace_configuration_authorities BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration authority transfers are immutable');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_authorities_no_delete BEFORE DELETE ON workspace_configuration_authorities BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration authority transfers are retained');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_receipts_no_update BEFORE UPDATE ON workspace_configuration_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration adoption receipts are immutable');
|
||||
END;
|
||||
CREATE TRIGGER workspace_configuration_receipts_no_delete BEFORE DELETE ON workspace_configuration_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'configuration adoption receipts are retained');
|
||||
END;
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Adoption component tests use the real Server SQLite transaction and content
|
||||
//! store. The legacy transfer callback is empty in this fixture; physical Feature
|
||||
//! transfer and authenticated HTTP are separate integration coverage.
|
||||
use super::*;
|
||||
use crate::config_source::{MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider};
|
||||
use crate::policy_config::PermissionConfigSchemaProvider;
|
||||
use config_source::{ConfigTreeChange, VirtualPath};
|
||||
use feature_permissions::*;
|
||||
|
||||
const WORKSPACE: &str = "adoption-ws";
|
||||
const OWNER: &str = "adoption-owner";
|
||||
|
||||
fn host(children: bool) -> PermissionHost {
|
||||
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
|
||||
let feature = host
|
||||
.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap())
|
||||
.unwrap();
|
||||
let namespace = host
|
||||
.bind_namespace(&feature, NamespaceId::parse("ticket").unwrap())
|
||||
.unwrap();
|
||||
host.declare(
|
||||
&namespace,
|
||||
NamespaceDeclaration {
|
||||
permissions: vec![
|
||||
PermissionDefinition {
|
||||
key: PermissionKey::parse("ticket.hoge").unwrap(),
|
||||
description: String::new(),
|
||||
children: if children {
|
||||
vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
},
|
||||
PermissionDefinition {
|
||||
key: PermissionKey::parse("ticket.hoge.fuga").unwrap(),
|
||||
description: String::new(),
|
||||
children: vec![],
|
||||
},
|
||||
],
|
||||
sets: vec![SetContribution {
|
||||
set: PermissionSetId::parse("ticket").unwrap(),
|
||||
children: vec![PermissionReference::Namespace(NamespacePermissions {
|
||||
namespace: NamespaceId::parse("ticket").unwrap(),
|
||||
keys: vec![PermissionKey::parse("ticket.hoge").unwrap()],
|
||||
})],
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
host
|
||||
}
|
||||
fn schema() -> WorkspaceConfigSchemaBundle {
|
||||
WorkspaceConfigSchemaBundle::compose([
|
||||
PermissionConfigSchemaProvider.contribution().unwrap(),
|
||||
crate::prompt_settings::PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
fn open(path: &std::path::Path) -> SqliteWorkspaceStore {
|
||||
SqliteWorkspaceStore::open_for_test(path).unwrap()
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = open(&dir.path().join("server.sqlite"));
|
||||
db.with_immediate_transaction(|tx| {
|
||||
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema())
|
||||
.unwrap();
|
||||
(dir, db)
|
||||
}
|
||||
fn request(
|
||||
db: &SqliteWorkspaceStore,
|
||||
host: &PermissionHost,
|
||||
id: &str,
|
||||
source: Option<&str>,
|
||||
) -> ConfigurationAdoptionRequest {
|
||||
let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap();
|
||||
let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap();
|
||||
let changes = source
|
||||
.map(|source| {
|
||||
vec![ConfigTreeChange::Update {
|
||||
path: path.clone(),
|
||||
expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(),
|
||||
content: source.into(),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ConfigurationAdoptionRequest {
|
||||
operation_id: id.into(),
|
||||
expected_legacy_export: db
|
||||
.configuration_head(WORKSPACE, OWNER)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
.then(|| "fixture-empty".into()),
|
||||
expected_head: db.configuration_head(WORKSPACE, OWNER).unwrap(),
|
||||
expected_catalog_digest: catalog_digest(host).unwrap(),
|
||||
source: ConfigCommitRequest {
|
||||
base_digest: current.snapshot.digest,
|
||||
changes,
|
||||
entrypoints: vec![path],
|
||||
},
|
||||
}
|
||||
}
|
||||
fn adopt(
|
||||
db: &SqliteWorkspaceStore,
|
||||
host: &PermissionHost,
|
||||
request: &ConfigurationAdoptionRequest,
|
||||
) -> Result<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_operation_id_cannot_reuse_a_previous_policy_change_identity() {
|
||||
let (_dir, db) = fixture();
|
||||
let host = host(false);
|
||||
adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
|
||||
let before = db
|
||||
.adopted_subject_policy(WORKSPACE, "subject-a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let change = request(&db, &host, &before.change_id, Some(B));
|
||||
adopt(&db, &host, &change).unwrap();
|
||||
let after = db
|
||||
.adopted_subject_policy(WORKSPACE, "subject-a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(after.change_id, before.change_id);
|
||||
assert_ne!(after.source, before.source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_database_connections_serialize_concurrent_web_and_cli_heads() {
|
||||
let (dir, db) = fixture();
|
||||
let registry = host(false);
|
||||
adopt(&db, ®istry, &request(&db, ®istry, "initial", Some(A))).unwrap();
|
||||
let other = open(&dir.path().join("server.sqlite"));
|
||||
let web = request(&db, ®istry, "web-edit", Some(B));
|
||||
let cli = request(
|
||||
&other,
|
||||
®istry,
|
||||
"cli-edit",
|
||||
Some("{} as WorkspaceConfigSchema"),
|
||||
);
|
||||
assert_eq!(web.expected_head, cli.expected_head);
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
let (web, cli) = std::thread::scope(|scope| {
|
||||
let web = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
adopt(&db, &host(false), &web)
|
||||
});
|
||||
let cli = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
adopt(&other, &host(false), &cli)
|
||||
});
|
||||
(web.join().unwrap(), cli.join().unwrap())
|
||||
});
|
||||
let (winner, loser) = match (web, cli) {
|
||||
(Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser),
|
||||
results => panic!("exactly one observed head may adopt: {results:?}"),
|
||||
};
|
||||
assert!(matches!(loser, Error::WorkspaceConfigConflict(_)));
|
||||
assert_eq!(
|
||||
db.load_workspace_config(WORKSPACE).unwrap(),
|
||||
Some(winner.state)
|
||||
);
|
||||
assert_eq!(
|
||||
db.configuration_head(WORKSPACE, OWNER).unwrap(),
|
||||
Some(winner.history_head)
|
||||
);
|
||||
assert_eq!(
|
||||
other
|
||||
.configuration_current(WORKSPACE, OWNER)
|
||||
.unwrap()
|
||||
.history_head,
|
||||
db.configuration_head(WORKSPACE, OWNER).unwrap()
|
||||
);
|
||||
}
|
||||
@@ -183,49 +183,56 @@ impl SqliteWorkspaceStore {
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<WorkspaceConfigState> {
|
||||
let desired_schema = schema_bundle.clone();
|
||||
let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let workspace_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||
[workspace_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !workspace_exists {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
let stored_decodal_version = tx
|
||||
let (state, requires_toolchain_refresh, base_identity, canonical) =
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let workspace_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||
[workspace_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !workspace_exists {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
let stored_decodal_version = tx
|
||||
.query_row(
|
||||
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
|
||||
[workspace_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
let requires_toolchain_refresh = stored_decodal_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != DECODAL_VERSION);
|
||||
let state = match load_state(&tx, workspace_id)? {
|
||||
Some(state) => state,
|
||||
None => {
|
||||
let mut state = initial_state_with_schema(schema_bundle.clone())?;
|
||||
if has_plugin_provider(&schema_bundle) {
|
||||
let (contract, evaluation, _) =
|
||||
crate::plugin_settings::evaluate_catalog_config(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&state.snapshot,
|
||||
schema_bundle.clone(),
|
||||
)?;
|
||||
state.contract = contract;
|
||||
state.projection_digest = evaluation.projection_digest;
|
||||
let requires_toolchain_refresh = stored_decodal_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != DECODAL_VERSION);
|
||||
let state = match load_state(&tx, workspace_id)? {
|
||||
Some(state) => state,
|
||||
None => {
|
||||
let mut state = initial_state_with_schema(schema_bundle.clone())?;
|
||||
if has_plugin_provider(&schema_bundle) {
|
||||
let (contract, evaluation, _) =
|
||||
crate::plugin_settings::evaluate_catalog_config(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&state.snapshot,
|
||||
schema_bundle.clone(),
|
||||
)?;
|
||||
state.contract = contract;
|
||||
state.projection_digest = evaluation.projection_digest;
|
||||
}
|
||||
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
||||
state
|
||||
}
|
||||
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
||||
state
|
||||
}
|
||||
};
|
||||
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
tx.commit()?;
|
||||
Ok((state, requires_toolchain_refresh, base_identity))
|
||||
})?;
|
||||
};
|
||||
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
let canonical = crate::config_adoption::canonical(&tx, workspace_id)?;
|
||||
tx.commit()?;
|
||||
Ok((state, requires_toolchain_refresh, base_identity, canonical))
|
||||
})?;
|
||||
// Once adopted, schema changes must be previewed/adopted by the owner,
|
||||
// not persisted as a second startup writer outside the policy/history tx.
|
||||
if canonical {
|
||||
return Ok(state);
|
||||
}
|
||||
let desired_schema = crate::plugin_settings::materialization_schema(
|
||||
desired_schema,
|
||||
&state.contract.schema_bundle,
|
||||
@@ -340,6 +347,9 @@ impl SqliteWorkspaceStore {
|
||||
) -> Result<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 +500,7 @@ fn format_candidate_sources(
|
||||
}
|
||||
|
||||
// Read persisted provenance, not load_state's upgraded in-memory contract.
|
||||
fn load_evaluation_identity(
|
||||
pub(crate) fn load_evaluation_identity(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
) -> Result<(String, String)> {
|
||||
@@ -509,7 +519,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
|
||||
.any(|provider| provider.provider_id == "builtin:plugins")
|
||||
}
|
||||
|
||||
fn evaluate_candidate_in_transaction(
|
||||
pub(crate) fn evaluate_candidate_in_transaction(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
current: WorkspaceConfigState,
|
||||
@@ -566,6 +576,28 @@ fn evaluate_candidate(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sync_adopted_config_references(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
workspace: &str,
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<()> {
|
||||
if has_plugin_provider(&state.contract.schema_bundle) {
|
||||
let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config(
|
||||
tx,
|
||||
workspace,
|
||||
&state.snapshot,
|
||||
state.contract.schema_bundle.clone(),
|
||||
)?;
|
||||
if contract != state.contract || evaluation.projection_digest != state.projection_digest {
|
||||
return Err(config_conflict(
|
||||
"Plugin catalog/schema changed during adoption",
|
||||
));
|
||||
}
|
||||
crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_state(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
@@ -792,7 +824,7 @@ fn config_not_materialized() -> Error {
|
||||
Error::RegistryInconsistency("workspace config tree is not materialized".to_string())
|
||||
}
|
||||
|
||||
fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
|
||||
pub(crate) fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
|
||||
if entrypoints == [main_config_path()] {
|
||||
Ok(())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Read-only preparation of an editable Workspace permission migration draft.
|
||||
//!
|
||||
//! The authenticated owner boundary lives in Server/core. This helper checks the
|
||||
//! guarded stores and exact Workspace attribution, but neither saves the draft nor
|
||||
//! seals the legacy writer. Adoption must check `original`, the actual editing
|
||||
//! head and export identity/digest again, and reject blocking diagnostics before
|
||||
//! sealing. A successful preview alone is not permission authority.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigTreeChange, ConfigTreeSnapshot, SnapshotEnvironment,
|
||||
ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use feature_permissions::{NamespacePermissions, PermissionHost};
|
||||
use subjektiv::{PermissionTransferSnapshot, SubjektivStore};
|
||||
|
||||
use crate::config_source::{
|
||||
ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigState,
|
||||
validate_workspace_config_candidate_projections,
|
||||
};
|
||||
use crate::policy_config::{
|
||||
self, AdoptedSubjectPolicy, AssignmentSource, MigrationDiagnostic, PermissionSource,
|
||||
};
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub(crate) struct MigrationSeed {
|
||||
pub original: WorkspaceConfigState,
|
||||
pub state: WorkspaceConfigState,
|
||||
pub snapshot: PermissionTransferSnapshot,
|
||||
pub policies: BTreeMap<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,9 @@ where
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeDiagnostic {
|
||||
/// Internal startup observations; public consumers must separately authorize projection.
|
||||
#[serde(skip)]
|
||||
pub startup: Option<runtime_api::WorkerStartupFailure>,
|
||||
pub code: String,
|
||||
pub severity: HostDiagnosticSeverity,
|
||||
pub message: String,
|
||||
@@ -6649,7 +6652,7 @@ fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
|
||||
}
|
||||
|
||||
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
||||
match error {
|
||||
let mut projection = match error {
|
||||
EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic(
|
||||
"restore_admission_busy",
|
||||
HostDiagnosticSeverity::Warning,
|
||||
@@ -6767,7 +6770,22 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Embedded Runtime state is unavailable".to_string(),
|
||||
),
|
||||
};
|
||||
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
|
||||
if result.startup_failure.is_some() {
|
||||
projection.message = "Embedded Runtime Worker startup failed".into();
|
||||
}
|
||||
projection.startup = result.startup_failure.as_ref().and_then(|primary| {
|
||||
internal_startup_transfer(runtime_api::WorkerStartupFailure {
|
||||
primary: primary.clone(),
|
||||
secondary: result.secondary_failures.clone(),
|
||||
resources: result.startup_resources,
|
||||
persistence: result.startup_failure_persistence,
|
||||
io_kind: result.startup_io_kind,
|
||||
})
|
||||
});
|
||||
}
|
||||
projection
|
||||
}
|
||||
|
||||
fn host_id_for_embedded_workspace(workspace_id: &str) -> String {
|
||||
@@ -6846,6 +6864,7 @@ fn runtime_api_diagnostic(
|
||||
) -> RuntimeDiagnostic {
|
||||
match error {
|
||||
runtime_api::client_support::ClientError::Public { status, error } => {
|
||||
let startup = error.error.startup.and_then(internal_startup_transfer);
|
||||
let remote_code = error.error.code;
|
||||
let message = sanitize_remote_runtime_message(&remote_code, &error.error.message);
|
||||
let (code, severity) = match status {
|
||||
@@ -6862,13 +6881,15 @@ fn runtime_api_diagnostic(
|
||||
},
|
||||
),
|
||||
};
|
||||
diagnostic(
|
||||
let mut projection = diagnostic(
|
||||
code,
|
||||
severity,
|
||||
format!(
|
||||
"Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}"
|
||||
),
|
||||
)
|
||||
);
|
||||
projection.startup = startup;
|
||||
projection
|
||||
}
|
||||
runtime_api::client_support::ClientError::Failure(failure) => {
|
||||
let code = match failure {
|
||||
@@ -6945,11 +6966,25 @@ fn remote_http_status_diagnostic(
|
||||
}
|
||||
_ => (remote_code, HostDiagnosticSeverity::Warning),
|
||||
};
|
||||
diagnostic(
|
||||
let mut projection = diagnostic(
|
||||
code,
|
||||
severity,
|
||||
format!("Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {remote_message}"),
|
||||
)
|
||||
);
|
||||
projection.startup = error
|
||||
.and_then(|error| error.error.startup)
|
||||
.and_then(internal_startup_transfer);
|
||||
projection
|
||||
}
|
||||
|
||||
/// Treat transferred details as untrusted observations and reproject through the
|
||||
/// same domain allowlist; HTTP status and text never supply a missing cause.
|
||||
fn internal_startup_transfer(
|
||||
mut startup: runtime_api::WorkerStartupFailure,
|
||||
) -> Option<runtime_api::WorkerStartupFailure> {
|
||||
startup.primary =
|
||||
worker::startup_failure::reproject_internal_startup_failure(&startup.primary).ok()?;
|
||||
Some(startup)
|
||||
}
|
||||
|
||||
fn diagnostic(
|
||||
@@ -6958,6 +6993,7 @@ fn diagnostic(
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
@@ -7145,6 +7181,79 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn session_registration_embedded_and_remote_host_projection_is_internal_only() {
|
||||
use protocol::session_failure::{
|
||||
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
|
||||
};
|
||||
use worker_runtime::execution::{
|
||||
WorkerExecutionOperation, WorkerExecutionOutcome, WorkerExecutionResult,
|
||||
};
|
||||
let mut result = WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn);
|
||||
result.outcome = WorkerExecutionOutcome::Errored;
|
||||
result.startup_failure = Some(
|
||||
session_registration_attachment(SessionRegistrationCause::denied(
|
||||
SessionPrincipal::Subject,
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
result.startup_failure_persistence =
|
||||
Some(protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown);
|
||||
result.secondary_failures.push(
|
||||
protocol::startup_failure::StartupSecondaryFailure::Persistence {
|
||||
phase: protocol::startup_failure::StartupFailurePhase::MetadataSave,
|
||||
outcome: protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown,
|
||||
},
|
||||
);
|
||||
let error = EmbeddedRuntimeError::WorkerExecutionRejected {
|
||||
worker_id: EmbeddedWorkerId::now_v7(),
|
||||
operation: result.operation,
|
||||
outcome: result.outcome,
|
||||
message: "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
|
||||
result,
|
||||
};
|
||||
let embedded = embedded_runtime_diagnostic(&error);
|
||||
let mut wire = runtime_api::RuntimeApiError::new(
|
||||
500,
|
||||
"worker_execution_rejected",
|
||||
"unused remote prose",
|
||||
);
|
||||
wire.error.startup = embedded.startup.clone();
|
||||
let received = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
|
||||
let remote = runtime_api_diagnostic(
|
||||
"remote",
|
||||
runtime_api::client_support::ClientError::Public {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error: received,
|
||||
},
|
||||
);
|
||||
assert!(embedded.startup.is_some());
|
||||
assert_eq!(embedded.startup, remote.startup);
|
||||
let public = serde_json::to_value(&remote).unwrap();
|
||||
assert!(
|
||||
public.get("startup").is_none(),
|
||||
"no blind public/storage attachment forwarding"
|
||||
);
|
||||
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
|
||||
assert!(!serde_json::to_string(&embedded).unwrap().contains(secret));
|
||||
}
|
||||
|
||||
let legacy = runtime_api_diagnostic(
|
||||
"remote",
|
||||
runtime_api::client_support::ClientError::Public {
|
||||
status: reqwest::StatusCode::FORBIDDEN,
|
||||
error: runtime_api::RuntimeApiError::new(403, "forbidden", "session.record denied"),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
legacy.startup.is_none(),
|
||||
"HTTP status and prose cannot attest a Session cause"
|
||||
);
|
||||
let mut corrupt = embedded.startup.unwrap();
|
||||
corrupt.primary = serde_json::from_value(json!("not an error-contract envelope")).unwrap();
|
||||
assert!(internal_startup_transfer(corrupt).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
|
||||
use crate::db_executor::{DbExecutor, DbExecutorLimits};
|
||||
|
||||
@@ -5,7 +5,11 @@ pub mod authority;
|
||||
pub mod backend_job;
|
||||
pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_adoption;
|
||||
pub mod config_source;
|
||||
mod configuration_migration;
|
||||
#[cfg(test)]
|
||||
mod configuration_transfer_tests;
|
||||
#[cfg(test)]
|
||||
mod content_history_tests;
|
||||
pub mod db_executor;
|
||||
@@ -24,6 +28,7 @@ pub mod plugin_plan;
|
||||
pub mod plugin_receipt;
|
||||
pub mod plugin_service;
|
||||
pub mod plugin_settings;
|
||||
pub mod policy_config;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
pub mod records;
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Subject assignments in the existing Workspace virtual configuration.
|
||||
//!
|
||||
//! An omitted Subject is unchanged; removal is explicit. Assignments contain only
|
||||
//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions
|
||||
//! remain separate domain constraints, never payloads on permission keys.
|
||||
//!
|
||||
//! Assigning a Subject replaces both execution and delegation assignments for
|
||||
//! that Subject. An omitted lane is empty, not its old value. `{}` is explicit
|
||||
//! empty; `revoked = true` denies that lane without altering the other lane.
|
||||
//! `remove_subjects` removes policy, not the Subject, and never restores a default.
|
||||
//! Omitting `permissions`, or omitting an individual Subject, preserves policy.
|
||||
//! Consequently deleting an assignment from source alone is not revocation.
|
||||
//!
|
||||
//! For example, when the catalog declares `filesystem.read` and `subject-a` exists:
|
||||
//!
|
||||
//! ```text
|
||||
//! {
|
||||
//! permissions = {
|
||||
//! subjects = [{
|
||||
//! subject_id = "subject-a";
|
||||
//! execution = {
|
||||
//! namespaces = [{ namespace = "filesystem"; keys = ["filesystem.read"]; }];
|
||||
//! };
|
||||
//! delegation = {};
|
||||
//! }];
|
||||
//! remove_subjects = [];
|
||||
//! };
|
||||
//! } as WorkspaceConfigSchema
|
||||
//! ```
|
||||
//!
|
||||
//! `sets` and `exclude_sets` reference Feature-owned shared definitions;
|
||||
//! `namespaces` and `exclude_namespaces` reference exact keys in their declared
|
||||
//! Namespace. This source does not redeclare Sets, infer children from dot
|
||||
//! prefixes, attach payloads/arguments to keys or issue provider capabilities.
|
||||
//! Multi-segment keys remain exact. Only catalog-declared children expand them.
|
||||
//! Unknown references are rejected even in revoked assignments, so reactivation
|
||||
//! cannot conceal invalid configuration.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use config_source::{ConfigSchemaContribution, EvaluationResult};
|
||||
use feature_permissions::{
|
||||
NamespaceId, NamespacePermissions, PermissionAssignment, PermissionDiagnostic, PermissionHost,
|
||||
PermissionKey, PermissionReference, PermissionSetId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::authorization::{ScopedPermission, SubjectPermissions};
|
||||
|
||||
use crate::config_source::WorkspaceConfigSchemaProvider;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
permissions = {
|
||||
subjects = [...{
|
||||
subject_id = String;
|
||||
execution = {
|
||||
sets = [...String] default [];
|
||||
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
exclude_sets = [...String] default [];
|
||||
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
revoked = Bool default false;
|
||||
} default {};
|
||||
delegation = {
|
||||
sets = [...String] default [];
|
||||
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
exclude_sets = [...String] default [];
|
||||
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
|
||||
revoked = Bool default false;
|
||||
} default {};
|
||||
}] default [];
|
||||
remove_subjects = [...String] default [];
|
||||
};
|
||||
}"#;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PermissionConfigSchemaProvider;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for PermissionConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
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 Ok(key) = PermissionKey::parse(operation.to_owned()) else {
|
||||
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id: subject_id.into(),
|
||||
permission: permission.clone(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let assignment = PermissionAssignment {
|
||||
grants: vec![PermissionReference::Namespace(NamespacePermissions {
|
||||
namespace: key.namespace(),
|
||||
keys: vec![key.clone()],
|
||||
})],
|
||||
exclusions: vec![],
|
||||
revoked: false,
|
||||
};
|
||||
let resolution = host.resolve(Some(&assignment));
|
||||
let exact = resolution.projection().diagnostics.is_empty()
|
||||
&& resolution.projection().allowed.len() == 1
|
||||
&& resolution.projection().allowed.contains_key(&key);
|
||||
if !exact {
|
||||
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id: subject_id.into(),
|
||||
permission: permission.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
by_namespace.entry(key.namespace()).or_default().insert(key);
|
||||
diagnostics.push(MigrationDiagnostic::DomainConstraint {
|
||||
subject_id: subject_id.into(),
|
||||
permission: permission.clone(),
|
||||
});
|
||||
}
|
||||
Ok(AssignmentSource {
|
||||
namespaces: by_namespace
|
||||
.into_iter()
|
||||
.map(|(namespace, keys)| NamespacePermissions {
|
||||
namespace,
|
||||
keys: keys.into_iter().collect(),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let execution = lane(&legacy.execution)?;
|
||||
let delegation = lane(&legacy.delegation)?;
|
||||
Ok((
|
||||
AdoptedSubjectPolicy {
|
||||
source: SubjectAssignmentSource {
|
||||
subject_id: subject_id.into(),
|
||||
execution,
|
||||
delegation,
|
||||
},
|
||||
change_id: legacy.change_id.clone(),
|
||||
legacy_constraints: Some(legacy.clone()),
|
||||
},
|
||||
diagnostics,
|
||||
))
|
||||
}
|
||||
|
||||
impl AdoptedSubjectPolicy {
|
||||
/// Read bridge for the old scoped-operation consumer. New payload-free
|
||||
/// consumers resolve `source` through the current Feature Host and also enforce
|
||||
/// retained domain constraints. This bridge cannot turn a key into an all-target
|
||||
/// legacy resource: an operation without a representable constraint is unavailable.
|
||||
pub fn legacy_read(&self, host: &PermissionHost) -> Result<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)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ mod plugin_host;
|
||||
#[path = "server_plugin_runtime.rs"]
|
||||
mod plugin_runtime;
|
||||
pub use plugin_delivery::InProcessPluginArtifactSource;
|
||||
#[path = "server_configuration_adoption.rs"]
|
||||
mod configuration_adoption;
|
||||
#[path = "server_workspace_config.rs"]
|
||||
mod workspace_config;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool {
|
||||
};
|
||||
resource == "config/source-tree"
|
||||
|| resource.starts_with("config/source-tree/")
|
||||
|| resource == "config/adoption"
|
||||
|| resource.starts_with("config/adoption/")
|
||||
|| resource == "settings/profiles"
|
||||
|| resource.starts_with("settings/profiles/")
|
||||
}
|
||||
@@ -4106,7 +4110,8 @@ fn workspace_server_router(api: WorkspaceServerApi) -> Router {
|
||||
.merge(generated_auth_contract_router(contract_service.clone()))
|
||||
.merge(generated_workspace_catalog_contract_router(
|
||||
contract_service,
|
||||
));
|
||||
))
|
||||
.merge(configuration_adoption::routes(api.clone()));
|
||||
catalog.layer(axum::middleware::from_fn_with_state(
|
||||
api,
|
||||
enforce_server_cookie_mutation_origin,
|
||||
@@ -4636,6 +4641,9 @@ impl WorkspaceApi {
|
||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
|
||||
.with_provider(Arc::new(
|
||||
crate::policy_config::PermissionConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(RuntimeConfigSchemaProvider))
|
||||
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
|
||||
.with_provider(Arc::new(skills::SkillConfigSchemaProvider))
|
||||
@@ -8612,6 +8620,7 @@ fn worker_input_result_to_api(
|
||||
// distinguishable without collapsing it into a definite refusal or replaying input.
|
||||
if result.disposition == WorkerInputDisposition::Unknown {
|
||||
result.diagnostics.push(RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "worker_input_outcome_unknown".into(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "Worker input delivery outcome is unknown; inspect the current session before retrying".into(),
|
||||
@@ -16485,22 +16494,13 @@ async fn scoped_get_workspace_config_entry(
|
||||
}
|
||||
|
||||
async fn scoped_commit_workspace_config_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<server_api::ConfigCommitRequest>,
|
||||
State(_api): State<WorkspaceApi>,
|
||||
AxumPath(_path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(_request): Json<server_api::ConfigCommitRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> {
|
||||
api.host_executors
|
||||
.host()?
|
||||
.run(move || {
|
||||
let request = config_commit_request_from_api(request)?;
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(workspace_config_state_to_api(state)),
|
||||
))
|
||||
})
|
||||
.await
|
||||
Err(Error::WorkspaceConfigConflict(
|
||||
"source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(),
|
||||
).into())
|
||||
}
|
||||
|
||||
/// Shared canonical commit and activation boundary for UI and logical config operations.
|
||||
@@ -24197,11 +24197,27 @@ where
|
||||
executor.run(move || runtime.block_on(future)).await
|
||||
}
|
||||
|
||||
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
|
||||
fn open_legacy_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
|
||||
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
|
||||
.map_err(|error| ApiError::from(Error::Store(error.to_string())))
|
||||
}
|
||||
|
||||
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
|
||||
let host = worker::feature::permissions::builtin_permission_host(
|
||||
feature_permissions::WorkspaceId::parse(api.config.workspace_id.clone())
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?,
|
||||
)
|
||||
.map_err(|error| Error::Config(error.to_string()))?;
|
||||
Ok(
|
||||
open_legacy_subjektiv_store(api)?.with_canonical_permission_source(Arc::new(
|
||||
crate::config_adoption::CanonicalConfigurationSource {
|
||||
store: api.config_store.as_ref().clone(),
|
||||
host,
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SubjektivWorkerAuthority {
|
||||
Subject,
|
||||
@@ -27020,6 +27036,7 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
|
||||
message: format!("unknown Skill `{name}`"),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "skill_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: format!("unknown Skill `{name}`"),
|
||||
@@ -30426,6 +30443,7 @@ fn prepare_workspace_workdir_create(
|
||||
.to_string(),
|
||||
),
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "default_runtime_not_configured".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Workspace default Runtime is not configured".to_string(),
|
||||
@@ -30580,6 +30598,7 @@ async fn create_workspace_working_directory(
|
||||
return Err(ApiError::with_diagnostics(
|
||||
Error::UnknownRuntime(reserved.resolved_runtime_id),
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Selected Runtime is not available".to_string(),
|
||||
@@ -30603,6 +30622,7 @@ async fn create_workspace_working_directory(
|
||||
message: "Selected Runtime is not connected".to_string(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Selected Runtime is not available".to_string(),
|
||||
@@ -30659,6 +30679,7 @@ async fn create_workspace_working_directory(
|
||||
return Err(ApiError::with_diagnostics(
|
||||
error.into_error(),
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: failure_code,
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Selected Runtime could not inspect the reserved Workdir".to_string(),
|
||||
@@ -30697,6 +30718,7 @@ async fn create_workspace_working_directory(
|
||||
return Err(ApiError::with_diagnostics(
|
||||
error.into_error(),
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: failure_code,
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Selected Runtime rejected Workdir creation".to_string(),
|
||||
@@ -30736,6 +30758,7 @@ async fn create_workspace_working_directory(
|
||||
diagnostics.insert(
|
||||
0,
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: failure_code.clone(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Runtime did not create the reserved Workdir".to_string(),
|
||||
@@ -31490,6 +31513,7 @@ fn build_runtime_cleanup_plan(
|
||||
let mut diagnostics = error.diagnostics;
|
||||
if diagnostics.is_empty() {
|
||||
diagnostics.push(RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "workspace_cleanup_runtime_observation_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: sanitize_backend_error(&error.error.to_string()),
|
||||
@@ -35599,6 +35623,7 @@ fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> A
|
||||
message: "The embedded Runtime does not accept working directories".to_string(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "embedded_worker_workdir_unsupported".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(),
|
||||
@@ -35689,6 +35714,7 @@ fn finalize_worker_spawn_stage<T>(
|
||||
.map(|assignment| assignment.operation_id.as_str())
|
||||
.unwrap_or("none");
|
||||
let mut diagnostics = vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: format!("worker_spawn_finalize_{}_failed", stage.as_str()),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
@@ -35704,6 +35730,7 @@ fn finalize_worker_spawn_stage<T>(
|
||||
diagnostics.extend(compensation_errors);
|
||||
if !compensation_failed {
|
||||
diagnostics.push(RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "worker_spawn_compensated".to_string(),
|
||||
severity: HostDiagnosticSeverity::Info,
|
||||
message: format!(
|
||||
@@ -36039,6 +36066,7 @@ fn lifecycle_failure_detail(
|
||||
|
||||
fn spawn_compensation_diagnostic(code: &str, message: String) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message,
|
||||
@@ -37044,6 +37072,7 @@ fn project_workspace_worker_with_attachments(
|
||||
summary.state = "unavailable".to_string();
|
||||
summary.worker_state = None;
|
||||
summary.diagnostics.push(RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "worker_singleton_fenced".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "This historical singleton generation is fenced by Backend ownership."
|
||||
@@ -37749,6 +37778,7 @@ fn working_directory_summaries(
|
||||
}
|
||||
if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? {
|
||||
diagnostics.push(RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "external_workdir_legacy_permissions".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: format!(
|
||||
@@ -37955,6 +37985,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> InternalWorker
|
||||
},
|
||||
workdir_attachments: Vec::new(),
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "backend_worker_missing".to_string(),
|
||||
severity: HostDiagnosticSeverity::Info,
|
||||
message:
|
||||
@@ -37999,6 +38030,7 @@ fn worker_summary_from_projection(
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "worker_observation_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Info,
|
||||
message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(),
|
||||
@@ -38645,6 +38677,7 @@ fn validate_working_directory_claims_for_browser(
|
||||
message: "working directory relative_cwd must stay inside the Runtime-owned working directory".to_string(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_relative_cwd_invalid".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "relative_cwd must be a relative path without parent traversal".to_string(),
|
||||
@@ -39239,6 +39272,7 @@ fn settings_diagnostic(
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
@@ -39260,6 +39294,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "repository_not_configured".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message,
|
||||
@@ -39277,6 +39312,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message,
|
||||
@@ -39292,6 +39328,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "repository_evidence_invalid".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message,
|
||||
@@ -39452,6 +39489,7 @@ fn workspace_id_mismatch_error() -> ApiError {
|
||||
ApiError::with_diagnostics(
|
||||
Error::WorkspaceIdMismatch,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "workspace_id_mismatch".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message,
|
||||
@@ -39561,6 +39599,7 @@ impl ApiErrorLog {
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: diagnostic.code.clone(),
|
||||
severity: match diagnostic.severity {
|
||||
server_api::DiagnosticSeverity::Info => HostDiagnosticSeverity::Info,
|
||||
@@ -39598,6 +39637,7 @@ impl From<Error> for ApiError {
|
||||
Error::DbCapacityExceeded
|
||||
| Error::DbExecutorUnavailable
|
||||
| Error::DbOperationOutcomeUnknown => vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: match &error {
|
||||
Error::DbCapacityExceeded => "db_capacity_exceeded",
|
||||
Error::DbExecutorUnavailable => "db_executor_unavailable",
|
||||
@@ -39608,21 +39648,25 @@ impl From<Error> for ApiError {
|
||||
message: error.to_string(),
|
||||
}],
|
||||
Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "workdir_session_open_failed".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: transport.message.clone(),
|
||||
}],
|
||||
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.clone(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}],
|
||||
Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.clone(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}],
|
||||
Error::Ticket(ticket_error) => vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: match ticket_error {
|
||||
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
||||
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
|
||||
@@ -39669,6 +39713,7 @@ impl From<Error> for ApiError {
|
||||
subjektiv_diagnostic_code(message)
|
||||
.map(|code| {
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
@@ -40358,6 +40403,7 @@ mod tests {
|
||||
mod workspace_admission_tests;
|
||||
mod workspace_protocol_backpressure_tests;
|
||||
include!("server_workspace_config_tests.rs");
|
||||
include!("server_configuration_adoption_tests.rs");
|
||||
use super::*;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
@@ -43074,6 +43120,7 @@ mod tests {
|
||||
worker: None,
|
||||
acceptance_evidence: Vec::new(),
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "fixture_runtime_rejected".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "fixture rejected Worker create".to_string(),
|
||||
@@ -44981,6 +45028,7 @@ mod tests {
|
||||
kind: "ticket_backend_error".to_string(),
|
||||
message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "ticket_backend_error".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "FOREIGN KEY constraint failed".to_string(),
|
||||
@@ -51633,6 +51681,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unrecoverable_pending_workspace_restore_is_typed_for_replacement() {
|
||||
let diagnostics = [RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "embedded_worker_execution_rejected".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "Restore Errored: pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config".to_string(),
|
||||
@@ -51644,6 +51693,7 @@ mod tests {
|
||||
#[test]
|
||||
fn workdir_runtime_miss_uses_exact_typed_code() {
|
||||
let typed_not_found = [RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "missing".to_string(),
|
||||
@@ -51655,6 +51705,7 @@ mod tests {
|
||||
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
|
||||
|
||||
let unrelated = [RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "some_other_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "not a typed workdir miss".to_string(),
|
||||
@@ -63358,6 +63409,7 @@ mod tests {
|
||||
&api,
|
||||
deleted,
|
||||
&[RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "missing".to_string(),
|
||||
@@ -63384,6 +63436,7 @@ mod tests {
|
||||
&api,
|
||||
unknown,
|
||||
&[RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "temporarily unavailable".to_string(),
|
||||
@@ -65080,6 +65133,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_not_found".into(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "authoritatively absent".into(),
|
||||
@@ -65131,6 +65185,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_cleanup_resource_busy".into(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "busy".into(),
|
||||
@@ -65360,6 +65415,7 @@ mod tests {
|
||||
|
||||
let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found");
|
||||
let not_found = RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "missing".to_string(),
|
||||
@@ -65395,6 +65451,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Accepted,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_provider_timeout".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "timeout".to_string(),
|
||||
@@ -65511,6 +65568,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Unsupported,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_unsupported".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "unsupported".to_string(),
|
||||
@@ -65571,6 +65629,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: code.into(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "secret /private/path".into(),
|
||||
@@ -65602,6 +65661,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_cleanup_mount_present".into(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "/private/mount credentials must not escape".into(),
|
||||
@@ -65752,6 +65812,7 @@ mod tests {
|
||||
InternalWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_cleanup_permission_denied".into(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "permission denied /private/path".into(),
|
||||
@@ -65938,6 +65999,7 @@ mod tests {
|
||||
state: InternalWorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "missing".to_string(),
|
||||
@@ -65949,6 +66011,7 @@ mod tests {
|
||||
state: InternalWorkerOperationState::Rejected,
|
||||
working_directory: None,
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "working_directory_provider_timeout".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "timeout".to_string(),
|
||||
@@ -67641,11 +67704,13 @@ mod tests {
|
||||
fn provider_rejection_classification_prefers_stable_error_diagnostic() {
|
||||
let diagnostics = vec![
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_capacity_warning".to_string(),
|
||||
severity: HostDiagnosticSeverity::Warning,
|
||||
message: "capacity is low".to_string(),
|
||||
},
|
||||
RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_capacity_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "capacity is exhausted".to_string(),
|
||||
@@ -67705,6 +67770,7 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
let diagnostics = vec![RuntimeDiagnostic {
|
||||
startup: None,
|
||||
code: "runtime_capacity_unavailable".to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: "capacity is exhausted".to_string(),
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! Focused T840 connection: real Subject policy -> signed Session POST -> real
|
||||
//! ProfileRuntimeWorkerFactory startup -> RuntimeHTTP/Host diagnostic -> saved
|
||||
//! Worker metadata. Only the unused external model is replaced. No restore,
|
||||
//! operation replay, singleton-release, or Browser rendering contract is asserted.
|
||||
use super::*;
|
||||
use protocol::session_failure::{
|
||||
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
|
||||
};
|
||||
use session_store::WorkerMetadataStore as _;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use worker_runtime::worker_backend::RuntimeWorkerFactory as _;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct UnusedModel(Arc<AtomicUsize>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl agen::llm_client::LlmClient for UnusedModel {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_: agen::llm_client::Request,
|
||||
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||
{
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
panic!("Session registration does not require a model turn")
|
||||
}
|
||||
}
|
||||
|
||||
struct RegistrationFixture {
|
||||
connected: ConnectedWorkerHarness,
|
||||
subject: String,
|
||||
callbacks: Arc<Mutex<Vec<(StatusCode, Value)>>>,
|
||||
model: UnusedModel,
|
||||
}
|
||||
|
||||
impl RegistrationFixture {
|
||||
async fn new(revoke_on_registration: bool) -> Self {
|
||||
// Armed only after real Subject creation with its normal default policy.
|
||||
// The callback is a deterministic synchronization point, not a fake Host
|
||||
// rejection: the original signed request and real handler remain intact.
|
||||
let revoke = Arc::new(Mutex::new(None::<(WorkspaceApi, String, PathBuf)>));
|
||||
let hook = revoke.clone();
|
||||
let callbacks = Arc::new(Mutex::new(Vec::new()));
|
||||
let observed = callbacks.clone();
|
||||
let model = UnusedModel::default();
|
||||
let connected = ConnectedWorkerHarness::start_with_layer(model.clone(), move |router| {
|
||||
router.layer(axum::middleware::from_fn(
|
||||
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
||||
let hook = hook.clone();
|
||||
let observed = observed.clone();
|
||||
async move {
|
||||
if request.method() != axum::http::Method::POST
|
||||
|| request.uri().path()
|
||||
!= format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions")
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
assert!(request.headers().contains_key(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER
|
||||
));
|
||||
let armed = hook.lock().unwrap().take();
|
||||
let pin_root = armed.as_ref().map(|(_, _, root)| root.clone());
|
||||
if let Some((api, subject, _)) = armed {
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let policy = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
assert!(
|
||||
policy
|
||||
.execution
|
||||
.iter()
|
||||
.any(|p| p.operation == "session.record")
|
||||
);
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "session-registration-revoke-at-callback"
|
||||
.into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: policy
|
||||
.execution
|
||||
.into_iter()
|
||||
.filter(|p| p.operation != "session.record")
|
||||
.collect(),
|
||||
delegation: policy.delegation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let (parts, body) = request.into_parts();
|
||||
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
|
||||
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if let Some(root) = pin_root {
|
||||
let request: server_api::SubjektivRecordSessionRequest = serde_json::from_slice(&bytes).unwrap();
|
||||
let session = request.session_id.parse().unwrap();
|
||||
let retention = worker_runtime::retention::runtime_session_retention(&root);
|
||||
retention.pin(session, session_store::retention::SessionRetentionPin(uuid::Uuid::now_v7()), || {
|
||||
let entries = std::fs::read_dir(root.join("workers"))?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = format!("worker-runtime-{}", entry.file_name().to_string_lossy());
|
||||
let store = session_store::WorkerAggregateStore::open_read_only(&entry.path(), &name)
|
||||
.map_err(|error| session_store::StoreError::Io(std::io::Error::other(error)))?;
|
||||
if store.read_by_name(&name).unwrap().and_then(|m| m.active).is_some_and(|a| a.session_id == session) { return Ok(true); }
|
||||
}
|
||||
Ok(false)
|
||||
}).unwrap();
|
||||
}
|
||||
let response = next
|
||||
.run(Request::from_parts(parts, Body::from(bytes)))
|
||||
.await;
|
||||
observed.lock().unwrap().push((response.status(), input));
|
||||
response
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
.await;
|
||||
let subject = open_subjektiv_store(&connected.api)
|
||||
.unwrap()
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new("session-registration-connected").unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.id;
|
||||
let initial = open_subjektiv_store(&connected.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
initial
|
||||
.execution
|
||||
.iter()
|
||||
.any(|p| p.operation == "session.record")
|
||||
);
|
||||
if revoke_on_registration {
|
||||
*revoke.lock().unwrap() = Some((
|
||||
connected.api.clone(),
|
||||
subject.clone(),
|
||||
connected.runtime_root.clone(),
|
||||
));
|
||||
}
|
||||
Self {
|
||||
connected,
|
||||
subject,
|
||||
callbacks,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&self) -> CreateWorkspaceWorkerRequest {
|
||||
CreateWorkspaceWorkerRequest {
|
||||
runtime_id: CONNECTED_RUNTIME.into(),
|
||||
display_name: "Connected Session registration".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
|
||||
subject_id: self.subject.clone(),
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_callback(&self, expected: StatusCode) -> String {
|
||||
let callbacks = self.callbacks.lock().unwrap();
|
||||
assert!(!callbacks.is_empty(), "real signed startup callback");
|
||||
assert!(callbacks.iter().all(|(status, body)| *status == expected
|
||||
&& body["session_id"] == callbacks[0].1["session_id"]));
|
||||
let (status, input) = &callbacks[0];
|
||||
assert_eq!(*status, expected);
|
||||
assert_eq!(input["create_if_missing"], true);
|
||||
assert_eq!(self.model.0.load(Ordering::SeqCst), 0);
|
||||
input["session_id"].as_str().unwrap().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
// This deliberately inspects only persisted serde shape, not rendered error text
|
||||
// or an error-contract dependency. The expected value is a typed live attachment.
|
||||
fn contains_saved_value(saved: &Value, expected: &Value) -> bool {
|
||||
saved == expected
|
||||
|| match saved {
|
||||
Value::Object(fields) => fields
|
||||
.values()
|
||||
.any(|value| contains_saved_value(value, expected)),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.any(|value| contains_saved_value(value, expected)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_metadata(root: &std::path::Path, worker: &str) -> Value {
|
||||
// Reopen the canonical metadata store, not the separate Runtime catalog.
|
||||
let name = format!("worker-runtime-{worker}");
|
||||
let store = session_store::WorkerAggregateStore::open_read_only(
|
||||
&root.join("workers").join(worker),
|
||||
&name,
|
||||
)
|
||||
.unwrap();
|
||||
let metadata = store.read_by_name(&name).unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
metadata.decode_startup_failure::<SessionRegistrationCause>(),
|
||||
session_store::worker_metadata::StartupFailureRead::Present(_)
|
||||
));
|
||||
serde_json::to_value(metadata).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_registration_real_judgments_separate_operator_subject_and_attribution() {
|
||||
// This normal boundary fixture uses the real router, Runtime signature,
|
||||
// authority store and attribution store; its Runtime does not run a model.
|
||||
let f = Fixture::new().await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
|
||||
let missing_session = session_store::new_session_id().to_string();
|
||||
let request = json!({"session_id": missing_session, "create_if_missing": false});
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
human_request(&f.owner, "POST", &path, Some(request.clone())),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_registration_cause(
|
||||
body,
|
||||
SessionRegistrationCause::denied(SessionPrincipal::Operator),
|
||||
);
|
||||
|
||||
let signed = |request: &Value| {
|
||||
runtime_source_request(
|
||||
&f.identity,
|
||||
Some(&f.source.worker_id),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(request).unwrap(),
|
||||
)
|
||||
};
|
||||
let (status, body) = response(&f.api, signed(&request)).await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_registration_cause(body, SessionRegistrationCause::attribution_missing());
|
||||
|
||||
let store = open_subjektiv_store(&f.api).unwrap();
|
||||
let foreign_session = session_store::new_session_id().to_string();
|
||||
store
|
||||
.record_session_attribution(
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
&f.subject,
|
||||
&f.source.runtime_id,
|
||||
"foreign-worker",
|
||||
&foreign_session,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
signed(&json!({"session_id":foreign_session, "create_if_missing":false})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_registration_cause(body, SessionRegistrationCause::binding_mismatch());
|
||||
|
||||
// The fixture removes its bootstrap policy only after the real setup POST.
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
signed(&json!({"session_id":missing_session, "create_if_missing":true})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_registration_cause(
|
||||
body,
|
||||
SessionRegistrationCause::denied(SessionPrincipal::Subject),
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.session_attribution(&missing_session)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_registration_cause(body: Value, cause: SessionRegistrationCause) {
|
||||
let error: server_api::RepositoryApiError = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(
|
||||
error.failure,
|
||||
Some(session_registration_attachment(cause).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||
async fn real_session_record_denial_survives_runtime_host_and_metadata_reopen() {
|
||||
let fixture = RegistrationFixture::new(true).await;
|
||||
// The real create preflight sees normal defaults. Revocation happens only
|
||||
// after the actual factory has issued its signed Session-registration POST.
|
||||
let error = scoped_create_workspace_worker(
|
||||
State(fixture.connected.api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
}),
|
||||
HeaderMap::new(),
|
||||
Json(fixture.request()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
fixture.assert_callback(StatusCode::FORBIDDEN);
|
||||
let store = open_subjektiv_store(&fixture.connected.api).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.subject_session_attributions(&fixture.subject)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.subject_permissions(&fixture.subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.execution
|
||||
.iter()
|
||||
.any(|p| p.operation == "session.record")
|
||||
);
|
||||
|
||||
let failures = fixture.connected.runtime_startup_failures.lock().unwrap();
|
||||
assert_eq!(
|
||||
failures.len(),
|
||||
1,
|
||||
"actual RuntimeHTTP startup error envelope"
|
||||
);
|
||||
let wire = &failures[0];
|
||||
let expected_primary = session_registration_attachment(SessionRegistrationCause::denied(
|
||||
SessionPrincipal::Subject,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(wire.primary, expected_primary);
|
||||
assert_eq!(
|
||||
wire.persistence,
|
||||
Some(protocol::startup_failure::StartupPersistenceOutcome::Persisted)
|
||||
);
|
||||
let host = error
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter_map(|d| d.startup.as_ref())
|
||||
.find(|startup| startup.primary == wire.primary)
|
||||
.expect("RemoteWorkerRuntime must preserve the typed startup primary in Host diagnostics");
|
||||
assert_eq!(host.primary, wire.primary);
|
||||
assert!(
|
||||
host.secondary.iter().any(|failure| matches!(
|
||||
failure,
|
||||
protocol::startup_failure::StartupSecondaryFailure::Persistence {
|
||||
phase: protocol::startup_failure::StartupFailurePhase::RuntimeRecordDelete,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"a real Session retention pin rejects outer Runtime deletion without hiding the primary"
|
||||
);
|
||||
let expected = serde_json::to_value(&wire.primary).unwrap();
|
||||
assert!(
|
||||
expected.is_string(),
|
||||
"the shared attachment contains a codec envelope"
|
||||
);
|
||||
|
||||
// A fresh fixture has exactly one attempted Worker. Discover its canonical
|
||||
// saved aggregate, without making any assumptions about retained ownership
|
||||
// or public catalog visibility after a failed create.
|
||||
let workers = std::fs::read_dir(fixture.connected.runtime_root.join("workers"))
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
workers.len(),
|
||||
1,
|
||||
"factory attempt must have real saved metadata"
|
||||
);
|
||||
let saved = read_metadata(&fixture.connected.runtime_root, &workers[0]);
|
||||
assert!(
|
||||
contains_saved_value(&saved, &expected),
|
||||
"saved metadata must retain the original typed Session registration denial"
|
||||
);
|
||||
let reopened = read_metadata(&fixture.connected.runtime_root, &workers[0]);
|
||||
assert_eq!(
|
||||
reopened, saved,
|
||||
"read-only reopen, not a startup operation retry"
|
||||
);
|
||||
assert!(contains_saved_value(&reopened, &expected));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||
async fn permitted_session_registration_reaches_real_factory_and_saved_confirmation() {
|
||||
let fixture = RegistrationFixture::new(false).await;
|
||||
let initial = open_subjektiv_store(&fixture.connected.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&fixture.subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let Json(created) = scoped_create_workspace_worker(
|
||||
State(fixture.connected.api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
}),
|
||||
HeaderMap::new(),
|
||||
Json(fixture.request()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = fixture.assert_callback(StatusCode::OK);
|
||||
let store = open_subjektiv_store(&fixture.connected.api).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.subject_permissions(&fixture.subject)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
initial
|
||||
);
|
||||
let attributions = store
|
||||
.subject_session_attributions(&fixture.subject)
|
||||
.unwrap();
|
||||
assert_eq!(attributions.len(), 1);
|
||||
assert_eq!(attributions[0].session_id, session_id);
|
||||
assert_eq!(attributions[0].worker_id, created.worker_id);
|
||||
assert_eq!(attributions[0].runtime_id, CONNECTED_RUNTIME);
|
||||
assert!(
|
||||
fixture
|
||||
.connected
|
||||
.runtime_startup_failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let name = format!("worker-runtime-{}", created.worker_id);
|
||||
let metadata = session_store::WorkerAggregateStore::open_read_only(
|
||||
&fixture
|
||||
.connected
|
||||
.runtime_root
|
||||
.join("workers")
|
||||
.join(&created.worker_id),
|
||||
&name,
|
||||
)
|
||||
.unwrap();
|
||||
let saved = metadata.read_by_name(&name).unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
saved.subjektiv_session_attribution,
|
||||
Some(session_store::SubjektivSessionAttributionState::Confirmed { .. })
|
||||
));
|
||||
let source = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
|
||||
fixture
|
||||
.connected
|
||||
.factory
|
||||
.retained_session_snapshot(&worker_runtime::identity::WorkerRef::try_from(&source).unwrap())
|
||||
.expect("real factory retained the permitted Worker Session");
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use super::*;
|
||||
mod chat_delegation_connected;
|
||||
#[path = "chat_tests.rs"]
|
||||
mod chat_tests;
|
||||
#[path = "session_registration_connected_tests.rs"]
|
||||
mod session_registration_connected;
|
||||
#[path = "subject_body_cleanup_connected_tests.rs"]
|
||||
mod subject_body_cleanup_connected;
|
||||
#[path = "subject_body_connected_tests.rs"]
|
||||
@@ -2737,6 +2739,7 @@ pub(super) struct ConnectedWorkerHarness {
|
||||
materializer: worker_runtime::working_directory::RuntimeGitMaterializer,
|
||||
authorizations: Arc<Mutex<Vec<(Value, StatusCode)>>>,
|
||||
runtime_errors: Arc<Mutex<Vec<String>>>,
|
||||
runtime_startup_failures: Arc<Mutex<Vec<runtime_api::WorkerStartupFailure>>>,
|
||||
_backend_server: AuthorizationCallbackServer,
|
||||
_runtime_server: ConnectedRuntimeServer,
|
||||
_dir: tempfile::TempDir,
|
||||
@@ -2942,6 +2945,8 @@ impl ConnectedWorkerHarness {
|
||||
.unwrap();
|
||||
let runtime_errors = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let captured_errors = runtime_errors.clone();
|
||||
let runtime_startup_failures = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured_startup_failures = runtime_startup_failures.clone();
|
||||
let runtime_server = ConnectedRuntimeServer::start(
|
||||
runtime_listener,
|
||||
worker_runtime::http_server::runtime_http_router_with_workspace_auth(
|
||||
@@ -2955,6 +2960,7 @@ impl ConnectedWorkerHarness {
|
||||
.layer(axum::middleware::from_fn(
|
||||
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
||||
let errors = captured_errors.clone();
|
||||
let startup_failures = captured_startup_failures.clone();
|
||||
async move {
|
||||
let response = next.run(request).await;
|
||||
if !response.status().is_client_error()
|
||||
@@ -2965,6 +2971,13 @@ impl ConnectedWorkerHarness {
|
||||
let (parts, body) = response.into_parts();
|
||||
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if let Some(startup) = body["error"].get("startup").filter(|v| !v.is_null())
|
||||
{
|
||||
startup_failures.lock().unwrap().push(
|
||||
serde_json::from_value(startup.clone())
|
||||
.expect("RuntimeHTTP structured startup failure"),
|
||||
);
|
||||
}
|
||||
errors.lock().unwrap().push(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
@@ -3045,6 +3058,7 @@ impl ConnectedWorkerHarness {
|
||||
materializer,
|
||||
authorizations,
|
||||
runtime_errors,
|
||||
runtime_startup_failures,
|
||||
_backend_server: backend_server,
|
||||
_runtime_server: runtime_server,
|
||||
_dir: dir,
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Owner-only HTTP boundary for source adoption and the one-way legacy cutover.
|
||||
//!
|
||||
//! Every preview and adoption acquires the native Feature authority guard before
|
||||
//! entering the Server transaction. Subject references are checked against current
|
||||
//! metadata/deletion fences under its IMMEDIATE transaction, not a preloaded set.
|
||||
//! Preview and canonical saves never seal: their nonmutating Feature transaction
|
||||
//! remains held through the Server operation and rolls back when the guard drops.
|
||||
//!
|
||||
//! Only migration adoption seals. Seal commits Feature evidence, not Server
|
||||
//! adoption; the outer connection/lifecycle guard remains held until Server commit
|
||||
//! or rollback, and no Feature access follows the seal. Legacy logical writers
|
||||
//! must use that same domain guard and require_unsealed. IMMEDIATE serializes gate
|
||||
//! users across connections/processes; after seal releases the SQLite write lock,
|
||||
//! the durable seal rejects legacy writers. This is not a distributed commit:
|
||||
//! Server failure can leave a pending transfer requiring the exact original retry.
|
||||
//! Runtime operations must still check current policy and Subject lifecycle.
|
||||
//! Callbacks must not reenter the Feature store or its canonical source provider.
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::config_adoption::{
|
||||
ConfigurationAdoptionRequest as AdoptionRequest, ConfigurationAdoptionResult as AdoptionResult,
|
||||
ConfigurationCurrent, ConfigurationMigrationBaseline, ConfigurationPolicyTransfer,
|
||||
request_fingerprint,
|
||||
};
|
||||
use crate::configuration_migration::{MigrationSeed, preview_migration};
|
||||
use crate::policy_config::{AdoptedSubjectPolicy, MigrationDiagnostic};
|
||||
use crate::subjektiv::{
|
||||
DatabaseIdentity, PermissionTransferReceipt, SubjektivError, SubjektivStore,
|
||||
};
|
||||
use feature_permissions::PermissionHost;
|
||||
use server_api::{
|
||||
ConfigurationAdoptionApi, ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest,
|
||||
ConfigurationMigrationDiagnostic, ConfigurationPreviewResponse, ConfigurationReconciliation,
|
||||
ConfigurationSourceResponse, ConfigurationSubjectPolicy, RepositoryApiError,
|
||||
RuntimePolicyApplication, ServerRequestContext,
|
||||
};
|
||||
|
||||
/// Mount on the Server router, under its cookie-mutation-origin middleware.
|
||||
/// These routes own admission because they bypass Workspace fallback dispatch.
|
||||
pub(super) fn routes(service: WorkspaceServerApi) -> Router {
|
||||
let context_service = ServerApiContractService::Server(service.clone());
|
||||
let service = Arc::new(service);
|
||||
Router::new()
|
||||
.merge(server_api::configuration_adoption_api_axum::configuration_source(service.clone()))
|
||||
.merge(server_api::configuration_adoption_api_axum::configuration_preview(service.clone()))
|
||||
.merge(server_api::configuration_adoption_api_axum::configuration_adopt(service.clone()))
|
||||
.merge(server_api::configuration_adoption_api_axum::configuration_reconcile(service))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
context_service,
|
||||
attach_server_request_context,
|
||||
))
|
||||
}
|
||||
|
||||
async fn owner_operation<T: Send + 'static>(
|
||||
service: &WorkspaceServerApi,
|
||||
context: ServerRequestContext,
|
||||
workspace: String,
|
||||
operation: impl FnOnce(WorkspaceApi, String) -> Result<T> + Send + 'static,
|
||||
) -> std::result::Result<T, RepositoryApiError> {
|
||||
// These owner-only routes are mounted above Workspace fallback dispatch.
|
||||
// A Runtime proof must not be ignored in favor of a shared human credential.
|
||||
if context.transport_headers.iter().any(|(name, _)| {
|
||||
name.eq_ignore_ascii_case(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
}) {
|
||||
return Err(ApiError::from(Error::WorkspacePermissionDenied(
|
||||
"Runtime source proofs cannot authorize owner configuration management".into(),
|
||||
))
|
||||
.into_repository_api_error());
|
||||
}
|
||||
let actor = subject_authorization::administration_actor(&context)
|
||||
.map_err(ApiError::into_repository_api_error)?
|
||||
.clone();
|
||||
let result: ApiResult<T> = async {
|
||||
let gate = service.admission_for_workspace(&workspace).await;
|
||||
let lease = gate.admit(false).ok_or_else(|| {
|
||||
Error::WorkspaceConfigConflict("Workspace deletion is draining requests".into())
|
||||
})?;
|
||||
crate::db_executor::with_operation_lifetime(Arc::new(lease), async {
|
||||
// Authenticate against catalog ownership before resolving an API:
|
||||
// API construction itself can load configuration and Feature stores.
|
||||
let record = service
|
||||
.store
|
||||
.get_workspace(&workspace)
|
||||
.await?
|
||||
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||
if record.owner_account_id != actor.account_id {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"configuration management requires the Workspace owner".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if record.state != "active" {
|
||||
return Err(
|
||||
Error::WorkspaceConfigConflict("Workspace is not active".into()).into(),
|
||||
);
|
||||
}
|
||||
let api = service
|
||||
.api_for_workspace(&workspace)
|
||||
.await?
|
||||
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||
require_workspace_owner(&api, &workspace, &actor, "Configuration adoption").await?;
|
||||
// The admitted lifetime survives HTTP cancellation. The synchronous
|
||||
// DB job owns native Feature guards through Server commit/rollback.
|
||||
service
|
||||
.store
|
||||
.db_executor()
|
||||
.run(move || operation(api, actor.account_id))
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
})
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
result.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
fn conflict(message: &str) -> Error {
|
||||
Error::WorkspaceConfigConflict(message.into())
|
||||
}
|
||||
|
||||
fn request_from_api(request: ConfigurationAdoptionRequest) -> Result<AdoptionRequest> {
|
||||
server_api::authorization::validate_authorization_identifier(&request.operation_id)
|
||||
.map_err(Error::InvalidInput)?;
|
||||
Ok(AdoptionRequest {
|
||||
operation_id: request.operation_id,
|
||||
expected_head: request.expected_head,
|
||||
expected_catalog_digest: request.expected_catalog_digest,
|
||||
expected_legacy_export: request.expected_legacy_export,
|
||||
source: config_commit_request_from_api(request.source)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn host(workspace: &str) -> Result<PermissionHost> {
|
||||
let workspace =
|
||||
feature_permissions::WorkspaceId::parse(workspace).map_err(Error::InvalidInput)?;
|
||||
worker::feature::permissions::builtin_permission_host(workspace)
|
||||
.map_err(|error| Error::Config(error.to_string()))
|
||||
}
|
||||
|
||||
fn raw_legacy(api: &WorkspaceApi) -> Result<SubjektivStore> {
|
||||
// Intentionally do not call open_subjektiv_store: the normal helper connects
|
||||
// the canonical provider. Transfer preparation must retain the raw guard.
|
||||
open_legacy_subjektiv_store(api).map_err(|error| error.error)
|
||||
}
|
||||
|
||||
fn transfer_error(error: SubjektivError) -> Error {
|
||||
match error {
|
||||
SubjektivError::PermissionTransferConflict | SubjektivError::PermissionAuthoritySealed => {
|
||||
conflict("legacy permission transfer changed or is sealed for another request")
|
||||
}
|
||||
error => subjektiv_store_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_snapshot(api: &WorkspaceApi, account: &str) -> Result<ConfigurationCurrent> {
|
||||
api.config_store
|
||||
.configuration_current(&api.config.workspace_id, account)
|
||||
}
|
||||
|
||||
fn saved_result(
|
||||
api: &WorkspaceApi,
|
||||
account: &str,
|
||||
request: &AdoptionRequest,
|
||||
) -> Result<Option<AdoptionResult>> {
|
||||
// Core authenticates the current owner and compares the complete request.
|
||||
api.config_store
|
||||
.configuration_request_result(&api.config.workspace_id, account, request)
|
||||
}
|
||||
|
||||
fn pending_transfer(
|
||||
legacy: &SubjektivStore,
|
||||
workspace: &str,
|
||||
account: &str,
|
||||
request: &AdoptionRequest,
|
||||
) -> Result<Option<PermissionTransferReceipt>> {
|
||||
let identity = legacy.database_identity().map_err(transfer_error)?;
|
||||
if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
// Read-only guarded lookup verifies native identity; lookup must never seal.
|
||||
let receipt = legacy
|
||||
.permission_transfer_receipt(&identity)
|
||||
.map_err(transfer_error)?;
|
||||
if let Some(receipt) = &receipt {
|
||||
if receipt.snapshot.identity != identity
|
||||
|| receipt.transfer_id != request_fingerprint(workspace, account, request)?
|
||||
|| request.expected_legacy_export.as_deref()
|
||||
!= Some(receipt.snapshot.export_digest.as_str())
|
||||
{
|
||||
return Err(conflict(
|
||||
"sealed transfer belongs to another adoption request",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> {
|
||||
ConfigurationMigrationBaseline {
|
||||
original: &seed.original,
|
||||
state: &seed.state,
|
||||
policies: &seed.policies,
|
||||
diagnostics: &seed.diagnostics,
|
||||
export_digest: &seed.snapshot.export_digest,
|
||||
}
|
||||
}
|
||||
|
||||
struct Preparation {
|
||||
host: PermissionHost,
|
||||
schema: ::config_source::WorkspaceConfigSchemaBundle,
|
||||
legacy: SubjektivStore,
|
||||
identity: DatabaseIdentity,
|
||||
seed: Option<MigrationSeed>,
|
||||
}
|
||||
|
||||
fn prepare(api: &WorkspaceApi, account: &str, request: &AdoptionRequest) -> Result<Preparation> {
|
||||
let current = source_snapshot(api, account)?;
|
||||
if current.canonical && request.expected_legacy_export.is_some() {
|
||||
return Err(conflict(
|
||||
"legacy transfer is already adopted; reload current source",
|
||||
));
|
||||
}
|
||||
let host = host(&api.config.workspace_id)?;
|
||||
let schema = api.config_schema_registry.compose()?;
|
||||
let legacy = raw_legacy(api)?;
|
||||
let sealed = if current.canonical {
|
||||
None
|
||||
} else {
|
||||
pending_transfer(&legacy, &api.config.workspace_id, account, request)?
|
||||
};
|
||||
let seed = if current.canonical {
|
||||
None
|
||||
} else {
|
||||
let seed = preview_migration(
|
||||
&api.config_store,
|
||||
&legacy,
|
||||
&api.config.workspace_id,
|
||||
schema.clone(),
|
||||
&host,
|
||||
)?;
|
||||
if sealed
|
||||
.as_ref()
|
||||
.is_some_and(|receipt| receipt.snapshot != seed.snapshot)
|
||||
{
|
||||
return Err(conflict(
|
||||
"sealed transfer differs from the evaluated migration snapshot",
|
||||
));
|
||||
}
|
||||
Some(seed)
|
||||
};
|
||||
// Observe identity outside the guarded Server operation. Inside the native
|
||||
// gate, use only authority methods; reopening this store would reenter it.
|
||||
let identity = match &seed {
|
||||
Some(seed) => seed.snapshot.identity.clone(),
|
||||
None => legacy.database_identity().map_err(transfer_error)?,
|
||||
};
|
||||
Ok(Preparation {
|
||||
host,
|
||||
schema,
|
||||
legacy,
|
||||
identity,
|
||||
seed,
|
||||
})
|
||||
}
|
||||
|
||||
fn policy_to_api(policy: AdoptedSubjectPolicy) -> ConfigurationSubjectPolicy {
|
||||
ConfigurationSubjectPolicy {
|
||||
subject_id: policy.source.subject_id,
|
||||
change_id: policy.change_id,
|
||||
execution: policy.source.execution.assignment(),
|
||||
delegation: policy.source.delegation.assignment(),
|
||||
legacy_constraints: policy.legacy_constraints,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_to_api(diagnostic: MigrationDiagnostic) -> ConfigurationMigrationDiagnostic {
|
||||
match diagnostic {
|
||||
MigrationDiagnostic::Converted {
|
||||
subject_id,
|
||||
operation,
|
||||
} => ConfigurationMigrationDiagnostic::Converted {
|
||||
subject_id,
|
||||
operation,
|
||||
},
|
||||
MigrationDiagnostic::DomainConstraint {
|
||||
subject_id,
|
||||
permission,
|
||||
} => ConfigurationMigrationDiagnostic::DomainConstraint {
|
||||
subject_id,
|
||||
permission,
|
||||
},
|
||||
MigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id,
|
||||
permission,
|
||||
} => ConfigurationMigrationDiagnostic::ExplicitConfigurationRequired {
|
||||
subject_id,
|
||||
permission,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt_to_api(result: AdoptionResult) -> ConfigurationAdoptionReceipt {
|
||||
ConfigurationAdoptionReceipt {
|
||||
workspace_id: result.workspace_id,
|
||||
operation_id: result.operation_id,
|
||||
history_head: result.history_head,
|
||||
state: workspace_config_state_to_api(result.state),
|
||||
source_stored: result.source_stored,
|
||||
policy_committed: result.policy_committed,
|
||||
runtime_application: match result.runtime_application {
|
||||
crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck => {
|
||||
RuntimePolicyApplication::NextCurrentPolicyCheck
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigurationAdoptionApi for WorkspaceServerApi {
|
||||
async fn configuration_source(
|
||||
&self,
|
||||
context: ServerRequestContext,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<ConfigurationSourceResponse, RepositoryApiError> {
|
||||
owner_operation(self, context, workspace_id, |api, account| {
|
||||
let current = source_snapshot(&api, &account)?;
|
||||
let host = host(&api.config.workspace_id)?;
|
||||
let catalog_digest = crate::config_adoption::catalog_digest(&host)?;
|
||||
let (state, expected_legacy_export, policies, diagnostics) = if current.canonical {
|
||||
(current.state, None, current.policies, vec![])
|
||||
} else {
|
||||
let legacy = raw_legacy(&api)?;
|
||||
let seed = preview_migration(
|
||||
&api.config_store,
|
||||
&legacy,
|
||||
&api.config.workspace_id,
|
||||
api.config_schema_registry.compose()?,
|
||||
&host,
|
||||
)?;
|
||||
let observed = source_snapshot(&api, &account)?;
|
||||
if observed.canonical
|
||||
|| observed.history_head != current.history_head
|
||||
|| observed.state != seed.original
|
||||
{
|
||||
return Err(conflict(
|
||||
"configuration changed during migration preview; reload source",
|
||||
));
|
||||
}
|
||||
(
|
||||
seed.state,
|
||||
Some(seed.snapshot.export_digest),
|
||||
seed.policies,
|
||||
seed.diagnostics,
|
||||
)
|
||||
};
|
||||
Ok(ConfigurationSourceResponse {
|
||||
state: workspace_config_state_to_api(state),
|
||||
history_head: current.history_head,
|
||||
source_stored: current.canonical,
|
||||
catalog: host.catalog(),
|
||||
catalog_digest,
|
||||
expected_legacy_export,
|
||||
policies: policies.into_values().map(policy_to_api).collect(),
|
||||
migration_diagnostics: diagnostics.into_iter().map(diagnostic_to_api).collect(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configuration_preview(
|
||||
&self,
|
||||
context: ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
) -> std::result::Result<ConfigurationPreviewResponse, RepositoryApiError> {
|
||||
owner_operation(self, context, workspace_id, move |api, account| {
|
||||
let request = request_from_api(request)?;
|
||||
// Detect operation-ID reuse even though preview itself never persists.
|
||||
saved_result(&api, &account, &request)?;
|
||||
let Preparation {
|
||||
host,
|
||||
schema,
|
||||
legacy,
|
||||
identity,
|
||||
seed,
|
||||
} = prepare(&api, &account, &request)?;
|
||||
let baseline = seed.as_ref().map(baseline);
|
||||
// Preview never seals. The native read-only IMMEDIATE transaction
|
||||
// fences metadata updates through core validation, then rolls back.
|
||||
let preview = legacy
|
||||
.with_permission_authority_guard(&identity, |authority| {
|
||||
Ok(api.config_store.preview_configuration_adoption(
|
||||
&api.config.workspace_id,
|
||||
&account,
|
||||
&request,
|
||||
schema,
|
||||
&host,
|
||||
baseline.as_ref(),
|
||||
|subject| authority.require_subject(subject).map_err(transfer_error),
|
||||
))
|
||||
})
|
||||
.map_err(transfer_error)??;
|
||||
Ok(ConfigurationPreviewResponse {
|
||||
expected_head: preview.expected_head,
|
||||
state: workspace_config_state_to_api(preview.state),
|
||||
changed_paths: preview.changed_paths,
|
||||
changed_subjects: preview.changed_subjects,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configuration_adopt(
|
||||
&self,
|
||||
context: ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
) -> std::result::Result<ConfigurationAdoptionReceipt, RepositoryApiError> {
|
||||
owner_operation(self, context, workspace_id, move |api, account| {
|
||||
let request = request_from_api(request)?;
|
||||
if let Some(result) = saved_result(&api, &account, &request)? {
|
||||
return Ok(receipt_to_api(result));
|
||||
}
|
||||
let Preparation {
|
||||
host,
|
||||
schema,
|
||||
legacy,
|
||||
identity,
|
||||
seed,
|
||||
} = prepare(&api, &account, &request)?;
|
||||
let baseline = seed.as_ref().map(baseline);
|
||||
// Feature connection + IMMEDIATE transaction always precede Server.
|
||||
// Never call store-level Feature APIs from inside this closure.
|
||||
let result = legacy
|
||||
.with_permission_authority_guard(&identity, |authority| {
|
||||
// Keep Server failures as the inner Result. Only native gate
|
||||
// acquisition/finalization failures belong to the outer lane.
|
||||
Ok(api.config_store.adopt_configuration(
|
||||
&api.config.workspace_id,
|
||||
&account,
|
||||
&request,
|
||||
schema,
|
||||
&host,
|
||||
baseline.as_ref(),
|
||||
|subject| authority.require_subject(subject).map_err(transfer_error),
|
||||
|fingerprint| {
|
||||
// Canonical saves must not seal. Their Feature transaction
|
||||
// stays nonmutating until guard-drop rollback after core.
|
||||
let seed = seed.as_ref().ok_or_else(|| {
|
||||
conflict("canonical adoption must not prepare a legacy transfer")
|
||||
})?;
|
||||
// Core completes every reference check before this final
|
||||
// Feature access. One method verifies a retained seal or
|
||||
// creates the first one; the rest is in-memory work only.
|
||||
let receipt = authority
|
||||
.seal_permission_sources(fingerprint, &seed.snapshot.export_digest)
|
||||
.map_err(transfer_error)?;
|
||||
if receipt.transfer_id != fingerprint
|
||||
|| receipt.snapshot != seed.snapshot
|
||||
{
|
||||
return Err(conflict(
|
||||
"sealed transfer differs from the evaluated migration snapshot",
|
||||
));
|
||||
}
|
||||
Ok(ConfigurationPolicyTransfer {
|
||||
transfer_identity: receipt.transfer_id,
|
||||
physical_identity: receipt.snapshot.identity.physical_id,
|
||||
export_digest: receipt.snapshot.export_digest,
|
||||
policies: seed.policies.clone(),
|
||||
})
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(transfer_error)??;
|
||||
Ok(receipt_to_api(result))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configuration_reconcile(
|
||||
&self,
|
||||
context: ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
) -> std::result::Result<ConfigurationReconciliation, RepositoryApiError> {
|
||||
owner_operation(self, context, workspace_id, move |api, account| {
|
||||
let request = request_from_api(request)?;
|
||||
if let Some(result) = saved_result(&api, &account, &request)? {
|
||||
return Ok(ConfigurationReconciliation::Adopted {
|
||||
receipt: receipt_to_api(result),
|
||||
});
|
||||
}
|
||||
// Canonical operations do not use the old transfer. A missing normal
|
||||
// save receipt is not evidence about the unrelated initial cutover.
|
||||
if source_snapshot(&api, &account)?.canonical {
|
||||
return Ok(match saved_result(&api, &account, &request)? {
|
||||
Some(result) => ConfigurationReconciliation::Adopted {
|
||||
receipt: receipt_to_api(result),
|
||||
},
|
||||
None => ConfigurationReconciliation::NotObserved,
|
||||
});
|
||||
}
|
||||
// No draft generation, validation against today's head/catalog, seal,
|
||||
// or adoption here: this is observation of the exact original input.
|
||||
let legacy = raw_legacy(&api)?;
|
||||
let pending = pending_transfer(&legacy, &api.config.workspace_id, &account, &request)?;
|
||||
// Adoption may have committed between the first lookup and Feature read.
|
||||
if let Some(result) = saved_result(&api, &account, &request)? {
|
||||
return Ok(ConfigurationReconciliation::Adopted {
|
||||
receipt: receipt_to_api(result),
|
||||
});
|
||||
}
|
||||
Ok(match pending {
|
||||
Some(receipt) => ConfigurationReconciliation::PendingTransfer {
|
||||
transfer_identity: receipt.transfer_id,
|
||||
export_digest: receipt.snapshot.export_digest,
|
||||
},
|
||||
None => ConfigurationReconciliation::NotObserved,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
// Included inside server::tests to reuse its catalog, identity and storage fixtures.
|
||||
mod configuration_adoption_http_integration {
|
||||
//! Ordinary Server library integration tests: real catalog router (including
|
||||
//! cookie-origin middleware), owner/session lookup, builtin permission catalog,
|
||||
//! migration, guarded Feature storage and Server SQLite adoption. Only unused
|
||||
//! Runtime execution is the existing deterministic fixture. Router oneshot is
|
||||
//! the HTTP boundary; these are not TCP-loss, OS-crash or store-fault tests.
|
||||
//! The separate configuration_transfer/config_adoption suites own store faults.
|
||||
use super::*;
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
|
||||
};
|
||||
use server_api::{
|
||||
ConfigCommitRequest as SourceRequest, ConfigTreeChange as Change,
|
||||
ConfigurationAdoptionReceipt as Receipt, ConfigurationAdoptionRequest as Adoption,
|
||||
ConfigurationPreviewResponse as Preview, ConfigurationReconciliation as Reconciliation,
|
||||
ConfigurationSourceResponse as Source, RuntimePolicyApplication,
|
||||
};
|
||||
|
||||
const OWNER_SUFFIX: &str = "adoption-http-owner";
|
||||
const COOKIE_TOKEN: &str = "adoption-http-browser-session";
|
||||
const MARKER: &str = "notes/adoption-http.txt";
|
||||
|
||||
struct Fixture {
|
||||
api: WorkspaceApi,
|
||||
app: Router,
|
||||
owner: String,
|
||||
foreign: String,
|
||||
owner_account: String,
|
||||
cookie: String,
|
||||
origin: String,
|
||||
subject: String,
|
||||
legacy: crate::subjektiv::SubjektivStore,
|
||||
legacy_update: SubjectPermissionsUpdate,
|
||||
legacy_policy: SubjectPermissions,
|
||||
runtime_identity: RuntimeIdentityMaterial,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(dir.path()).await;
|
||||
let owner_account = api
|
||||
.store
|
||||
.get_workspace(TEST_WORKSPACE_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.owner_account_id;
|
||||
let owner = seed_test_workspace_owner_token(api.store.as_ref(), OWNER_SUFFIX).await;
|
||||
let foreign = seed_test_api_token(api.store.as_ref(), "adoption-http-nonowner");
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash(COOKIE_TOKEN),
|
||||
session_id: "adoption-http-session-id".into(),
|
||||
user_id: format!("user-{OWNER_SUFFIX}"),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: "2099-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let AuthConfig::Passkey {
|
||||
origin,
|
||||
cookie_name,
|
||||
..
|
||||
} = &api.config.auth;
|
||||
let origin = origin.clone();
|
||||
let cookie = format!("{cookie_name}={COOKIE_TOKEN}");
|
||||
|
||||
// Open the real raw Feature store before adoption, retaining a handle
|
||||
// to verify that cutover fences already-open legacy writers too.
|
||||
let legacy = open_legacy_subjektiv_store(&api).unwrap();
|
||||
let subject = legacy
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("adoption-http").unwrap())
|
||||
.unwrap()
|
||||
.id;
|
||||
let legacy_update = SubjectPermissionsUpdate {
|
||||
operation_id: "adoption-http-existing-policy".into(),
|
||||
expected_change_id: legacy
|
||||
.subject_permissions(&subject)
|
||||
.unwrap()
|
||||
.map(|policy| policy.change_id),
|
||||
execution: vec![workspace_permission("ticket.query")],
|
||||
delegation: vec![workspace_permission("ticket.create")],
|
||||
};
|
||||
let legacy_policy = legacy
|
||||
.set_subject_permissions(&subject, &legacy_update)
|
||||
.unwrap();
|
||||
|
||||
let runtime_identity =
|
||||
RuntimeIdentityMaterial::generate("adoption-http-runtime").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &runtime_identity, "adoption-http-runtime");
|
||||
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||
// Reuse the fixture API, not a second Runtime with the same store lease.
|
||||
// Catalog ownership and credential resolution remain production code.
|
||||
catalog
|
||||
.apis
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||
catalog
|
||||
.routers
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||
let app = workspace_server_router(catalog);
|
||||
Self {
|
||||
api,
|
||||
app,
|
||||
owner,
|
||||
foreign,
|
||||
owner_account,
|
||||
cookie,
|
||||
origin,
|
||||
subject,
|
||||
legacy,
|
||||
legacy_update,
|
||||
legacy_policy,
|
||||
runtime_identity,
|
||||
_dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn path(suffix: &str) -> String {
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption{suffix}")
|
||||
}
|
||||
|
||||
fn bearer(
|
||||
&self,
|
||||
token: &str,
|
||||
method: &str,
|
||||
suffix: &str,
|
||||
body: Option<Value>,
|
||||
) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(Self::path(suffix))
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
body.map(|body| body.to_string()).unwrap_or_default(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn browser(
|
||||
&self,
|
||||
method: &str,
|
||||
suffix: &str,
|
||||
body: Option<Value>,
|
||||
origin: Option<&str>,
|
||||
) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method(method)
|
||||
.uri(Self::path(suffix))
|
||||
.header(axum::http::header::COOKIE, &self.cookie)
|
||||
.header(CONTENT_TYPE, "application/json");
|
||||
if let Some(origin) = origin {
|
||||
request = request.header(ORIGIN, origin);
|
||||
}
|
||||
request
|
||||
.body(Body::from(
|
||||
body.map(|body| body.to_string()).unwrap_or_default(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn call(&self, request: Request<Body>) -> (StatusCode, Value) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let response = self.app.clone().oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected JSON response ({status}): {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
});
|
||||
(status, body)
|
||||
})
|
||||
.await
|
||||
.expect("bounded adoption HTTP request")
|
||||
}
|
||||
|
||||
async fn owner_call(
|
||||
&self,
|
||||
suffix: &str,
|
||||
request: &Adoption,
|
||||
expected: StatusCode,
|
||||
) -> Value {
|
||||
let (status, body) = self
|
||||
.call(self.bearer(
|
||||
&self.owner,
|
||||
"POST",
|
||||
suffix,
|
||||
Some(serde_json::to_value(request).unwrap()),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(status, expected, "{suffix}: {body}");
|
||||
body
|
||||
}
|
||||
|
||||
async fn source(&self) -> Source {
|
||||
let (status, body) = self.call(self.bearer(&self.owner, "GET", "", None)).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
serde_json::from_value(body).unwrap()
|
||||
}
|
||||
|
||||
async fn commit(&self, request: &Adoption) -> Receipt {
|
||||
serde_json::from_value(self.owner_call("/commit", request, StatusCode::OK).await)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn adopt_draft(&self, id: &str) -> Receipt {
|
||||
let draft = self.source().await;
|
||||
self.commit(&request(&draft, id, vec![])).await
|
||||
}
|
||||
|
||||
// Re-read durable authorities, not cached HTTP responses. The raw export
|
||||
// API is guarded by FeatureStorage and checks its physical identity.
|
||||
fn durable(&self) -> Value {
|
||||
let current = self
|
||||
.api
|
||||
.config_store
|
||||
.configuration_current(TEST_WORKSPACE_ID, &self.owner_account)
|
||||
.unwrap();
|
||||
let identity = self.legacy.database_identity().unwrap();
|
||||
let receipts: Vec<(String, String, String)> = self.api.config_store.with_conn(|conn| {
|
||||
let mut query = conn.prepare(
|
||||
"SELECT operation_id,request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 ORDER BY operation_id",
|
||||
)?;
|
||||
let rows = query.query_map([TEST_WORKSPACE_ID], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}).unwrap();
|
||||
json!({
|
||||
"state": current.state,
|
||||
"history_head": current.history_head,
|
||||
"canonical": current.canonical,
|
||||
"policies": current.policies,
|
||||
"receipts": receipts,
|
||||
"legacy_export": self.legacy.preview_permission_transfer(&identity).unwrap(),
|
||||
"legacy_receipt": self.legacy.permission_transfer_receipt(&identity).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_legacy_writable(&self) {
|
||||
// Exact replay is accepted by an unsealed writer, but rejected after
|
||||
// cutover even on an already-open handle. It does not create a change.
|
||||
assert_eq!(
|
||||
self.legacy
|
||||
.set_subject_permissions(&self.subject, &self.legacy_update)
|
||||
.unwrap(),
|
||||
self.legacy_policy
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_legacy_sealed(&self) {
|
||||
assert!(matches!(
|
||||
self.legacy
|
||||
.set_subject_permissions(&self.subject, &self.legacy_update),
|
||||
Err(crate::subjektiv::SubjektivError::PermissionAuthoritySealed),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn core_request(request: &Adoption) -> crate::config_adoption::ConfigurationAdoptionRequest {
|
||||
crate::config_adoption::ConfigurationAdoptionRequest {
|
||||
operation_id: request.operation_id.clone(),
|
||||
expected_head: request.expected_head.clone(),
|
||||
expected_catalog_digest: request.expected_catalog_digest.clone(),
|
||||
expected_legacy_export: request.expected_legacy_export.clone(),
|
||||
source: config_commit_request_from_api(request.source.clone()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assignment(operation: &str) -> feature_permissions::PermissionAssignment {
|
||||
feature_permissions::PermissionAssignment {
|
||||
grants: vec![feature_permissions::PermissionReference::Namespace(
|
||||
feature_permissions::NamespacePermissions {
|
||||
namespace: feature_permissions::NamespaceId::parse("ticket").unwrap(),
|
||||
keys: vec![feature_permissions::PermissionKey::parse(operation).unwrap()],
|
||||
},
|
||||
)],
|
||||
exclusions: vec![],
|
||||
revoked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_permission(operation: &str) -> ScopedPermission {
|
||||
ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn request(source: &Source, id: &str, changes: Vec<Change>) -> Adoption {
|
||||
Adoption {
|
||||
operation_id: id.into(),
|
||||
expected_head: source.history_head.clone(),
|
||||
expected_catalog_digest: source.catalog_digest.clone(),
|
||||
expected_legacy_export: source.expected_legacy_export.clone(),
|
||||
source: SourceRequest {
|
||||
base_digest: source.state.snapshot.digest.clone(),
|
||||
changes,
|
||||
entrypoints: source.state.contract.entrypoints.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update(source: &Source, path: &str, content: &str) -> Change {
|
||||
Change::Update {
|
||||
path: path.into(),
|
||||
expected_digest: source.state.snapshot.entries[path].content_digest.clone(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_marker(content: &str) -> Change {
|
||||
Change::Create {
|
||||
path: MARKER.into(),
|
||||
content_type: server_api::ConfigContentType::Text,
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_path(source: &Source) -> String {
|
||||
source
|
||||
.state
|
||||
.snapshot
|
||||
.entries
|
||||
.keys()
|
||||
.find(|path| path.starts_with("permissions/migrated") && path.ends_with(".dcdl"))
|
||||
.expect("migration generated editable permission source")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn permission_source(subject: &str, execution: &str, delegation: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
subjects = [{{
|
||||
subject_id = {subject:?};
|
||||
execution = {{ namespaces = [{{ namespace = "ticket"; keys = [{execution:?}]; }}]; }};
|
||||
delegation = {{ namespaces = [{{ namespace = "ticket"; keys = [{delegation:?}]; }}]; }};
|
||||
}}];
|
||||
remove_subjects = [];
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() {
|
||||
let fixture = Fixture::new().await;
|
||||
let before = fixture.durable();
|
||||
let draft = fixture.source().await;
|
||||
assert!(!draft.source_stored);
|
||||
assert_eq!(draft.history_head, None);
|
||||
assert!(draft.expected_legacy_export.is_some());
|
||||
assert_ne!(
|
||||
draft.state.snapshot.digest,
|
||||
before["state"]["snapshot"]["digest"]
|
||||
);
|
||||
assert_eq!(draft.catalog.workspace.as_str(), TEST_WORKSPACE_ID);
|
||||
assert!(draft.catalog.namespaces.iter().any(|namespace| {
|
||||
namespace
|
||||
.declaration
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|permission| permission.key.as_str() == "ticket.query")
|
||||
}));
|
||||
assert_eq!(draft.policies.len(), 1);
|
||||
assert_eq!(draft.policies[0].change_id, fixture.legacy_policy.change_id);
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
before,
|
||||
"GET is a read, not a save or seal"
|
||||
);
|
||||
fixture.assert_legacy_writable();
|
||||
|
||||
let path = permission_path(&draft);
|
||||
let edited = format!(
|
||||
"{}\n# Owner edited the migration draft before first adoption.\n",
|
||||
draft.state.snapshot.entries[&path].content
|
||||
);
|
||||
let adoption = request(
|
||||
&draft,
|
||||
"edited-migration-draft",
|
||||
vec![update(&draft, &path, &edited)],
|
||||
);
|
||||
let preview: Preview = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/preview", &adoption, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(preview.expected_head, draft.history_head);
|
||||
assert!(preview.changed_paths.contains(&path));
|
||||
assert!(
|
||||
preview.state.snapshot.entries[&path]
|
||||
.content
|
||||
.contains("# Owner edited the migration draft before first adoption.")
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
before,
|
||||
"preview must not seal or persist the draft"
|
||||
);
|
||||
fixture.assert_legacy_writable();
|
||||
|
||||
// Exercise the real Browser session and configured Origin on commit.
|
||||
let (status, wire) = fixture
|
||||
.call(fixture.browser(
|
||||
"POST",
|
||||
"/commit",
|
||||
Some(serde_json::to_value(&adoption).unwrap()),
|
||||
Some(&fixture.origin),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{wire}");
|
||||
assert_eq!(wire["runtime_application"], "next_current_policy_check");
|
||||
let receipt: Receipt = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(
|
||||
receipt.runtime_application,
|
||||
RuntimePolicyApplication::NextCurrentPolicyCheck
|
||||
);
|
||||
assert!(receipt.source_stored && receipt.policy_committed);
|
||||
assert_eq!(receipt.workspace_id, TEST_WORKSPACE_ID);
|
||||
assert_eq!(receipt.operation_id, adoption.operation_id);
|
||||
assert_eq!(receipt.state, preview.state);
|
||||
let stored = fixture.source().await;
|
||||
assert!(stored.source_stored);
|
||||
assert_eq!(stored.expected_legacy_export, None);
|
||||
assert_eq!(stored.state, receipt.state);
|
||||
assert_eq!(
|
||||
stored.history_head.as_deref(),
|
||||
Some(receipt.history_head.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
stored.policies, draft.policies,
|
||||
"source-only edit preserves policy and change IDs"
|
||||
);
|
||||
let current = fixture
|
||||
.api
|
||||
.config_store
|
||||
.configuration_current(TEST_WORKSPACE_ID, &fixture.owner_account)
|
||||
.unwrap();
|
||||
assert!(current.canonical);
|
||||
assert_eq!(workspace_config_state_to_api(current.state), receipt.state);
|
||||
assert_eq!(
|
||||
open_subjektiv_store(&fixture.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&fixture.subject)
|
||||
.unwrap(),
|
||||
Some(fixture.legacy_policy.clone())
|
||||
);
|
||||
let identity = fixture.legacy.database_identity().unwrap();
|
||||
let transfer = fixture
|
||||
.legacy
|
||||
.permission_transfer_receipt(&identity)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Some(transfer.snapshot.export_digest),
|
||||
draft.expected_legacy_export
|
||||
);
|
||||
assert_eq!(transfer.snapshot.identity, identity);
|
||||
let saved = fixture
|
||||
.api
|
||||
.config_store
|
||||
.configuration_request_result(
|
||||
TEST_WORKSPACE_ID,
|
||||
&fixture.owner_account,
|
||||
&core_request(&adoption),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.history_head, receipt.history_head);
|
||||
assert_eq!(workspace_config_state_to_api(saved.state), receipt.state);
|
||||
assert_eq!(
|
||||
saved.runtime_application,
|
||||
crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck
|
||||
);
|
||||
assert_eq!(
|
||||
transfer.transfer_id,
|
||||
crate::config_adoption::request_fingerprint(
|
||||
TEST_WORKSPACE_ID,
|
||||
&fixture.owner_account,
|
||||
&core_request(&adoption),
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
fixture.assert_legacy_sealed();
|
||||
|
||||
// Then edit actual builtin operation assignments through the same routes.
|
||||
let changed = permission_source(&fixture.subject, "ticket.create", "ticket.query");
|
||||
let policy_edit = request(
|
||||
&stored,
|
||||
"owner-policy-edit",
|
||||
vec![update(&stored, &path, &changed)],
|
||||
);
|
||||
let preview: Preview = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/preview", &policy_edit, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(preview.changed_subjects, vec![fixture.subject.clone()]);
|
||||
let next = fixture.commit(&policy_edit).await;
|
||||
assert_eq!(next.state, preview.state);
|
||||
assert_ne!(next.history_head, receipt.history_head);
|
||||
let stored = fixture.source().await;
|
||||
assert_eq!(stored.state, next.state);
|
||||
let policy = stored
|
||||
.policies
|
||||
.iter()
|
||||
.find(|policy| policy.subject_id == fixture.subject)
|
||||
.unwrap();
|
||||
assert_eq!(policy.execution, assignment("ticket.create"));
|
||||
assert_eq!(policy.delegation, assignment("ticket.query"));
|
||||
assert_ne!(policy.change_id, fixture.legacy_policy.change_id);
|
||||
let adopted = fixture
|
||||
.api
|
||||
.config_store
|
||||
.adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(adopted.source.execution.assignment(), policy.execution);
|
||||
assert_eq!(adopted.source.delegation.assignment(), policy.delegation);
|
||||
assert_eq!(adopted.change_id, policy.change_id);
|
||||
// New payload-free keys cannot fabricate broader legacy resource scopes.
|
||||
assert_eq!(
|
||||
policy.legacy_constraints,
|
||||
Some(fixture.legacy_policy.clone())
|
||||
);
|
||||
fixture.assert_legacy_sealed();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn competing_http_requests_reject_stale_head_even_after_content_returns_to_a() {
|
||||
let fixture = Fixture::new().await;
|
||||
fixture.adopt_draft("aba-migrate").await;
|
||||
let initial = fixture.source().await;
|
||||
fixture
|
||||
.commit(&request(&initial, "aba-a", vec![create_marker("A")]))
|
||||
.await;
|
||||
let a = fixture.source().await;
|
||||
// Two independent requests are built from the same observed head.
|
||||
let stale = request(&a, "aba-losing-editor", vec![update(&a, MARKER, "loser")]);
|
||||
let b = request(&a, "aba-b", vec![update(&a, MARKER, "B")]);
|
||||
fixture.commit(&b).await;
|
||||
let before = fixture.durable();
|
||||
fixture
|
||||
.owner_call("/commit", &stale, StatusCode::CONFLICT)
|
||||
.await;
|
||||
assert_eq!(fixture.durable(), before);
|
||||
let b = fixture.source().await;
|
||||
fixture
|
||||
.commit(&request(&b, "aba-return-a", vec![update(&b, MARKER, "A")]))
|
||||
.await;
|
||||
let returned = fixture.source().await;
|
||||
assert_eq!(
|
||||
returned.state.snapshot, a.state.snapshot,
|
||||
"the content really returned to A"
|
||||
);
|
||||
assert_ne!(
|
||||
returned.history_head, a.history_head,
|
||||
"editing history did not return to A"
|
||||
);
|
||||
let before = fixture.durable();
|
||||
fixture
|
||||
.owner_call("/preview", &stale, StatusCode::CONFLICT)
|
||||
.await;
|
||||
fixture
|
||||
.owner_call("/commit", &stale, StatusCode::CONFLICT)
|
||||
.await;
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
before,
|
||||
"ABA rejection leaves source, policy and receipts intact"
|
||||
);
|
||||
let reconciled: Reconciliation = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/reconcile", &stale, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reconciled, Reconciliation::NotObserved);
|
||||
assert_eq!(fixture.durable(), before);
|
||||
fixture.assert_legacy_sealed();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_request_reconcile_recovers_received_commit_result_without_reapplying_old_policy()
|
||||
{
|
||||
let fixture = Fixture::new().await;
|
||||
let draft = fixture.source().await;
|
||||
let adoption = request(&draft, "response-lost-after-commit", vec![]);
|
||||
let before = fixture.durable();
|
||||
let missing: Reconciliation = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(missing, Reconciliation::NotObserved);
|
||||
assert_eq!(fixture.durable(), before);
|
||||
fixture.assert_legacy_writable();
|
||||
|
||||
// Receive a committed HTTP result, then model the consumer losing it.
|
||||
// Reconciliation uses the retained original request, not result-derived input.
|
||||
let received_result = fixture
|
||||
.owner_call("/commit", &adoption, StatusCode::OK)
|
||||
.await;
|
||||
let after_commit = fixture.durable();
|
||||
let reconciled: Reconciliation = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
let expected: Receipt = serde_json::from_value(received_result).unwrap();
|
||||
assert_eq!(
|
||||
reconciled,
|
||||
Reconciliation::Adopted {
|
||||
receipt: expected.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(fixture.commit(&adoption).await, expected);
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
after_commit,
|
||||
"reconcile and exact retry are not new adoptions"
|
||||
);
|
||||
|
||||
let current = fixture.source().await;
|
||||
let path = permission_path(¤t);
|
||||
// Explicit removal makes old-receipt resurrection observable.
|
||||
let removal = format!(
|
||||
"{{ subjects = []; remove_subjects = [{:?}]; }}\n",
|
||||
fixture.subject
|
||||
);
|
||||
fixture
|
||||
.commit(&request(
|
||||
¤t,
|
||||
"later-policy-removal",
|
||||
vec![update(¤t, &path, &removal)],
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.config_store
|
||||
.adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let latest = fixture.durable();
|
||||
let reconciled: Reconciliation = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reconciled,
|
||||
Reconciliation::Adopted {
|
||||
receipt: expected.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(fixture.commit(&adoption).await, expected);
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
latest,
|
||||
"historical receipt must not resurrect removed policy or head"
|
||||
);
|
||||
assert!(
|
||||
open_subjektiv_store(&fixture.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&fixture.subject)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
fixture.assert_legacy_sealed();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_rejects_same_operation_id_with_any_changed_request_fingerprint() {
|
||||
let fixture = Fixture::new().await;
|
||||
let draft = fixture.source().await;
|
||||
let adoption = request(&draft, "fingerprint-migration", vec![]);
|
||||
let receipt = fixture.commit(&adoption).await;
|
||||
let before = fixture.durable();
|
||||
let mut variants = Vec::new();
|
||||
let mut changed = adoption.clone();
|
||||
changed.expected_head = Some(receipt.history_head.clone());
|
||||
variants.push(("head", changed));
|
||||
let mut changed = adoption.clone();
|
||||
changed.expected_catalog_digest.push_str("-different");
|
||||
variants.push(("catalog", changed));
|
||||
let mut changed = adoption.clone();
|
||||
changed.expected_legacy_export = None;
|
||||
variants.push(("legacy export", changed));
|
||||
let mut changed = adoption.clone();
|
||||
changed.source.base_digest.push_str("-different");
|
||||
variants.push(("base content", changed));
|
||||
let mut changed = adoption.clone();
|
||||
changed
|
||||
.source
|
||||
.changes
|
||||
.push(create_marker("not the same request"));
|
||||
variants.push(("changes", changed));
|
||||
let mut changed = adoption.clone();
|
||||
changed.source.entrypoints.push("other.dcdl".into());
|
||||
variants.push(("entrypoints", changed));
|
||||
for (field, changed) in variants {
|
||||
fixture
|
||||
.owner_call("/reconcile", &changed, StatusCode::CONFLICT)
|
||||
.await;
|
||||
fixture
|
||||
.owner_call("/commit", &changed, StatusCode::CONFLICT)
|
||||
.await;
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
before,
|
||||
"changed {field} must not adopt or modify a receipt"
|
||||
);
|
||||
}
|
||||
let exact: Reconciliation = serde_json::from_value(
|
||||
fixture
|
||||
.owner_call("/reconcile", &adoption, StatusCode::OK)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(exact, Reconciliation::Adopted { receipt });
|
||||
assert_eq!(fixture.durable(), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_owner_and_cookie_origin_denials_leave_all_authorities_and_legacy_writer_intact() {
|
||||
let fixture = Fixture::new().await;
|
||||
for canonical in [false, true] {
|
||||
let source = fixture.source().await;
|
||||
assert_eq!(source.source_stored, canonical);
|
||||
let change = if canonical {
|
||||
update(&source, MARKER, "second authorized value")
|
||||
} else {
|
||||
create_marker("first authorized value")
|
||||
};
|
||||
let adoption = request(
|
||||
&source,
|
||||
&format!("denied-cutover-{canonical}"),
|
||||
vec![change],
|
||||
);
|
||||
let wire = serde_json::to_value(&adoption).unwrap();
|
||||
let before = fixture.durable();
|
||||
for (method, suffix) in [
|
||||
("GET", ""),
|
||||
("POST", "/preview"),
|
||||
("POST", "/commit"),
|
||||
("POST", "/reconcile"),
|
||||
] {
|
||||
let body = (method == "POST").then(|| wire.clone());
|
||||
let anonymous = Request::builder()
|
||||
.method(method)
|
||||
.uri(Fixture::path(suffix))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
body.as_ref().map(Value::to_string).unwrap_or_default(),
|
||||
))
|
||||
.unwrap();
|
||||
let runtime = runtime_source_request(
|
||||
&fixture.runtime_identity,
|
||||
None,
|
||||
method,
|
||||
&Fixture::path(suffix),
|
||||
body.as_ref()
|
||||
.map(|value| serde_json::to_vec(value).unwrap())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let mut runtime_with_owner = runtime_source_request(
|
||||
&fixture.runtime_identity,
|
||||
None,
|
||||
method,
|
||||
&Fixture::path(suffix),
|
||||
body.as_ref()
|
||||
.map(|value| serde_json::to_vec(value).unwrap())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
runtime_with_owner.headers_mut().insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {}", fixture.owner).parse().unwrap(),
|
||||
);
|
||||
for (principal, request) in [
|
||||
(
|
||||
"nonowner",
|
||||
fixture.bearer(&fixture.foreign, method, suffix, body.clone()),
|
||||
),
|
||||
("unauthenticated", anonymous),
|
||||
("runtime proof", runtime),
|
||||
// A valid owner credential must not hide the Runtime source.
|
||||
("runtime proof plus owner bearer", runtime_with_owner),
|
||||
] {
|
||||
let (status, response) = fixture.call(request).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"{principal} {method} {suffix}: {response}"
|
||||
);
|
||||
assert_eq!(fixture.durable(), before, "{principal} {suffix}");
|
||||
if canonical {
|
||||
fixture.assert_legacy_sealed();
|
||||
} else {
|
||||
fixture.assert_legacy_writable();
|
||||
}
|
||||
}
|
||||
if method == "POST" {
|
||||
for origin in [None, Some("https://attacker.invalid")] {
|
||||
let (status, response) = fixture
|
||||
.call(fixture.browser(method, suffix, body.clone(), origin))
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cookie {origin:?} {suffix}: {response}"
|
||||
);
|
||||
assert_eq!(fixture.durable(), before);
|
||||
if canonical {
|
||||
fixture.assert_legacy_sealed();
|
||||
} else {
|
||||
fixture.assert_legacy_writable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Browser GET does not require a mutation Origin, and the same
|
||||
// valid adoption remains possible after every denied request.
|
||||
let (status, response) = fixture.call(fixture.browser("GET", "", None, None)).await;
|
||||
assert_eq!(status, StatusCode::OK, "{response}");
|
||||
assert_eq!(serde_json::from_value::<Source>(response).unwrap(), source);
|
||||
fixture.commit(&adoption).await;
|
||||
fixture.assert_legacy_sealed();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_http_source_and_old_source_tree_writer_cannot_partially_adopt() {
|
||||
let fixture = Fixture::new().await;
|
||||
let draft = fixture.source().await;
|
||||
let path = permission_path(&draft);
|
||||
let before = fixture.durable();
|
||||
let invalid = [
|
||||
("syntax", "{ subjects = [".to_owned()),
|
||||
(
|
||||
"unknown-key",
|
||||
permission_source(
|
||||
&fixture.subject,
|
||||
"ticket.not_a_builtin_operation",
|
||||
"ticket.query",
|
||||
),
|
||||
),
|
||||
(
|
||||
"missing-subject",
|
||||
permission_source(
|
||||
"nonexistent-adoption-subject",
|
||||
"ticket.create",
|
||||
"ticket.query",
|
||||
),
|
||||
),
|
||||
(
|
||||
"unknown-set",
|
||||
format!(
|
||||
"{{ subjects = [{{ subject_id = {:?}; execution = {{ sets = [\"unknown-adoption-set\"]; }}; delegation = {{}}; }}]; remove_subjects = []; }}",
|
||||
fixture.subject
|
||||
),
|
||||
),
|
||||
];
|
||||
for (name, content) in invalid {
|
||||
let adoption = request(
|
||||
&draft,
|
||||
&format!("invalid-{name}"),
|
||||
vec![update(&draft, &path, &content)],
|
||||
);
|
||||
for suffix in ["/preview", "/commit"] {
|
||||
let (status, response) = fixture
|
||||
.call(fixture.bearer(
|
||||
&fixture.owner,
|
||||
"POST",
|
||||
suffix,
|
||||
Some(serde_json::to_value(&adoption).unwrap()),
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"{name} {suffix}: {status} {response}"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.durable(),
|
||||
before,
|
||||
"invalid {name} leaves no source, policy, head, receipt or seal"
|
||||
);
|
||||
fixture.assert_legacy_writable();
|
||||
}
|
||||
}
|
||||
for canonical in [false, true] {
|
||||
if canonical {
|
||||
fixture.adopt_draft("old-writer-cutover").await;
|
||||
}
|
||||
let current = fixture.source().await;
|
||||
let old = SourceRequest {
|
||||
base_digest: current.state.snapshot.digest,
|
||||
changes: vec![create_marker("old writer must not persist")],
|
||||
entrypoints: current.state.contract.entrypoints,
|
||||
};
|
||||
let before = fixture.durable();
|
||||
let response = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit"
|
||||
))
|
||||
.header("authorization", format!("Bearer {}", fixture.owner))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&old).unwrap()))
|
||||
.unwrap();
|
||||
let (status, response) = fixture.call(response).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::CONFLICT,
|
||||
"old writer canonical={canonical}: {response}"
|
||||
);
|
||||
assert_eq!(fixture.durable(), before);
|
||||
if canonical {
|
||||
fixture.assert_legacy_sealed();
|
||||
} else {
|
||||
fixture.assert_legacy_writable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,22 +619,28 @@ pub(super) async fn commit(
|
||||
{
|
||||
return Err(fail(409, "authority_changed"));
|
||||
}
|
||||
// Preserve the shared helper's ordering and atomic CAS, but never
|
||||
// run its remote Runtime publication on the sole DB worker.
|
||||
project_repository_access_candidate(
|
||||
&*persistence_api.store,
|
||||
&persistence_api.repository_secrets,
|
||||
&persistence_api.config.workspace_id,
|
||||
&candidate,
|
||||
)
|
||||
.map_err(|_| unknown())?;
|
||||
let state = persistence_api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(
|
||||
&persistence_api.config.workspace_id,
|
||||
&candidate,
|
||||
)
|
||||
.map_err(|_| unknown())?;
|
||||
// Legacy logical writes share the physical authority gate with
|
||||
// initial cutover, in Feature -> Server order. A durable seal
|
||||
// fences this writer even when the Server adoption rolled back.
|
||||
let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?;
|
||||
let identity = legacy.database_identity().map_err(|_| unknown())?;
|
||||
let state = legacy.with_permission_authority_guard(&identity, |authority| {
|
||||
authority.require_unsealed()?;
|
||||
Ok((|| {
|
||||
project_repository_access_candidate(
|
||||
&*persistence_api.store,
|
||||
&persistence_api.repository_secrets,
|
||||
&persistence_api.config.workspace_id,
|
||||
&candidate,
|
||||
).map_err(|_| unknown())?;
|
||||
persistence_api.config_store.commit_evaluated_workspace_config(
|
||||
&persistence_api.config.workspace_id, &candidate,
|
||||
).map_err(pre_error)
|
||||
})())
|
||||
}).map_err(|error| match error {
|
||||
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
|
||||
_ => unknown(),
|
||||
})??;
|
||||
let publication = persistence_api
|
||||
.prompt_projection_cache
|
||||
.resolve(&persistence_api.config.workspace_id, &state)
|
||||
|
||||
@@ -110,8 +110,13 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) {
|
||||
verify_history(conn, current).unwrap();
|
||||
if current >= 114 {
|
||||
let plan = published_plan(conn, current).unwrap().unwrap();
|
||||
assert!(plan.migrations.is_empty());
|
||||
assert_eq!(plan.target_schema_version, current);
|
||||
assert_eq!(plan.migrations.len(), 1);
|
||||
assert_eq!(plan.migrations[0].version, 122);
|
||||
assert_eq!(
|
||||
plan.migrations[0].name,
|
||||
crate::config_adoption::MIGRATION_NAME
|
||||
);
|
||||
assert_eq!(plan.target_schema_version, 122);
|
||||
}
|
||||
assert_eq!(rows, saved_rows(conn));
|
||||
assert_eq!(shape, snapshot(conn).unwrap());
|
||||
@@ -195,11 +200,30 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
// Production store owner, not a separate test migration implementation.
|
||||
super::super::prepare_connection(&conn).unwrap();
|
||||
assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122);
|
||||
for (table, rows) in &expected {
|
||||
if table != "__yoi_schema_migrations" {
|
||||
assert_eq!(
|
||||
saved_rows(&conn)
|
||||
.into_iter()
|
||||
.find(|(name, _)| name == table)
|
||||
.unwrap()
|
||||
.1,
|
||||
*rows,
|
||||
"{table}, from {current}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut history = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
assert_eq!(
|
||||
super::super::current_schema_version(&conn).unwrap(),
|
||||
current
|
||||
history.pop(),
|
||||
Some((122, crate::config_adoption::MIGRATION_NAME.into()))
|
||||
);
|
||||
assert_eq!(saved_rows(&conn), expected);
|
||||
assert_eq!(
|
||||
history,
|
||||
super::super::workspace_schema_migration_history(&source).unwrap()
|
||||
);
|
||||
verify_history(&conn, 122).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,7 +314,7 @@ fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabe
|
||||
let (_temp, conn) = retained_to(103, alternate);
|
||||
let original = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
super::super::prepare_connection(&conn).unwrap();
|
||||
verify_history(&conn, 113).unwrap();
|
||||
verify_history(&conn, super::super::LATEST_SCHEMA_VERSION).unwrap();
|
||||
let updated = super::super::workspace_schema_migration_history(&conn).unwrap();
|
||||
assert_eq!(&updated[..original.len()], &original);
|
||||
let name: String = conn
|
||||
|
||||
@@ -71,6 +71,31 @@ fn invalid(message: impl std::fmt::Display) -> Error {
|
||||
/// Every other row, gap, mixed family, unknown suffix and missing object fails.
|
||||
pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> {
|
||||
let rows = super::workspace_schema_migration_history(conn)?;
|
||||
verify_history_records(conn, current, &rows)
|
||||
}
|
||||
|
||||
fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> {
|
||||
if current == 122 {
|
||||
verify_configuration_shape(conn)?;
|
||||
if rows == [(122, super::SCHEMA_BASELINE_NAME.to_owned())] {
|
||||
require_shape(conn, Stage::Base)?;
|
||||
return verify_103_features(conn, current, rows);
|
||||
}
|
||||
if rows.last().map(|(version, name)| (*version, name.as_str()))
|
||||
!= Some((122, crate::config_adoption::MIGRATION_NAME))
|
||||
{
|
||||
return Err(invalid("unrecognized configuration adoption receipt"));
|
||||
}
|
||||
let prefix = &rows[..rows.len() - 1];
|
||||
let prior = prefix
|
||||
.last()
|
||||
.map(|row| row.0)
|
||||
.ok_or_else(|| invalid("missing configuration predecessor"))?;
|
||||
if !(113..=121).contains(&prior) {
|
||||
return Err(invalid("invalid configuration predecessor"));
|
||||
}
|
||||
return verify_history_records(conn, prior, prefix);
|
||||
}
|
||||
let Some((baseline, name)) = rows.first() else {
|
||||
return Err(invalid("missing migration receipts"));
|
||||
};
|
||||
@@ -191,11 +216,34 @@ pub(super) fn published_plan(
|
||||
verify_history(conn, current)?;
|
||||
Ok(Some(super::WorkspaceSchemaMigrationPlan {
|
||||
current_schema_version: current,
|
||||
target_schema_version: current,
|
||||
migrations: vec![],
|
||||
target_schema_version: 122,
|
||||
migrations: if current < 122 {
|
||||
vec![super::WorkspaceSchemaMigrationStep {
|
||||
version: 122,
|
||||
name: crate::config_adoption::MIGRATION_NAME.to_owned(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn verify_configuration_shape(conn: &Connection) -> Result<()> {
|
||||
let expected_db = Connection::open_in_memory()?;
|
||||
expected_db.execute_batch(content_history_store::SCHEMA)?;
|
||||
expected_db.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
let expected = snapshot(&expected_db)?;
|
||||
let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect();
|
||||
let actual: Shape = snapshot(conn)?
|
||||
.into_iter()
|
||||
.filter(|(_, object)| tables.contains(&object.1))
|
||||
.collect();
|
||||
if actual != expected {
|
||||
return Err(invalid("configuration adoption receipt/shape mismatch"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// sqlite_schema programs, not table/column presence alone. SQL literal bytes,
|
||||
// CHECK/FK clauses, indexes and trigger programs remain part of the contract.
|
||||
type Shape = BTreeMap<String, (String, String, Vec<String>)>;
|
||||
@@ -233,7 +281,7 @@ fn reference_base() -> Result<Connection> {
|
||||
merge_request::migrate(&conn).map_err(invalid)?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
super::create_latest_workspace_schema(&tx)?;
|
||||
super::create_workspace_schema_through_v113(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
#[path = "storage_formats.rs"]
|
||||
mod storage_formats;
|
||||
|
||||
const LATEST_SCHEMA_VERSION: i64 = 113;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 122;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Ticket work-decomposition parent links",
|
||||
apply: migrate_ticket_parents_v112_to_v113,
|
||||
},
|
||||
Migration {
|
||||
version: 122,
|
||||
name: crate::config_adoption::MIGRATION_NAME,
|
||||
apply: migrate_configuration_adoption_to_v122,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -16069,6 +16074,25 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if !(113..=121).contains(¤t) {
|
||||
return Err(Error::Store(
|
||||
"Configuration adoption migration requires a recognized schema 113..121".into(),
|
||||
));
|
||||
}
|
||||
storage_formats::verify_history(conn, current)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(content_history_store::SCHEMA)?;
|
||||
tx.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)",
|
||||
[crate::config_adoption::MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 112 {
|
||||
return Err(Error::Store(
|
||||
@@ -16165,6 +16189,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_through_v113(conn)?;
|
||||
conn.execute_batch(content_history_store::SCHEMA)?;
|
||||
conn.execute_batch(crate::config_adoption::SCHEMA)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace_schema_through_v113(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
assignment_delivery::install_post_inputs(conn)?;
|
||||
@@ -17083,7 +17114,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result<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 +18909,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn current_schema_accepts_every_retained_canonical_provenance() {
|
||||
for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION {
|
||||
for baseline_version in
|
||||
(OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION))
|
||||
{
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
merge_request::migrate(&conn).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
|
||||
.unwrap();
|
||||
create_latest_workspace_schema(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)",
|
||||
params![baseline_version, SCHEMA_BASELINE_NAME],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -420,6 +420,17 @@
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"failure": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/StartupFailureAttachment"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience."
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -449,6 +460,10 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"StartupFailureAttachment": {
|
||||
"description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.",
|
||||
"type": "string"
|
||||
},
|
||||
"ValidatePermissionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.",
|
||||
@@ -481,7 +496,7 @@
|
||||
"info": {
|
||||
"title": "Feature permission declaration contract",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb"
|
||||
"x-yoi-source-digest": "sha256:9a4f2ec1ab277c73bf8c69b2adef050dbae26cff5430ff2bd1201371feef5a38"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts src/lib/workspace/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
|
||||
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.
|
||||
// Do not edit manually.
|
||||
|
||||
import type { ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions } from "./legacy-server-api.ts";
|
||||
import type { PermissionAssignment, PermissionCatalog } from "./feature-permissions.generated.ts";
|
||||
|
||||
export type ConfigurationAdoptionRequest = { operation_id: string, expected_head: string | null, expected_catalog_digest: string, expected_legacy_export: string | null, source: ConfigCommitRequest, };
|
||||
|
||||
export type RuntimePolicyApplication = "next_current_policy_check";
|
||||
|
||||
export type ConfigurationAdoptionReceipt = { workspace_id: string, operation_id: string, history_head: string, state: WorkspaceConfigTreeResponse, source_stored: boolean, policy_committed: boolean, runtime_application: RuntimePolicyApplication, };
|
||||
|
||||
export type ConfigurationSubjectPolicy = { subject_id: string, change_id: string, execution: PermissionAssignment, delegation: PermissionAssignment,
|
||||
/**
|
||||
* Exact retained domain restrictions, not editable payloads on permission keys.
|
||||
*/
|
||||
legacy_constraints: SubjectPermissions | null, };
|
||||
|
||||
export type ConfigurationMigrationDiagnostic = { "kind": "converted", subject_id: string, operation: string, } | { "kind": "domain_constraint", subject_id: string, permission: ScopedPermission, } | { "kind": "explicit_configuration_required", subject_id: string, permission: ScopedPermission, };
|
||||
|
||||
export type ConfigurationSourceResponse = {
|
||||
/**
|
||||
* Before the first adoption this is an editable, evaluated migration draft.
|
||||
* source_stored distinguishes it from the unchanged stored source.
|
||||
*/
|
||||
state: WorkspaceConfigTreeResponse, history_head: string | null, source_stored: boolean, catalog: PermissionCatalog, catalog_digest: string, expected_legacy_export: string | null, policies: Array<ConfigurationSubjectPolicy>, migration_diagnostics: Array<ConfigurationMigrationDiagnostic>, };
|
||||
|
||||
export type ConfigurationPreviewResponse = { expected_head: string | null, state: WorkspaceConfigTreeResponse, changed_paths: Array<string>, changed_subjects: Array<string>, };
|
||||
|
||||
export type ConfigurationReconciliation = { "status": "adopted", receipt: ConfigurationAdoptionReceipt, } | { "status": "pending_transfer", transfer_identity: string, export_digest: string, } | { "status": "not_observed" };
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
|
||||
import type { WorkspaceConfigTreeResponse } from "./types.ts";
|
||||
|
||||
const toolchain = vi.hoisted(() => ({
|
||||
construct: vi.fn(),
|
||||
@@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({
|
||||
}));
|
||||
|
||||
const schemaSource = "schema body\n".repeat(6000);
|
||||
const tree = {
|
||||
const tree: WorkspaceConfigTreeResponse = {
|
||||
snapshot: {
|
||||
digest: "sha256:tree",
|
||||
entries: {
|
||||
@@ -64,7 +65,27 @@ const tree = {
|
||||
projection_digest: "sha256:projection",
|
||||
};
|
||||
const fetcher = vi.fn();
|
||||
const response = () => new Response(JSON.stringify(tree));
|
||||
const sourceResponse = (state = tree) => ({
|
||||
state,
|
||||
history_head: "head:observed",
|
||||
source_stored: true,
|
||||
catalog: { workspace: "w", namespaces: [] },
|
||||
catalog_digest: "sha256:catalog",
|
||||
expected_legacy_export: "sha256:legacy",
|
||||
policies: [],
|
||||
migration_diagnostics: [],
|
||||
});
|
||||
const response = () => new Response(JSON.stringify(sourceResponse()));
|
||||
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body));
|
||||
const previewResponse = (state = tree, expected_head: string | null = "head:observed") => jsonResponse({
|
||||
expected_head, state, changed_paths: ["module.dcdl"], changed_subjects: [],
|
||||
});
|
||||
function receipt(operation_id: string, state = tree, history_head = "head:adopted") {
|
||||
return {
|
||||
workspace_id: "w", operation_id, history_head, state,
|
||||
source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation
|
||||
finishSetup();
|
||||
await expectReady(view);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/api/w/w%2Fone/config/source-tree",
|
||||
"/api/w/w%2Fone/config/adoption",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
@@ -140,7 +161,7 @@ test.each(["http", "invalid"])(
|
||||
fetcher.mockResolvedValueOnce(
|
||||
failure === "http"
|
||||
? new Response("Backend unavailable", { status: 503 })
|
||||
: new Response(JSON.stringify({ ...tree, unexpected: true })),
|
||||
: new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })),
|
||||
);
|
||||
const view = renderEditor();
|
||||
await waitFor(() =>
|
||||
@@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)(
|
||||
|
||||
test("a successfully loaded empty tree still offers source creation", async () => {
|
||||
fetcher.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
new Response(JSON.stringify(sourceResponse({
|
||||
...tree,
|
||||
snapshot: { ...tree.snapshot, entries: {} },
|
||||
})),
|
||||
}))),
|
||||
);
|
||||
const view = renderEditor();
|
||||
await waitFor(() =>
|
||||
@@ -328,49 +349,186 @@ test("diagnostics from another tree digest are not displayed", async () => {
|
||||
expect(view.container.textContent).not.toContain("Wrong tree diagnostic");
|
||||
});
|
||||
|
||||
test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => {
|
||||
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" };
|
||||
const candidate = {
|
||||
digest: "sha256:local-draft",
|
||||
entries: {
|
||||
...tree.snapshot.entries,
|
||||
"module.dcdl": {
|
||||
path: change.path,
|
||||
content_type: change.content_type,
|
||||
content: change.content,
|
||||
content_digest: "sha256:module",
|
||||
},
|
||||
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" } as const;
|
||||
const candidate = {
|
||||
digest: "sha256:local-draft",
|
||||
entries: {
|
||||
...tree.snapshot.entries,
|
||||
"module.dcdl": {
|
||||
path: change.path, content_type: change.content_type, content: change.content,
|
||||
content_digest: "sha256:module",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const candidateState = { ...tree, snapshot: candidate };
|
||||
|
||||
async function prepareDraft() {
|
||||
toolchain.applyChanges.mockResolvedValue(candidate);
|
||||
toolchain.changesBetween.mockResolvedValue([change]);
|
||||
toolchain.format.mockImplementation(async (source: string) => source);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Create local source" }));
|
||||
return view;
|
||||
}
|
||||
|
||||
const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body);
|
||||
|
||||
function commitReceiptResponse(state = candidateState, historyHead = "head:adopted") {
|
||||
return async (_input: string, init: RequestInit) =>
|
||||
jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead));
|
||||
}
|
||||
|
||||
test.each([409, 422])("preview rejection %s preserves the draft and observed editing metadata", async (status) => {
|
||||
const view = await prepareDraft();
|
||||
const commit = view.getByRole("button", { name: "Commit" });
|
||||
expect(commit).toBeInstanceOf(HTMLButtonElement);
|
||||
fetcher.mockResolvedValueOnce(new Response(
|
||||
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
|
||||
{ status },
|
||||
status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status },
|
||||
));
|
||||
await fireEvent.click(commit);
|
||||
await waitFor(() => expect(view.container.textContent).toContain(
|
||||
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
|
||||
status === 409 ? "editing head mismatch" : "candidate evaluation failed",
|
||||
));
|
||||
const expectedRequest = {
|
||||
base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"],
|
||||
};
|
||||
expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest);
|
||||
const first = requestAt(1);
|
||||
expect(first).toEqual({
|
||||
operation_id: expect.any(String), expected_head: "head:observed",
|
||||
expected_catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy",
|
||||
source: { base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"] },
|
||||
});
|
||||
expect(fetcher.mock.calls[1][0]).toBe("/api/w/w/config/adoption/preview");
|
||||
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
|
||||
expect(commit).toHaveProperty("disabled", false);
|
||||
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
|
||||
if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate");
|
||||
|
||||
fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate })));
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockImplementationOnce(commitReceiptResponse());
|
||||
await fireEvent.click(commit);
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest);
|
||||
const second = requestAt(2);
|
||||
expect(second).toEqual({ ...first, operation_id: expect.any(String) });
|
||||
expect(second.operation_id).not.toBe(first.operation_id);
|
||||
expect(requestAt(3)).toEqual(second);
|
||||
expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle);
|
||||
expect(commit).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
test("initial editable migration draft can be adopted without manually changing a file", async () => {
|
||||
fetcher.mockResolvedValueOnce(jsonResponse({
|
||||
...sourceResponse(), history_head: null, source_stored: false,
|
||||
}));
|
||||
toolchain.changesBetween.mockResolvedValue([]);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
const commit = view.getByRole("button", { name: "Commit" });
|
||||
expect(commit).toHaveProperty("disabled", false);
|
||||
fetcher.mockResolvedValueOnce(previewResponse(tree, null));
|
||||
fetcher.mockImplementationOnce(commitReceiptResponse(tree));
|
||||
await fireEvent.click(commit);
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
expect(requestAt(1).expected_head).toBeNull();
|
||||
expect(requestAt(1).source.changes).toEqual([]);
|
||||
expect(requestAt(2)).toEqual(requestAt(1));
|
||||
expect(commit).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
test("commit rejection does not refresh the head or discard local source", async () => {
|
||||
const view = await prepareDraft();
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockResolvedValueOnce(new Response("editing head mismatch", { status: 409 }));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Discard local candidate"));
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
|
||||
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockImplementationOnce(commitReceiptResponse());
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
expect(requestAt(3).expected_head).toBe(requestAt(1).expected_head);
|
||||
expect(requestAt(3).operation_id).not.toBe(requestAt(1).operation_id);
|
||||
});
|
||||
|
||||
test.each(["not_observed", "pending_transfer"])("lost commit followed by %s retains the exact request for explicit resume", async (status) => {
|
||||
const view = await prepareDraft();
|
||||
const unknown = status === "not_observed" ? { status } : {
|
||||
status, transfer_identity: "transfer:one", export_digest: "sha256:legacy",
|
||||
};
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockRejectedValueOnce(new TypeError("response lost"));
|
||||
fetcher.mockResolvedValueOnce(jsonResponse(unknown));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain(
|
||||
status === "not_observed" ? "not observed does not mean failed" : "transfer pending",
|
||||
));
|
||||
expect(fetcher).toHaveBeenCalledTimes(4);
|
||||
const request = requestAt(1);
|
||||
expect(requestAt(2)).toEqual(request);
|
||||
expect(requestAt(3)).toEqual(request);
|
||||
expect(fetcher.mock.calls[3][0]).toBe("/api/w/w/config/adoption/reconcile");
|
||||
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
|
||||
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
|
||||
|
||||
fetcher.mockResolvedValueOnce(jsonResponse(unknown));
|
||||
fetcher.mockImplementationOnce(commitReceiptResponse());
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
expect(fetcher).toHaveBeenCalledTimes(6);
|
||||
expect(requestAt(4)).toEqual(request);
|
||||
expect(requestAt(5)).toEqual(request);
|
||||
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
|
||||
});
|
||||
|
||||
test("adopted reconciliation updates head and clears the legacy export for the next explicit adoption", async () => {
|
||||
const view = await prepareDraft();
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockResolvedValueOnce(new Response("invalid receipt"));
|
||||
fetcher.mockImplementationOnce(async (_input: string, init: RequestInit) => jsonResponse({
|
||||
status: "adopted", receipt: receipt(JSON.parse(String(init.body)).operation_id, candidateState),
|
||||
}));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
|
||||
const first = requestAt(2);
|
||||
expect(requestAt(3)).toEqual(first);
|
||||
// A local deletion is another explicit adoption, not a retry of the lost reply.
|
||||
const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" };
|
||||
toolchain.applyChanges.mockResolvedValue(tree.snapshot);
|
||||
toolchain.changesBetween.mockResolvedValue([deletion]);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Delete" }));
|
||||
fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted"));
|
||||
fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second"));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(6));
|
||||
const second = requestAt(4);
|
||||
expect(second.expected_head).toBe("head:adopted");
|
||||
expect(second.expected_legacy_export).toBeNull();
|
||||
expect(second.expected_catalog_digest).toBe(first.expected_catalog_digest);
|
||||
expect(second.operation_id).not.toBe(first.operation_id);
|
||||
expect(second.source.base_digest).toBe(candidate.digest);
|
||||
});
|
||||
|
||||
test("edited draft requires reconciliation and cannot replace or resume an unknown request", async () => {
|
||||
const view = await prepareDraft();
|
||||
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
|
||||
fetcher.mockRejectedValueOnce(new TypeError("response lost"));
|
||||
fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" }));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("not observed does not mean failed"));
|
||||
const original = requestAt(2);
|
||||
toolchain.format.mockResolvedValue("{ changed: true }\n");
|
||||
await fireEvent.click(view.getByRole("button", { name: "Format" }));
|
||||
await waitFor(() => expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed"));
|
||||
fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" }));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Draft changed; reconcile the original request"));
|
||||
expect(fetcher).toHaveBeenCalledTimes(5);
|
||||
expect(requestAt(4)).toEqual(original);
|
||||
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
|
||||
|
||||
fetcher.mockResolvedValueOnce(jsonResponse({ status: "adopted", receipt: receipt(original.operation_id, candidateState) }));
|
||||
await fireEvent.click(view.getByRole("button", { name: "Reconcile adoption" }));
|
||||
await waitFor(() => expect(view.container.textContent).toContain("Later local edits remain unsaved"));
|
||||
expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed");
|
||||
expect(requestAt(5)).toEqual(original);
|
||||
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script lang="ts">
|
||||
// commitの条件は保存したbase_digestと各entryのexpected_digest。時刻や要求順では
|
||||
// 編集元の内容を特定できないため、成功時だけ返されたsnapshotを新しいbaseにする。
|
||||
// Server拒否時はdraftを残し、reload/reapplyを明示的な判断にする。
|
||||
// 内容digestとは別に、GETで観測した履歴headと移行条件を編集元として保持する。
|
||||
// 応答喪失時は依頼全体を固定し、照合で採用を確認するまで別IDへ切り替えない。
|
||||
// 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と
|
||||
// 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。
|
||||
import { onMount } from "svelte";
|
||||
import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
|
||||
import {
|
||||
commitConfigTree,
|
||||
ConfigSourceApiError,
|
||||
fetchConfigTree,
|
||||
previewConfigTree,
|
||||
reconcileConfigTree,
|
||||
} from "./api.ts";
|
||||
import type { ConfigurationEditingState } from "./api.ts";
|
||||
import type {
|
||||
ConfigurationAdoptionReceipt,
|
||||
ConfigurationAdoptionRequest,
|
||||
} from "#lib/generated/configuration-adoption.generated.ts";
|
||||
import { ConfigSourceToolchain } from "./toolchain.ts";
|
||||
import type {
|
||||
ConfigDiagnostic,
|
||||
@@ -31,6 +38,13 @@
|
||||
let busy = $state(false);
|
||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||
let baseDigest = $state("");
|
||||
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
|
||||
let editingWorkspaceId = $state("");
|
||||
let pendingAdoption = $state.raw<{
|
||||
workspaceId: string;
|
||||
request: ConfigurationAdoptionRequest;
|
||||
draftSignature: string;
|
||||
} | null>(null);
|
||||
let renamePath = $state("");
|
||||
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let conflict = $state(false);
|
||||
@@ -73,7 +87,7 @@
|
||||
void analyzer.analyze(path, value).then((result) => {
|
||||
if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest);
|
||||
}).catch((error) => {
|
||||
if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`;
|
||||
if (!request.signal.aborted && !pendingAdoption && !busy) status = `Analyze failed: ${String(error)}`;
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
@@ -83,20 +97,28 @@
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
if (pendingAdoption) {
|
||||
status = "Adoption outcome unknown. Reconcile the retained request before reloading.";
|
||||
return;
|
||||
}
|
||||
loadState = "loading";
|
||||
status = "Loading source tree…";
|
||||
analysisReady = false;
|
||||
analysisRequest?.abort();
|
||||
const requestedWorkspace = workspaceId;
|
||||
try {
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
if (!mounted) return;
|
||||
const observed = await fetchConfigTree(requestedWorkspace);
|
||||
const remote = observed.state;
|
||||
if (!mounted || workspaceId !== requestedWorkspace) return;
|
||||
toolchain?.close();
|
||||
toolchain = null;
|
||||
toolchain = new ConfigSourceToolchain();
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
if (!mounted) return;
|
||||
if (!mounted || workspaceId !== requestedWorkspace) return;
|
||||
// Publish the new baseline only after both fetch and toolchain setup succeed.
|
||||
treeState = remote;
|
||||
editingMetadata = observed;
|
||||
editingWorkspaceId = requestedWorkspace;
|
||||
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
@@ -204,52 +226,174 @@
|
||||
|
||||
function recordCommitError(error: unknown) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base content digest mismatch");
|
||||
conflict = error instanceof ConfigSourceApiError && error.status === 409;
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!treeState) return;
|
||||
function draftSignature(): string {
|
||||
const entries = new Map(Object.entries(treeState?.snapshot.entries ?? {}).map(
|
||||
([path, entry]) => [path, [entry.content_type, entry.content]],
|
||||
));
|
||||
if (selectedPath) entries.set(selectedPath, [selected?.content_type ?? "decodal", source]);
|
||||
return JSON.stringify([...entries].sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
async function applyReceipt(receipt: ConfigurationAdoptionReceipt) {
|
||||
const edited = pendingAdoption !== null && draftSignature() !== pendingAdoption.draftSignature;
|
||||
const local = edited && treeState ? $state.snapshot(treeState.snapshot) : null;
|
||||
const localSource = source;
|
||||
// receipt確定後のtoolchain障害は採用失敗ではない。headと確定状態を先に更新する。
|
||||
treeState = receipt.state;
|
||||
editingMetadata = {
|
||||
state: receipt.state,
|
||||
history_head: receipt.history_head,
|
||||
catalog_digest: editingMetadata!.catalog_digest,
|
||||
expected_legacy_export: null,
|
||||
source_stored: true,
|
||||
};
|
||||
baseSnapshot = $state.snapshot(receipt.state.snapshot);
|
||||
baseDigest = receipt.state.snapshot.digest;
|
||||
pendingAdoption = null;
|
||||
workingChanges = [];
|
||||
conflict = false;
|
||||
analysisReady = false;
|
||||
diagnostics = [];
|
||||
if (local) treeState = { ...receipt.state, snapshot: local };
|
||||
if (!edited && !treeState.snapshot.entries[selectedPath]) {
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
source = edited ? localSource : treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
try {
|
||||
if (local && toolchain) workingChanges = await toolchain.changesBetween(baseSnapshot, local);
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
analysisReady = true;
|
||||
status = edited
|
||||
? "Adoption confirmed. Later local edits remain unsaved; Commit to adopt them separately."
|
||||
: `Committed formatted source tree · ${receipt.state.snapshot.digest.slice(0, 20)}…`;
|
||||
} catch (error) {
|
||||
status = `Adoption confirmed, but local editor setup failed: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcilePending(): Promise<"adopted" | "unresolved" | "unavailable"> {
|
||||
const pending = pendingAdoption;
|
||||
if (!pending) return "adopted";
|
||||
try {
|
||||
const result = await reconcileConfigTree(pending.workspaceId, pending.request);
|
||||
if (result.status === "adopted") {
|
||||
await applyReceipt(result.receipt);
|
||||
return "adopted";
|
||||
}
|
||||
status = result.status === "pending_transfer"
|
||||
? "Adoption outcome unknown: transfer pending. Commit resumes the same request; reconcile before adopting edits."
|
||||
: "Adoption outcome unknown: not observed does not mean failed. Commit resumes the same request; reconcile before adopting edits.";
|
||||
return "unresolved";
|
||||
} catch (error) {
|
||||
status = `Adoption outcome unknown. Retained request must be reconciled: ${String(error)}`;
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPending(wasUnknown: boolean) {
|
||||
const pending = pendingAdoption;
|
||||
if (!pending) return;
|
||||
let receipt: ConfigurationAdoptionReceipt;
|
||||
try {
|
||||
receipt = await commitConfigTree(pending.workspaceId, pending.request);
|
||||
} catch (error) {
|
||||
// 明確な初回拒否と、既に結果不明の依頼に対する拒否は区別する。
|
||||
if (!wasUnknown && error instanceof ConfigSourceApiError &&
|
||||
[400, 401, 403, 404, 409, 422].includes(error.status)) {
|
||||
pendingAdoption = null;
|
||||
recordCommitError(error);
|
||||
} else {
|
||||
status = `Adoption outcome unknown: ${String(error)}`;
|
||||
await reconcilePending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await applyReceipt(receipt);
|
||||
}
|
||||
|
||||
async function reconcile() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await reconcilePending();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!treeState || !editingMetadata || busy || !analysisReady) return;
|
||||
busy = true;
|
||||
try {
|
||||
if (pendingAdoption) {
|
||||
const edited = draftSignature() !== pendingAdoption.draftSignature;
|
||||
const outcome = await reconcilePending();
|
||||
if (outcome !== "unresolved") return;
|
||||
if (edited) {
|
||||
status = "Adoption outcome unknown. Draft changed; reconcile the original request before adopting edits.";
|
||||
return;
|
||||
}
|
||||
// 明示的な次のCommitだけが、保存済みの同一依頼を再送できる。
|
||||
await submitPending(true);
|
||||
return;
|
||||
}
|
||||
if (editingWorkspaceId !== workspaceId) {
|
||||
status = "Workspace changed. Reload the authoritative source tree before adopting edits.";
|
||||
return;
|
||||
}
|
||||
const adoptionWorkspaceId = editingWorkspaceId;
|
||||
await formatWorkingSources();
|
||||
if (workingChanges.length === 0) {
|
||||
if (workingChanges.length === 0 && editingMetadata.source_stored) {
|
||||
status = "No working changes to commit.";
|
||||
return;
|
||||
}
|
||||
treeState = await commitConfigTree(workspaceId, {
|
||||
base_digest: baseDigest,
|
||||
changes: workingChanges,
|
||||
entrypoints: entrypoints(),
|
||||
});
|
||||
workingChanges = [];
|
||||
conflict = false;
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
diagnostics = [];
|
||||
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
const request: ConfigurationAdoptionRequest = {
|
||||
operation_id: crypto.randomUUID(),
|
||||
expected_head: editingMetadata.history_head,
|
||||
expected_catalog_digest: editingMetadata.catalog_digest,
|
||||
expected_legacy_export: editingMetadata.expected_legacy_export,
|
||||
source: {
|
||||
base_digest: baseDigest,
|
||||
changes: $state.snapshot(workingChanges),
|
||||
entrypoints: entrypoints(),
|
||||
},
|
||||
};
|
||||
const signature = draftSignature();
|
||||
await previewConfigTree(adoptionWorkspaceId, request);
|
||||
pendingAdoption = { workspaceId: adoptionWorkspaceId, request, draftSignature: signature };
|
||||
await submitPending(false);
|
||||
} catch (error) {
|
||||
recordCommitError(error);
|
||||
if (pendingAdoption) status = `Adoption outcome unknown: ${String(error)}`;
|
||||
else recordCommitError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discardAndReload() {
|
||||
if (busy) return;
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function reloadAndReapply() {
|
||||
if (!toolchain || !treeState) return;
|
||||
const localChanges = [...workingChanges];
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseDigest = remote.snapshot.digest;
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
if (!toolchain || !treeState || busy || pendingAdoption) return;
|
||||
busy = true;
|
||||
const requestedWorkspace = workspaceId;
|
||||
try {
|
||||
await stageCurrent();
|
||||
const localChanges = $state.snapshot(workingChanges);
|
||||
const observed = await fetchConfigTree(requestedWorkspace);
|
||||
const remote = observed.state;
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
const candidate = await toolchain.applyChanges(localChanges);
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseDigest = remote.snapshot.digest;
|
||||
editingMetadata = observed;
|
||||
editingWorkspaceId = requestedWorkspace;
|
||||
workingChanges = localChanges;
|
||||
treeState = { ...remote, snapshot: candidate };
|
||||
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||
@@ -258,7 +402,14 @@
|
||||
status = "Local changes reapplied to the latest source tree. Commit to persist them.";
|
||||
} catch (error) {
|
||||
conflict = true;
|
||||
try {
|
||||
await toolchain.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
} catch {
|
||||
analysisReady = false;
|
||||
}
|
||||
status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +482,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class:active={path === selectedPath}
|
||||
disabled={busy}
|
||||
onclick={() => select(path)}
|
||||
>
|
||||
<span>{path}</span>
|
||||
@@ -340,8 +492,8 @@
|
||||
</nav>
|
||||
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
|
||||
<label for="new-config-path">New path</label>
|
||||
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
|
||||
<button type="submit">Create local source</button>
|
||||
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" disabled={busy} />
|
||||
<button type="submit" disabled={busy}>Create local source</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
@@ -355,7 +507,7 @@
|
||||
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
||||
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
||||
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
||||
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
|
||||
<button class="primary" type="button" onclick={commit} disabled={(!dirty && !pendingAdoption && editingMetadata?.source_stored !== false) || busy || !analysisReady}>Commit</button>
|
||||
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -369,10 +521,16 @@
|
||||
: Promise.resolve(null)}
|
||||
/>
|
||||
<p class="config-source-status" aria-live="polite">{status}</p>
|
||||
{#if conflict}
|
||||
{#if pendingAdoption}
|
||||
<div class="config-source-conflict" role="status">
|
||||
<span>Adoption outcome unknown. Commit resumes the unchanged request; edited drafts require reconciliation first.</span>
|
||||
<button type="button" onclick={reconcile} disabled={busy}>Reconcile adoption</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if conflict && !pendingAdoption}
|
||||
<div class="config-source-conflict" role="alert">
|
||||
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
|
||||
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
|
||||
<button type="button" onclick={discardAndReload} disabled={busy}>Discard local candidate and reload</button>
|
||||
<button type="button" onclick={reloadAndReapply} disabled={busy}>Reload and reapply local candidate</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if diagnostics.length > 0}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/// <reference lib="deno.ns" />
|
||||
|
||||
import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert";
|
||||
import {
|
||||
commitConfigTree,
|
||||
ConfigSourceApiError,
|
||||
fetchConfigTree,
|
||||
parseConfigurationAdoptionReceipt,
|
||||
parseConfigurationReconciliation,
|
||||
parseConfigurationSourceResponse,
|
||||
previewConfigTree,
|
||||
reconcileConfigTree,
|
||||
} from "./api.ts";
|
||||
import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts";
|
||||
import type { WorkspaceConfigTreeResponse } from "./types.ts";
|
||||
|
||||
const state: WorkspaceConfigTreeResponse = {
|
||||
snapshot: { digest: "sha256:content-A", entries: {} },
|
||||
contract: {
|
||||
contract_version: 1, decodal_version: "0.4.0", schema_version: 1,
|
||||
entrypoints: ["main.dcdl"], import_policy_version: 1,
|
||||
schema_bundle: { contributions: [], source: "builtin", fingerprint: "sha256:schema" },
|
||||
fingerprint: "sha256:contract",
|
||||
},
|
||||
projection_digest: "sha256:projection",
|
||||
};
|
||||
const source = {
|
||||
state, history_head: null, source_stored: false,
|
||||
catalog: { workspace: "w/one", namespaces: [] },
|
||||
catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy",
|
||||
policies: [], migration_diagnostics: [],
|
||||
};
|
||||
const request: ConfigurationAdoptionRequest = {
|
||||
operation_id: "operation:one", expected_head: "head:A-after-B",
|
||||
expected_catalog_digest: source.catalog_digest,
|
||||
expected_legacy_export: source.expected_legacy_export,
|
||||
source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] },
|
||||
};
|
||||
const receipt = {
|
||||
workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted",
|
||||
state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
|
||||
};
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
const replies = (...bodies: unknown[]) =>
|
||||
(() => Promise.resolve(json(bodies.shift()))) as typeof fetch;
|
||||
|
||||
Deno.test("adoption source parser retains nullable observed metadata separately from content identity", () => {
|
||||
const parsed = parseConfigurationSourceResponse(source);
|
||||
assertEquals(parsed.state, state);
|
||||
assertEquals(parsed.history_head, null);
|
||||
assertEquals(parsed.source_stored, false);
|
||||
assertEquals(parsed.expected_legacy_export, "sha256:legacy");
|
||||
// A -> B -> A can retain content-A while having a different editing head.
|
||||
assertEquals(parseConfigurationSourceResponse({ ...source, history_head: "head:A-after-B" }).history_head, request.expected_head);
|
||||
});
|
||||
|
||||
Deno.test("adoption source parser rejects missing metadata, unknown envelope fields and unbounded opaque collections", () => {
|
||||
const { history_head: _head, ...missingHead } = source;
|
||||
const { expected_legacy_export: _export, ...missingExport } = source;
|
||||
for (const invalid of [
|
||||
state, missingHead, missingExport,
|
||||
{ ...source, unexpected: true }, { ...source, history_head: 1 },
|
||||
{ ...source, catalog_digest: "x".repeat(257) },
|
||||
{ ...source, expected_legacy_export: false }, { ...source, source_stored: null },
|
||||
{ ...source, policies: {} }, { ...source, migration_diagnostics: Array(257).fill({}) },
|
||||
{ ...source, catalog: { namespaces: Array(257).fill({}) } },
|
||||
{ ...source, state: { ...state, snapshot: { ...state.snapshot, unexpected: true } } },
|
||||
]) assertThrows(() => parseConfigurationSourceResponse(invalid), ConfigSourceApiError);
|
||||
});
|
||||
|
||||
Deno.test("adoption transport posts the full unchanged request to owner adoption routes, including reconcile", async () => {
|
||||
const calls: Array<{ url: string; body?: BodyInit | null }> = [];
|
||||
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, body: init?.body });
|
||||
const body = url.endsWith("/preview")
|
||||
? { expected_head: request.expected_head, state, changed_paths: [], changed_subjects: [] }
|
||||
: url.endsWith("/commit") ? receipt
|
||||
: url.endsWith("/reconcile") ? { status: "adopted", receipt }
|
||||
: source;
|
||||
return Promise.resolve(json(body));
|
||||
}) as typeof fetch;
|
||||
await fetchConfigTree("w/one", fetcher);
|
||||
await previewConfigTree("w/one", request, fetcher);
|
||||
await commitConfigTree("w/one", request, fetcher);
|
||||
await reconcileConfigTree("w/one", request, fetcher);
|
||||
assertEquals(calls.map(({ url }) => url), [
|
||||
"/api/w/w%2Fone/config/adoption", "/api/w/w%2Fone/config/adoption/preview",
|
||||
"/api/w/w%2Fone/config/adoption/commit", "/api/w/w%2Fone/config/adoption/reconcile",
|
||||
]);
|
||||
for (const call of calls.slice(1)) assertEquals(JSON.parse(String(call.body)), request);
|
||||
});
|
||||
|
||||
Deno.test("preview does not refresh an editing head from an unexpected response", async () => {
|
||||
await assertRejects(() => previewConfigTree("w/one", request, replies({
|
||||
expected_head: "head:new", state, changed_paths: [], changed_subjects: [],
|
||||
})), ConfigSourceApiError, "invalid response");
|
||||
assertEquals(request.expected_head, "head:A-after-B");
|
||||
});
|
||||
|
||||
Deno.test("receipt requires committed source and policy and matches the requested workspace and operation", async () => {
|
||||
for (const invalid of [
|
||||
{ ...receipt, source_stored: false }, { ...receipt, policy_committed: false },
|
||||
{ ...receipt, runtime_application: "already_running" },
|
||||
{ ...receipt, history_head: null }, { ...receipt, extra: true },
|
||||
]) assertThrows(() => parseConfigurationAdoptionReceipt(invalid), ConfigSourceApiError);
|
||||
for (const invalid of [
|
||||
{ ...receipt, workspace_id: "other" }, { ...receipt, operation_id: "other" },
|
||||
]) {
|
||||
await assertRejects(() => commitConfigTree("w/one", request, replies(invalid)), ConfigSourceApiError);
|
||||
await assertRejects(() => reconcileConfigTree("w/one", request, replies({ status: "adopted", receipt: invalid })), ConfigSourceApiError);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("not_observed and pending_transfer remain distinct unknown evidence, never a failed-save receipt", () => {
|
||||
for (const evidence of [
|
||||
{ status: "not_observed" },
|
||||
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
|
||||
{ status: "adopted", receipt },
|
||||
]) assertEquals(parseConfigurationReconciliation(evidence), evidence);
|
||||
for (const invalid of [
|
||||
{ status: "failed" }, { status: "not_observed", receipt },
|
||||
{ status: "pending_transfer", transfer_identity: "transfer:one" },
|
||||
{ status: "adopted" },
|
||||
]) assertThrows(() => parseConfigurationReconciliation(invalid), ConfigSourceApiError);
|
||||
});
|
||||
|
||||
Deno.test("lost or invalid responses do not retry or change request identity inside the adapter", async () => {
|
||||
for (const failure of ["network", "malformed", "oversized", "server"]) {
|
||||
let calls = 0;
|
||||
const fetcher = (() => {
|
||||
calls++;
|
||||
if (failure === "network") return Promise.reject(new TypeError("response lost"));
|
||||
return Promise.resolve(failure === "malformed" ? new Response("not JSON")
|
||||
: failure === "oversized" ? new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } })
|
||||
: new Response("outcome unavailable", { status: 500 }));
|
||||
}) as typeof fetch;
|
||||
await assertRejects(() => commitConfigTree("w/one", request, fetcher));
|
||||
assertEquals(calls, 1);
|
||||
assertEquals(request.operation_id, "operation:one");
|
||||
assertEquals(request.expected_head, "head:A-after-B");
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,6 @@
|
||||
* Browserの解析成功はServerのcommit受理を代替しない。
|
||||
*/
|
||||
import type {
|
||||
ConfigCommitRequest,
|
||||
ConfigContentType,
|
||||
ConfigEntry,
|
||||
ConfigProjectionValidator,
|
||||
@@ -15,6 +14,20 @@ import type {
|
||||
WorkspaceConfigTreeResponse,
|
||||
} from "./types.ts";
|
||||
|
||||
import type {
|
||||
ConfigurationAdoptionReceipt,
|
||||
ConfigurationAdoptionRequest,
|
||||
ConfigurationPreviewResponse,
|
||||
ConfigurationReconciliation,
|
||||
ConfigurationSourceResponse,
|
||||
} from "#lib/generated/configuration-adoption.generated.ts";
|
||||
|
||||
// 未使用のpolicy/catalogを認可判定へ転用しない。editorが使う観測値だけを型へ変換する。
|
||||
export type ConfigurationEditingState = Pick<
|
||||
ConfigurationSourceResponse,
|
||||
"state" | "history_head" | "catalog_digest" | "expected_legacy_export" | "source_stored"
|
||||
>;
|
||||
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
// Schema bodies are documents, not short metadata. Keep them within the
|
||||
// response budget; readJson also bounds the combined bundle and contributions.
|
||||
@@ -277,15 +290,138 @@ export function parseWorkspaceConfigTreeResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function adoptionUrl(workspaceId: string): string {
|
||||
return `/api/w/${encodeURIComponent(workspaceId)}/config/adoption`;
|
||||
}
|
||||
|
||||
function nullableDigest(value: unknown): string | null {
|
||||
return value === null ? null : boundedString(value, MAX_DIGEST_BYTES);
|
||||
}
|
||||
|
||||
// 非表示のpolicy/catalogも大きさと深さを制限するが、その内部を型付け・再解釈しない。
|
||||
function checkOpaqueData(value: unknown, depth = 0, budget = { nodes: 65536 }): void {
|
||||
if (depth > 16 || --budget.nodes < 0) throw schemaError();
|
||||
if (value === null || typeof value === "boolean") return;
|
||||
if (typeof value === "string") {
|
||||
boundedString(value);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of boundedArray(value)) checkOpaqueData(child, depth + 1, budget);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(objectValue(value));
|
||||
if (entries.length > MAX_COLLECTION_ITEMS) throw schemaError();
|
||||
for (const [key, child] of entries) {
|
||||
boundedString(key);
|
||||
checkOpaqueData(child, depth + 1, budget);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseConfigurationSourceResponse(value: unknown): ConfigurationEditingState {
|
||||
const item = record(value, [
|
||||
"state", "history_head", "source_stored", "catalog", "catalog_digest",
|
||||
"expected_legacy_export", "policies", "migration_diagnostics",
|
||||
]);
|
||||
if (typeof item.source_stored !== "boolean") throw schemaError();
|
||||
objectValue(item.catalog);
|
||||
boundedArray(item.policies);
|
||||
boundedArray(item.migration_diagnostics);
|
||||
checkOpaqueData([item.catalog, item.policies, item.migration_diagnostics]);
|
||||
return {
|
||||
state: parseWorkspaceConfigTreeResponse(item.state),
|
||||
history_head: nullableDigest(item.history_head),
|
||||
source_stored: item.source_stored,
|
||||
catalog_digest: boundedString(item.catalog_digest, MAX_DIGEST_BYTES),
|
||||
expected_legacy_export: nullableDigest(item.expected_legacy_export),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigurationPreviewResponse(value: unknown): ConfigurationPreviewResponse {
|
||||
const item = record(value, ["expected_head", "state", "changed_paths", "changed_subjects"]);
|
||||
return {
|
||||
expected_head: nullableDigest(item.expected_head),
|
||||
state: parseWorkspaceConfigTreeResponse(item.state),
|
||||
changed_paths: boundedArray(item.changed_paths).map((path) => boundedString(path, MAX_PATH_BYTES)),
|
||||
changed_subjects: boundedArray(item.changed_subjects).map((subject) => boundedString(subject)),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigurationAdoptionReceipt(value: unknown): ConfigurationAdoptionReceipt {
|
||||
const item = record(value, [
|
||||
"workspace_id", "operation_id", "history_head", "state", "source_stored",
|
||||
"policy_committed", "runtime_application",
|
||||
]);
|
||||
if (item.source_stored !== true || item.policy_committed !== true ||
|
||||
item.runtime_application !== "next_current_policy_check") throw schemaError();
|
||||
return {
|
||||
workspace_id: boundedString(item.workspace_id),
|
||||
operation_id: boundedString(item.operation_id),
|
||||
history_head: boundedString(item.history_head, MAX_DIGEST_BYTES),
|
||||
state: parseWorkspaceConfigTreeResponse(item.state),
|
||||
source_stored: true,
|
||||
policy_committed: true,
|
||||
runtime_application: "next_current_policy_check",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigurationReconciliation(value: unknown): ConfigurationReconciliation {
|
||||
const status = objectValue(value).status;
|
||||
if (status === "adopted") {
|
||||
const item = record(value, ["status", "receipt"]);
|
||||
return { status, receipt: parseConfigurationAdoptionReceipt(item.receipt) };
|
||||
}
|
||||
if (status === "pending_transfer") {
|
||||
const item = record(value, ["status", "transfer_identity", "export_digest"]);
|
||||
return {
|
||||
status,
|
||||
transfer_identity: boundedString(item.transfer_identity),
|
||||
export_digest: boundedString(item.export_digest, MAX_DIGEST_BYTES),
|
||||
};
|
||||
}
|
||||
if (status === "not_observed") {
|
||||
record(value, ["status"]);
|
||||
return { status };
|
||||
}
|
||||
throw schemaError();
|
||||
}
|
||||
|
||||
function matchingReceipt(
|
||||
receipt: ConfigurationAdoptionReceipt,
|
||||
workspaceId: string,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
): ConfigurationAdoptionReceipt {
|
||||
if (receipt.workspace_id !== workspaceId || receipt.operation_id !== request.operation_id) throw schemaError();
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async function postAdoption<T>(
|
||||
workspaceId: string,
|
||||
action: "preview" | "commit" | "reconcile",
|
||||
request: ConfigurationAdoptionRequest,
|
||||
parser: Parser<T>,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<T> {
|
||||
return await readJson(
|
||||
await fetcher(`${adoptionUrl(workspaceId)}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
parser,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfigTree(
|
||||
workspaceId: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WorkspaceConfigTreeResponse> {
|
||||
): Promise<ConfigurationEditingState> {
|
||||
return await readJson(
|
||||
await fetcher(sourceTreeUrl(workspaceId), {
|
||||
await fetcher(adoptionUrl(workspaceId), {
|
||||
headers: { accept: "application/json" },
|
||||
}),
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
parseConfigurationSourceResponse,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,30 +454,32 @@ export async function fetchConfigHistory(
|
||||
|
||||
export async function previewConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WorkspaceConfigTreeResponse> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
);
|
||||
): Promise<ConfigurationPreviewResponse> {
|
||||
const preview = await postAdoption(workspaceId, "preview", request, parseConfigurationPreviewResponse, fetcher);
|
||||
if (preview.expected_head !== request.expected_head) throw schemaError();
|
||||
return preview;
|
||||
}
|
||||
|
||||
export async function commitConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WorkspaceConfigTreeResponse> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
): Promise<ConfigurationAdoptionReceipt> {
|
||||
return matchingReceipt(
|
||||
await postAdoption(workspaceId, "commit", request, parseConfigurationAdoptionReceipt, fetcher),
|
||||
workspaceId,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export async function reconcileConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<ConfigurationReconciliation> {
|
||||
const result = await postAdoption(workspaceId, "reconcile", request, parseConfigurationReconciliation, fetcher);
|
||||
if (result.status === "adopted") matchingReceipt(result.receipt, workspaceId, request);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import {
|
||||
} from "jsr:@std/assert";
|
||||
import {
|
||||
commitConfigTree,
|
||||
type ConfigurationEditingState,
|
||||
ConfigSourceApiError,
|
||||
fetchConfigEntry,
|
||||
fetchConfigHistory,
|
||||
fetchConfigTree,
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
previewConfigTree,
|
||||
reconcileConfigTree,
|
||||
} from "../../src/lib/workspace/config-source/api.ts";
|
||||
import type {
|
||||
ConfigurationAdoptionReceipt,
|
||||
ConfigurationAdoptionRequest,
|
||||
ConfigurationPreviewResponse,
|
||||
} from "../../src/lib/generated/configuration-adoption.generated.ts";
|
||||
import type {
|
||||
ConfigEntry,
|
||||
WorkspaceConfigTreeResponse,
|
||||
} from "../../src/lib/workspace/config-source/types.ts";
|
||||
|
||||
function response(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -23,7 +34,7 @@ function response(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
const entry = {
|
||||
const entry: ConfigEntry = {
|
||||
path: "profiles/main.dcdl",
|
||||
content_type: "decodal",
|
||||
content: "profile = {}",
|
||||
@@ -33,7 +44,7 @@ const snapshot = {
|
||||
digest: "sha256:tree",
|
||||
entries: { "profiles/main.dcdl": entry },
|
||||
};
|
||||
const tree = {
|
||||
const tree: WorkspaceConfigTreeResponse = {
|
||||
snapshot,
|
||||
contract: {
|
||||
contract_version: 1,
|
||||
@@ -51,38 +62,119 @@ const tree = {
|
||||
projection_digest: "sha256:projection",
|
||||
};
|
||||
|
||||
Deno.test("config source API commits directly through the workspace scope", async () => {
|
||||
function editingState(
|
||||
state: WorkspaceConfigTreeResponse = tree,
|
||||
): ConfigurationEditingState {
|
||||
return {
|
||||
state,
|
||||
history_head: "head:observed",
|
||||
catalog_digest: "sha256:catalog",
|
||||
expected_legacy_export: "sha256:legacy",
|
||||
source_stored: false,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceResponse(state: unknown = tree) {
|
||||
return {
|
||||
...editingState(),
|
||||
state,
|
||||
catalog: {},
|
||||
policies: [],
|
||||
migration_diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function adoptionRequest(
|
||||
source: ConfigurationAdoptionRequest["source"] = {
|
||||
base_digest: snapshot.digest,
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
},
|
||||
): ConfigurationAdoptionRequest {
|
||||
const observed = editingState();
|
||||
return {
|
||||
operation_id: "operation:one",
|
||||
expected_head: observed.history_head,
|
||||
expected_catalog_digest: observed.catalog_digest,
|
||||
expected_legacy_export: observed.expected_legacy_export,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function adoptionReceipt(
|
||||
workspaceId: string,
|
||||
request: ConfigurationAdoptionRequest,
|
||||
state: WorkspaceConfigTreeResponse = tree,
|
||||
): ConfigurationAdoptionReceipt {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
operation_id: request.operation_id,
|
||||
history_head: "head:adopted",
|
||||
state,
|
||||
source_stored: true,
|
||||
policy_committed: true,
|
||||
runtime_application: "next_current_policy_check",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("config source API loads adoption metadata and posts the full request while retaining history and entry routes", async () => {
|
||||
const request = adoptionRequest({
|
||||
base_digest: snapshot.digest,
|
||||
changes: [{
|
||||
kind: "update",
|
||||
path: entry.path,
|
||||
expected_digest: entry.content_digest,
|
||||
content: "profile = { changed = true }",
|
||||
}],
|
||||
entrypoints: ["main.dcdl"],
|
||||
});
|
||||
const receipt = adoptionReceipt("w/one", request);
|
||||
const preview: ConfigurationPreviewResponse = {
|
||||
expected_head: request.expected_head,
|
||||
state: tree,
|
||||
changed_paths: [entry.path],
|
||||
changed_subjects: ["subject:one"],
|
||||
};
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.includes("/entries/")) return Promise.resolve(response(entry));
|
||||
if (url.includes("/history/")) return Promise.resolve(response(snapshot));
|
||||
return Promise.resolve(response(tree));
|
||||
if (url.endsWith("/preview")) return Promise.resolve(response(preview));
|
||||
if (url.endsWith("/commit")) return Promise.resolve(response(receipt));
|
||||
if (url.endsWith("/reconcile")) {
|
||||
return Promise.resolve(response({ status: "adopted", receipt }));
|
||||
}
|
||||
return Promise.resolve(response(sourceResponse()));
|
||||
}) as typeof fetch;
|
||||
|
||||
await fetchConfigTree("w/one", fetcher);
|
||||
await fetchConfigHistory("w/one", "sha256:tree", fetcher);
|
||||
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
|
||||
await commitConfigTree("w/one", {
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: [],
|
||||
}, fetcher);
|
||||
assertEquals(await fetchConfigTree("w/one", fetcher), editingState());
|
||||
assertEquals(await fetchConfigHistory("w/one", "sha256:tree", fetcher), snapshot);
|
||||
assertEquals(await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher), entry);
|
||||
assertEquals(await previewConfigTree("w/one", request, fetcher), preview);
|
||||
assertEquals(await commitConfigTree("w/one", request, fetcher), receipt);
|
||||
assertEquals(await reconcileConfigTree("w/one", request, fetcher), {
|
||||
status: "adopted",
|
||||
receipt,
|
||||
});
|
||||
|
||||
assertEquals<unknown>(calls.map((call) => call.url), [
|
||||
"/api/w/w%2Fone/config/source-tree",
|
||||
assertEquals(calls.map((call) => call.url), [
|
||||
"/api/w/w%2Fone/config/adoption",
|
||||
"/api/w/w%2Fone/config/source-tree/history/sha256%3Atree",
|
||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||
"/api/w/w%2Fone/config/source-tree/commit",
|
||||
"/api/w/w%2Fone/config/adoption/preview",
|
||||
"/api/w/w%2Fone/config/adoption/commit",
|
||||
"/api/w/w%2Fone/config/adoption/reconcile",
|
||||
]);
|
||||
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||
assert(
|
||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||
);
|
||||
assertEquals(JSON.parse(String(calls[3].init?.body)), {
|
||||
base_digest: "sha256:base", changes: [], entrypoints: [],
|
||||
});
|
||||
for (const call of calls.slice(3)) {
|
||||
assertEquals(call.init?.method, "POST");
|
||||
assertEquals(
|
||||
new Headers(call.init?.headers).get("content-type"),
|
||||
"application/json",
|
||||
);
|
||||
assertEquals(JSON.parse(String(call.init?.body)), request);
|
||||
}
|
||||
});
|
||||
|
||||
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
|
||||
@@ -111,15 +203,19 @@ for (const field of ["bundle", "contribution"] as const) {
|
||||
const body = field === "bundle"
|
||||
? withSchema(source)
|
||||
: withSchema("builtin", source);
|
||||
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
|
||||
const request = adoptionRequest();
|
||||
const receipt = { ...adoptionReceipt("w", request), state: body };
|
||||
const fetcher = ((input: string | URL | Request) =>
|
||||
Promise.resolve(response(
|
||||
String(input).endsWith("/commit") ? receipt : sourceResponse(body),
|
||||
))) as typeof fetch;
|
||||
assertEquals<unknown>(await fetchConfigTree("w", fetcher), {
|
||||
...editingState(),
|
||||
state: body,
|
||||
});
|
||||
assertEquals<unknown>(
|
||||
await commitConfigTree("w", {
|
||||
base_digest: snapshot.digest,
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
}, fetcher),
|
||||
body,
|
||||
await commitConfigTree("w", request, fetcher),
|
||||
receipt,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,7 +255,7 @@ Deno.test("config source API retains metadata and total response limits", async
|
||||
const fetcher = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
|
||||
JSON.stringify(sourceResponse(withSchema("x".repeat(8 * 1024 * 1024)))),
|
||||
{ headers },
|
||||
),
|
||||
)) as typeof fetch;
|
||||
@@ -172,9 +268,10 @@ Deno.test("config source API retains metadata and total response limits", async
|
||||
});
|
||||
|
||||
Deno.test("config source API rejects unknown response fields", async () => {
|
||||
const fetcher =
|
||||
(() =>
|
||||
Promise.resolve(response({ ...tree, unexpected: true }))) as typeof fetch;
|
||||
const fetcher = (() =>
|
||||
Promise.resolve(response(
|
||||
sourceResponse({ ...tree, unexpected: true }),
|
||||
))) as typeof fetch;
|
||||
await assertRejects(
|
||||
() => fetchConfigTree("w", fetcher),
|
||||
ConfigSourceApiError,
|
||||
@@ -190,7 +287,7 @@ Deno.test("config source API rejects mismatched entry map paths", async () => {
|
||||
entries: { "profiles/other.dcdl": entry },
|
||||
},
|
||||
};
|
||||
const fetcher = (() => Promise.resolve(response(invalid))) as typeof fetch;
|
||||
const fetcher = (() => Promise.resolve(response(sourceResponse(invalid)))) as typeof fetch;
|
||||
await assertRejects(
|
||||
() => fetchConfigTree("w", fetcher),
|
||||
ConfigSourceApiError,
|
||||
@@ -240,11 +337,11 @@ Deno.test("config source API does not materialize imported builtins in the works
|
||||
},
|
||||
},
|
||||
};
|
||||
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||
const fetcher = (() => Promise.resolve(response(sourceResponse(body)))) as typeof fetch;
|
||||
const result = await fetchConfigTree("w", fetcher);
|
||||
assertEquals(Object.keys(result.snapshot.entries), ["profiles/main.dcdl"]);
|
||||
assertEquals(Object.keys(result.state.snapshot.entries), ["profiles/main.dcdl"]);
|
||||
assertEquals(
|
||||
result.snapshot.entries["profiles/main.dcdl"].content,
|
||||
result.state.snapshot.entries["profiles/main.dcdl"].content,
|
||||
body.snapshot.entries["profiles/main.dcdl"].content,
|
||||
);
|
||||
});
|
||||
@@ -269,16 +366,12 @@ Deno.test("config source API preserves unknown read-only builtin diagnostics wit
|
||||
}) as typeof fetch;
|
||||
const error = await assertRejects(
|
||||
() =>
|
||||
commitConfigTree("w", {
|
||||
base_digest: "sha256:tree",
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
}, fetcher),
|
||||
commitConfigTree("w", adoptionRequest(), fetcher),
|
||||
ConfigSourceApiError,
|
||||
diagnostic.message,
|
||||
);
|
||||
assert(String(error).includes("main.dcdl"));
|
||||
assertEquals(calls, ["/api/w/w/config/source-tree/commit"]);
|
||||
assertEquals(calls, ["/api/w/w/config/adoption/commit"]);
|
||||
});
|
||||
|
||||
Deno.test("config source API surfaces bounded failed evaluation details", async () => {
|
||||
@@ -286,17 +379,12 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
|
||||
Promise.resolve(
|
||||
new Response("structured diagnostics", { status: 422 }),
|
||||
)) as typeof fetch;
|
||||
let message = "";
|
||||
try {
|
||||
await commitConfigTree("w", {
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: [],
|
||||
}, fetcher);
|
||||
} catch (error) {
|
||||
message = String(error);
|
||||
}
|
||||
assert(message.includes("structured diagnostics"));
|
||||
const error = await assertRejects(
|
||||
() => commitConfigTree("w", adoptionRequest(), fetcher),
|
||||
ConfigSourceApiError,
|
||||
"structured diagnostics",
|
||||
);
|
||||
assertEquals(error.status, 422);
|
||||
});
|
||||
|
||||
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => {
|
||||
@@ -337,11 +425,13 @@ Deno.test("config source API carries bounded optional authoring schemas without
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("config source API previews a candidate without calling commit", async () => {
|
||||
const request = {
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
Deno.test("config source API previews adoption change scope without calling commit", async () => {
|
||||
const request = adoptionRequest();
|
||||
const preview: ConfigurationPreviewResponse = {
|
||||
expected_head: request.expected_head,
|
||||
state: tree,
|
||||
changed_paths: [entry.path],
|
||||
changed_subjects: ["subject:one"],
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const result = await previewConfigTree(
|
||||
@@ -351,9 +441,9 @@ Deno.test("config source API previews a candidate without calling commit", async
|
||||
calls.push(String(input));
|
||||
assertEquals(init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(init?.body)), request);
|
||||
return Promise.resolve(response(tree));
|
||||
return Promise.resolve(response(preview));
|
||||
}) as typeof fetch,
|
||||
);
|
||||
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]);
|
||||
assertEquals<unknown>(result, tree);
|
||||
assertEquals(calls, ["/api/w/w%2Fone/config/adoption/preview"]);
|
||||
assertEquals(result, preview);
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("commit formats every working Decodal source without a preview roundtrip", async () => {
|
||||
Deno.test("config adoption formats changed sources and validates a request bound to observed editing metadata", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
||||
@@ -154,16 +154,59 @@ Deno.test("commit formats every working Decodal source without a preview roundtr
|
||||
"all changed Decodal entries should be formatted rather than only the selected source",
|
||||
);
|
||||
assert(
|
||||
source.includes("await formatWorkingSources();") &&
|
||||
source.includes("entrypoints: entrypoints()") &&
|
||||
source.includes("Committed formatted source tree"),
|
||||
"commit should format first and send the working changes directly to Backend authority",
|
||||
source.includes("expected_head: editingMetadata.history_head") &&
|
||||
source.includes("expected_catalog_digest: editingMetadata.catalog_digest") &&
|
||||
source.includes("expected_legacy_export: editingMetadata.expected_legacy_export") &&
|
||||
source.includes("base_digest: baseDigest") &&
|
||||
source.includes("entrypoints: entrypoints()"),
|
||||
"adoption should carry the observed history head and migration conditions separately from the source digest",
|
||||
);
|
||||
const commit = source.slice(
|
||||
source.indexOf("async function commit()"),
|
||||
source.indexOf("async function discardAndReload()"),
|
||||
);
|
||||
const formatted = commit.indexOf("await formatWorkingSources()");
|
||||
const validated = commit.indexOf("await previewConfigTree(");
|
||||
const submitted = commit.indexOf("await submitPending(false)");
|
||||
assert(
|
||||
formatted >= 0 && validated > formatted && submitted > validated,
|
||||
"a new adoption should format and validate with Server authority before submission; local analysis alone is insufficient",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("config editor wires retained unknown requests to an explicit reconciliation action", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes("reconcileConfigTree(pending.workspaceId, pending.request)") &&
|
||||
source.includes("commitConfigTree(pending.workspaceId, pending.request)"),
|
||||
"reconciliation and explicit resubmission should use the retained workspace and complete request, not a new operation identity",
|
||||
);
|
||||
const reconciliation = source.slice(
|
||||
source.indexOf("async function reconcilePending("),
|
||||
source.indexOf("async function submitPending("),
|
||||
);
|
||||
assert(
|
||||
!source.includes("previewConfigTree") &&
|
||||
!source.includes("requestCandidatePreview") &&
|
||||
!source.includes("toolchain_fingerprint"),
|
||||
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint",
|
||||
reconciliation.includes('result.status === "adopted"') &&
|
||||
reconciliation.includes("applyReceipt(result.receipt)") &&
|
||||
!reconciliation.includes("pendingAdoption = null"),
|
||||
"unknown evidence must leave the request retained; only an adopted receipt can be applied",
|
||||
);
|
||||
const action = source.slice(
|
||||
source.indexOf("async function reconcile()"),
|
||||
source.indexOf("async function commit()"),
|
||||
);
|
||||
assert(
|
||||
source.includes("onclick={reconcile}") &&
|
||||
action.includes("await reconcilePending()") &&
|
||||
!action.includes("commitConfigTree(") &&
|
||||
!action.includes("submitPending("),
|
||||
"the explicit reconciliation control must look up the retained outcome without submitting an adoption",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user