diff --git a/crates/registry-breg/Cargo.toml b/crates/registry-breg/Cargo.toml index 5ab3528a9b..d7477c8e57 100644 --- a/crates/registry-breg/Cargo.toml +++ b/crates/registry-breg/Cargo.toml @@ -400,6 +400,16 @@ name = "postgres_proposal" path = "tests/postgres_proposal.rs" required-features = ["postgres-test"] +[[test]] +name = "postgres_proposal_task_authority" +path = "tests/postgres_proposal_task_authority.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_proposal_recovery_permissions" +path = "tests/postgres_proposal_recovery_permissions.rs" +required-features = ["postgres-test", "tooling"] + [[test]] name = "postgres_proposal_human_recovery" path = "tests/postgres_proposal_human_recovery.rs" diff --git a/crates/registry-breg/src/access.rs b/crates/registry-breg/src/access.rs index 3b52a04078..66dd8dd832 100644 --- a/crates/registry-breg/src/access.rs +++ b/crates/registry-breg/src/access.rs @@ -335,7 +335,6 @@ pub(crate) fn compiled_access_findings( // A `self` consent issuer's subject and record targets are bound // through the caller's principal link, not by a row boundary. if reach.rows == "all" - && !reach.owner_only && !link_bound.contains(&reach.source_path) && entities .get(&reach.entity) diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index 22db476478..f1fce5b5ba 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -373,9 +373,11 @@ impl RegistryAuthenticator { fn admit_refusal_warning(last_warning: &Mutex>, now: Instant) -> bool { // One timestamp per authenticator bounds both memory and warning volume, // including concurrent requests with distinct caller-controlled key IDs. - let Ok(mut last) = last_warning.lock() else { - return false; - }; + // A timestamp contains no partial authorization state. Recover it after a + // panic so poisoning cannot permanently silence refusal diagnostics. + let mut last = last_warning + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if last .is_some_and(|previous| now.saturating_duration_since(previous) < REFUSAL_WARNING_INTERVAL) { @@ -870,6 +872,29 @@ pub(crate) fn authentication_refused() -> Response { mod tests { use super::*; + #[test] + fn refusal_warnings_recover_poisoned_timestamps_and_preserve_rate_limits() { + let last_warning = Mutex::new(None); + let now = Instant::now(); + std::thread::scope(|scope| { + let poisoned = scope.spawn(|| { + let _guard = last_warning.lock().expect("initial warning state"); + panic!("canary warning-state poison"); + }); + assert!(poisoned.join().is_err()); + }); + assert!(last_warning.is_poisoned()); + assert!(admit_refusal_warning(&last_warning, now)); + assert!(!admit_refusal_warning( + &last_warning, + now + REFUSAL_WARNING_INTERVAL - Duration::from_nanos(1) + )); + assert!(admit_refusal_warning( + &last_warning, + now + REFUSAL_WARNING_INTERVAL + )); + } + #[test] fn key_refusal_warnings_are_bounded_across_concurrent_misses_and_resume_after_interval() { let last_warning = Mutex::new(None); diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 9d559f2e3e..a8b929188e 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -7,7 +7,9 @@ mod action; pub(crate) use action::erase_expired_action_evidence; pub(crate) use action::{HookChangeApplication, HookChangeOutcome}; mod proposal; -pub(crate) use proposal::{proposal_operation_name, ProposalApplyPreflight}; +pub(crate) use proposal::{ + proposal_operation_name, CheckedProposalTaskAuthority, ProposalApplyPreflight, +}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; diff --git a/crates/registry-breg/src/mutation/proposal.rs b/crates/registry-breg/src/mutation/proposal.rs index a33d1057fe..6abb4de286 100644 --- a/crates/registry-breg/src/mutation/proposal.rs +++ b/crates/registry-breg/src/mutation/proposal.rs @@ -27,6 +27,7 @@ use crate::proposal_workflow::{ pub(crate) enum ProposalApplyPreflight { Receipt(MutationOutcome), Continue { + task_authority: Option, review: Option<( String, registry_review_client::ReviewRequestAccepted, @@ -39,6 +40,42 @@ pub(crate) enum ProposalApplyPreflight { }, } +/// The retained submission authority observed outside the effect transaction. +/// +/// Apply reloads this exact binding under PostgreSQL before releasing effects. +/// The remote authority cannot share that transaction, so the local deadline +/// is also checked at effect admission and again immediately before commit. +pub(crate) struct CheckedProposalTaskAuthority { + retained: Option, +} + +impl CheckedProposalTaskAuthority { + pub(crate) fn new(retained: Option) -> Self { + Self { retained } + } + + fn verify_binding( + &self, + retained: Option<&crate::task_grant::TaskGrantBinding>, + ) -> Result<(), MutationError> { + if retained != self.retained.as_ref() { + return Err(MutationError::PreconditionFailed); + } + self.verify_current() + } + + pub(crate) fn verify_current(&self) -> Result<(), MutationError> { + if self + .retained + .as_ref() + .is_some_and(|binding| !binding.is_current()) + { + return Err(MutationError::PreconditionFailed); + } + Ok(()) + } +} + fn check_current_proposal_grant(authority: &ProposalAuthority) -> Result<(), MutationError> { if authority .task_grant @@ -943,6 +980,13 @@ impl MutationTransaction { return Err(MutationError::PreconditionFailed); } verify_apply_binding(input, proposal)?; + let task_authority = proposal_store::load_task_authority( + tx, + &document.action_id, + id, + document.header.submission_version, + ) + .await?; if !input.authority.is_system() && !crate::api::proposals::external_recovery_actor_allowed( proposal.application_policy(), @@ -1002,6 +1046,7 @@ impl MutationTransaction { vec![] }; Ok(ProposalApplyPreflight::Continue { + task_authority, review: accepted, evidence, }) @@ -1015,6 +1060,7 @@ impl MutationTransaction { input: ProposalMutationInput<'_>, review_evidence: Option<&crate::review_integration::AcceptedReviewEvidence>, commit_evidence: &[crate::action_evidence_client::VerifiedAcquisition], + checked_task_authority: Option<&CheckedProposalTaskAuthority>, deadline: tokio::time::Instant, ) -> Result { let action = proposal_action(registry, input.action_id)?; @@ -1579,6 +1625,16 @@ impl MutationTransaction { .current_proposal() .ok_or(MutationError::PreconditionFailed)?; verify_apply_binding(&input, frozen)?; + let checked_task_authority = + checked_task_authority.ok_or(MutationError::Unavailable)?; + let retained_task_authority = proposal_store::load_task_authority( + tx, + &action.id, + id, + document.header.submission_version, + ) + .await?; + checked_task_authority.verify_binding(retained_task_authority.as_ref())?; if !input.authority.is_system() && !crate::api::proposals::external_recovery_actor_allowed( frozen.application_policy(), @@ -1740,6 +1796,11 @@ impl MutationTransaction { return Err(MutationError::Unavailable); } check_current_proposal_grant(input.authority)?; + if input.operation == ProposalAuthorityOperation::Lifecycle(ProposalOperation::Apply) { + checked_task_authority + .ok_or(MutationError::Unavailable)? + .verify_current()?; + } guarded .commit() .await @@ -1846,7 +1907,6 @@ impl MutationTransaction { if tokio::time::Instant::now() >= deadline { return Err(MutationError::Unavailable); } - check_current_proposal_grant(input.authority)?; if input.operation == ProposalAuthorityOperation::Lifecycle(ProposalOperation::Apply) { let frozen = workflow .current_proposal() @@ -1891,6 +1951,12 @@ impl MutationTransaction { if tokio::time::Instant::now() >= deadline { return Err(MutationError::Unavailable); } + check_current_proposal_grant(input.authority)?; + if input.operation == ProposalAuthorityOperation::Lifecycle(ProposalOperation::Apply) { + checked_task_authority + .ok_or(MutationError::Unavailable)? + .verify_current()?; + } guarded .commit() .await diff --git a/crates/registry-breg/src/postgres/proposal.rs b/crates/registry-breg/src/postgres/proposal.rs index 3f7f433553..ef8a7cd6d4 100644 --- a/crates/registry-breg/src/postgres/proposal.rs +++ b/crates/registry-breg/src/postgres/proposal.rs @@ -6,7 +6,7 @@ use super::*; use crate::api::proposals::{ProposalListQuery, ProposalMutationInput}; use crate::api::VerifiedRequestClaims; use crate::model::ProposalOperation; -use crate::mutation::ProposalApplyPreflight; +use crate::mutation::{CheckedProposalTaskAuthority, ProposalApplyPreflight}; use crate::postgres::{begin_proposal_transaction, ProposalAuthority, ProposalAuthorityOperation}; use crate::proposal_store; use std::collections::{BTreeMap, BTreeSet}; @@ -515,63 +515,72 @@ impl PostgresRecordMutationService { .await .map_err(|_| MutationError::Unavailable)??; } - let (review_evidence, commit_requests) = - if input.operation == ProposalAuthorityOperation::Lifecycle(ProposalOperation::Apply) { - let preflight = { - let client = tokio::time::timeout_at(deadline, self.pool.get()) - .await - .map_err(|_| MutationError::Unavailable)? - .map_err(|_| MutationError::Unavailable)?; - let mut guard = QueryCancellationGuard::new(self.pool.clone(), client); - let preflight = tokio::time::timeout_at( + let (review_evidence, commit_requests, checked_task_authority) = if input.operation + == ProposalAuthorityOperation::Lifecycle(ProposalOperation::Apply) + { + let preflight = { + let client = tokio::time::timeout_at(deadline, self.pool.get()) + .await + .map_err(|_| MutationError::Unavailable)? + .map_err(|_| MutationError::Unavailable)?; + let mut guard = QueryCancellationGuard::new(self.pool.clone(), client); + let preflight = tokio::time::timeout_at( + deadline, + self.coordinator.preflight_proposal_apply( + guard.client(), + &self.registry, + &input, + ), + ) + .await; + match preflight { + Ok(result) => { + guard.disarm(); + result? + } + Err(_) => { + guard.cancel_and_discard().await; + return Err(MutationError::Unavailable); + } + } + }; + let (task_authority, review, evidence) = match preflight { + ProposalApplyPreflight::Receipt(receipt) => return Ok(receipt), + ProposalApplyPreflight::Continue { + task_authority, + review, + evidence, + } => (task_authority, review, evidence), + }; + if let Some(binding) = &task_authority { + tokio::time::timeout_at(deadline, self.coordinator.check_task_authority(binding)) + .await + .map_err(|_| MutationError::Unavailable)??; + } + let checked_task_authority = CheckedProposalTaskAuthority::new(task_authority); + let review_evidence = match review { + None => None, + Some((authority, accepted, result_id)) => { + let source = self + .review_result_source + .as_ref() + .ok_or(MutationError::Unavailable)?; + let fresh = tokio::time::timeout_at( deadline, - self.coordinator.preflight_proposal_apply( - guard.client(), - &self.registry, - &input, - ), + source.approved_evidence(&authority, &accepted), ) - .await; - match preflight { - Ok(result) => { - guard.disarm(); - result? - } - Err(_) => { - guard.cancel_and_discard().await; - return Err(MutationError::Unavailable); - } - } - }; - match preflight { - ProposalApplyPreflight::Receipt(receipt) => return Ok(receipt), - ProposalApplyPreflight::Continue { - review: None, - evidence, - } => (None, evidence), - ProposalApplyPreflight::Continue { - review: Some((authority, accepted, result_id)), - evidence, - } => { - let source = self - .review_result_source - .as_ref() - .ok_or(MutationError::Unavailable)?; - let fresh = tokio::time::timeout_at( - deadline, - source.approved_evidence(&authority, &accepted), - ) - .await - .map_err(|_| MutationError::Unavailable)??; - if result_id.is_some_and(|result_id| fresh.result_id() != result_id) { - return Err(MutationError::ProposalReviewResultMismatch); - } - (Some(fresh), evidence) + .await + .map_err(|_| MutationError::Unavailable)??; + if result_id.is_some_and(|result_id| fresh.result_id() != result_id) { + return Err(MutationError::ProposalReviewResultMismatch); } + Some(fresh) } - } else { - (None, vec![]) }; + (review_evidence, evidence, Some(checked_task_authority)) + } else { + (None, vec![], None) + }; // Preflight admitted and projected only the frozen guard selectors. // Reuse the configured, pinned provider adapter with no checked-out // database connection; the coordinator rechecks those same selectors. @@ -618,6 +627,7 @@ impl PostgresRecordMutationService { input, review_evidence.as_ref(), &commit_evidence, + checked_task_authority.as_ref(), deadline, ), ) diff --git a/crates/registry-breg/src/proposal_retention.rs b/crates/registry-breg/src/proposal_retention.rs index 9ac7f68e0b..88e6286b90 100644 --- a/crates/registry-breg/src/proposal_retention.rs +++ b/crates/registry-breg/src/proposal_retention.rs @@ -913,15 +913,24 @@ pub async fn guard_successor_activation( let CompiledActionExecution::Proposal { proposal } = &action.execution else { return Err(ProposalRetentionError::ActiveProposalRecoveryUnavailable); }; - if !proposal.permissions.iter().any(|grant| { + let inspection_available = proposal.permissions.iter().any(|grant| { grant .operations .contains(&crate::model::ProposalOperation::Get) - }) || !proposal.permissions.iter().any(|grant| { + }); + let cancellation_recovery_available = proposal.permissions.iter().any(|grant| { grant .operations .contains(&crate::model::ProposalOperation::Cancel) - }) { + && grant.operations.iter().any(|operation| { + matches!( + operation, + crate::model::ProposalOperation::Get + | crate::model::ProposalOperation::List + ) + }) + }); + if !inspection_available || !cancellation_recovery_available { return Err(ProposalRetentionError::ActiveProposalRecoveryUnavailable); } } diff --git a/crates/registry-breg/tests/postgres_proposal_recovery_permissions.rs b/crates/registry-breg/tests/postgres_proposal_recovery_permissions.rs new file mode 100644 index 0000000000..558f2fa861 --- /dev/null +++ b/crates/registry-breg/tests/postgres_proposal_recovery_permissions.rs @@ -0,0 +1,536 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Successor activation preserves one usable public recovery path for pending Proposals. +#![cfg(feature = "postgres-test")] + +#[path = "support/client_http.rs"] +#[allow(dead_code)] +mod client_http; +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; +#[cfg(feature = "tooling")] +#[path = "support/proposal_activation.rs"] +mod proposal_activation; + +use registry_breg::api::{VerifiedClaimValue, VerifiedRequestClaims}; +use registry_breg::compiler::{compile_project, CompileProfile}; +use registry_breg::contract::parse_project_json; +use registry_breg::proposal_retention::ProposalRetentionError; +use registry_breg_client::{ + BRegActionInvocationRequest, BRegCreateRequest, BRegDirectWrite, BRegIdempotencyKey, + BRegProposal, BRegProposalQuery, BRegProposalState, BRegRecordFormat, +}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +fn claims() -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated_with_actor_kind( + "sub", + "author", + BTreeSet::from(["proposal:manage".into()]), + Some("registry-administration".into()), + BTreeMap::from([( + "jurisdiction".into(), + VerifiedClaimValue::direct_string("zone-a").unwrap(), + )]), + registry_platform_oidc::ActorKind::Human, + ) + .unwrap() +} + +fn key(value: &str) -> BRegIdempotencyKey { + BRegIdempotencyKey::parse(value).unwrap() +} + +fn map(value: Value) -> Map { + value.as_object().unwrap().clone() +} + +fn registry_source() -> Value { + json!({ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", + "kind":"BRegProject", + "project":{ + "id":"proposal-recovery-permissions", + "version":"1", + "defaultLanguage":"en", + "canonicalBaseIri":"https://proposal-recovery.example.test" + }, + "datasets":[{"id":"directory"}], + "recordTypes":[{ + "id":"target", + "primaryDataset":"directory", + "route":"targets", + "mutationMode":"mutable", + "mutationPolicy":{"patch":"proposal-required"}, + "fields":[ + {"id":"name","type":"string","required":true,"maximumLength":64,"classification":"internal"}, + {"id":"jurisdiction","type":"string","required":true,"maximumLength":64,"classification":"internal"} + ] + }], + "actions":[{ + "id":"correct-name", + "execution":{"mode":"proposal","proposal":{ + "review":{"type":"none"}, + "application":{"mode":"manual"}, + "retention":{"mode":"retain"} + }}, + "inputs":[ + {"id":"target","type":"reference","target":"target","required":true,"classification":"internal"}, + {"id":"value","type":"string","required":true,"maximumLength":64,"classification":"internal"} + ], + "commitGuards":{"targets":[{"input":"target","revision":"unchanged"}]}, + "plan":{"type":"fixed","effects":[{ + "id":"name", + "target":{"fromInput":"target"}, + "operation":"patch", + "set":{"name":{"fromInput":"value"}} + }]} + }], + "accessProfiles":[{ + "id":"clerk", + "default":true, + "principalClaim":"sub", + "requiredScopes":["proposal:manage"], + "requiredPurposes":["registry-administration"], + "permissions":{ + "records":[{ + "recordType":"target", + "operations":["create","get","list"], + "readableFields":["name","jurisdiction"], + "writableFields":["name","jurisdiction"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "actions":[{ + "action":"correct-name", + "operations":["start"], + "targets":[{ + "recordType":"target", + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + }], + "proposals":[{ + "action":"correct-name", + "operations":["get","list","edit","submit","revise","rebase","retry","cancel","apply"], + "ownership":"caller", + "targets":[{ + "recordType":"target", + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + }] + } + }] + }) +} + +fn compile(source: &Value) -> registry_breg::CompiledRegistry { + let project = parse_project_json(&serde_json::to_vec(source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("Proposal recovery fixture compiles") +} + +fn set_operations(profile: &mut Value, operations: Value) { + profile["permissions"]["proposals"][0]["operations"] = operations; +} + +fn add_profile(source: &mut Value, id: &str, operations: Value) { + let mut profile = source["accessProfiles"][0].clone(); + profile["id"] = json!(id); + profile["default"] = json!(false); + profile["permissions"]["records"] = json!([]); + profile["permissions"]["actions"] = json!([]); + set_operations(&mut profile, operations); + source["accessProfiles"] + .as_array_mut() + .unwrap() + .push(profile); +} + +fn split_recovery_source(source: &Value) -> Value { + let mut candidate = source.clone(); + set_operations(&mut candidate["accessProfiles"][0], json!(["get"])); + add_profile(&mut candidate, "canceller", json!(["cancel"])); + candidate +} + +fn listed_recovery_source(source: &Value) -> Value { + let mut candidate = source.clone(); + set_operations(&mut candidate["accessProfiles"][0], json!(["get"])); + add_profile( + &mut candidate, + "recovery-operator", + json!(["list", "cancel"]), + ); + candidate +} + +async fn start_proposal(fixture: &client_http::ClientFixture) -> BRegProposal { + let metadata = fixture + .http + .client + .registry_contract(Some("clerk")) + .await + .unwrap(); + let BRegDirectWrite::Create(create) = metadata + .value + .select_direct_write("records.target.create", "clerk") + .unwrap() + else { + panic!("target create route must be direct"); + }; + let record = fixture + .http + .client + .create_record( + &create, + &BRegCreateRequest::new(map(json!({ + "name":"Before", + "jurisdiction":"zone-a" + }))) + .unwrap(), + &key("recovery-permissions-record"), + BRegRecordFormat::Json, + ) + .await + .unwrap(); + let action = metadata + .value + .select_action("correct-name", "clerk") + .unwrap(); + let input = BRegActionInvocationRequest::new( + &action, + map(json!({ + "target":record.value.data.record_identifier, + "value":"After" + })), + None, + ) + .unwrap(); + fixture + .http + .client + .start_proposal(&action, &input, &key("recovery-permissions-start")) + .await + .unwrap() + .value +} + +async fn submit_proposal( + fixture: &client_http::ClientFixture, + draft: &BRegProposal, +) -> BRegProposal { + let submit = fixture + .http + .client + .prepare_proposal( + draft, + "submit", + json!({}), + &key("recovery-permissions-submit"), + ) + .unwrap(); + fixture + .http + .client + .execute_proposal(&submit) + .await + .unwrap() + .value +} + +fn assert_same_action_revision( + original: ®istry_breg::CompiledRegistry, + candidate: ®istry_breg::CompiledRegistry, +) { + assert_eq!( + original.actions().actions[0].definition_revision, + candidate.actions().actions[0].definition_revision, + "permission-only successors must not reinterpret the frozen Action" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn successor_recovery_requires_one_profile_with_a_condition_source_and_cancel() { + let source = registry_source(); + let original = compile(&source); + let fixture = client_http::ClientFixture::start(original.clone(), claims()).await; + let _draft = start_proposal(&fixture).await; + + let mut get_and_cancel = source.clone(); + set_operations( + &mut get_and_cancel["accessProfiles"][0], + json!(["get", "cancel"]), + ); + let get_and_cancel = compile(&get_and_cancel); + assert_same_action_revision(&original, &get_and_cancel); + registry_breg::proposal_retention::guard_successor_activation( + &fixture.database.admin, + &get_and_cancel, + ) + .await + .expect("one profile can recover through Get and Cancel"); + + let list_and_cancel = compile(&listed_recovery_source(&source)); + assert_same_action_revision(&original, &list_and_cancel); + registry_breg::proposal_retention::guard_successor_activation( + &fixture.database.admin, + &list_and_cancel, + ) + .await + .expect("one profile can recover through a promoted List condition and Cancel"); + + let split = compile(&split_recovery_source(&source)); + assert_same_action_revision(&original, &split); + assert_eq!( + registry_breg::proposal_retention::guard_successor_activation( + &fixture.database.admin, + &split, + ) + .await, + Err(ProposalRetentionError::ActiveProposalRecoveryUnavailable), + "Get and Cancel in different authority contexts cannot produce a usable condition" + ); + + fixture.finish().await; +} + +#[cfg(feature = "tooling")] +struct ActiveReady; + +#[cfg(feature = "tooling")] +impl registry_breg::api::ReadinessProbe for ActiveReady { + fn is_ready(&self) -> registry_breg::api::ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +#[cfg(feature = "tooling")] +async fn successor_client( + fixture: &client_http::ClientFixture, + registry: registry_breg::CompiledRegistry, + identity: registry_breg::postgres::ExpectedRegistryIdentity, +) -> client_http::ClientHttp { + use registry_breg::api::{HttpService, ReadRuntimeIdentity}; + use registry_breg::postgres::{ + PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, + }; + use std::sync::Arc; + + let registry = Arc::new(registry); + let lock = RegistryLockKey::derive(registry.registry_id()).unwrap(); + let audit = registry_breg::audit::test_support::capturing( + registry_platform_audit::AuditProfile::production_from_secret_bytes(vec![0x61; 32].into()) + .unwrap(), + ) + .0; + let cursors = Arc::new( + registry_breg::cursor::CursorCodec::new( + zeroize::Zeroizing::new(vec![0x62; 32]), + std::time::Duration::from_secs(300), + ) + .unwrap(), + ); + let records = PostgresRecordReadService::new( + fixture.pool.clone(), + registry.clone(), + identity.clone(), + lock, + std::time::Duration::from_secs(2), + audit.clone(), + cursors.clone(), + ); + let mutations = PostgresRecordMutationService::new( + fixture.pool.clone(), + registry.clone(), + identity.clone(), + "proposal-recovery-permissions-test", + lock, + std::time::Duration::from_secs(2), + audit, + ); + let service = HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.activation_id, + schema_fingerprint: identity.schema_fingerprint, + }, + Arc::new(records), + Arc::new(ActiveReady), + cursors, + ) + .with_postgres_mutations(Arc::new(mutations)); + client_http::ClientHttp::start(registry_breg::api::router(Arc::new(service)), claims()).await +} + +#[cfg(feature = "tooling")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refused_split_recovery_successor_preserves_activation_and_pending_work() { + use registry_breg::migration::{ + apply_verified_package, ActivationDeployment, ApplyPrecondition, ApplyRoles, ApplyTimeouts, + ApplyVerifiedPackageRequest, MigrationError, + }; + use std::time::Duration; + + let source = registry_source(); + let original = compile(&source); + let fixture = client_http::ClientFixture::start(original.clone(), claims()).await; + let draft = start_proposal(&fixture).await; + let submitted = submit_proposal(&fixture, &draft).await; + let split = split_recovery_source(&source); + assert_same_action_revision(&original, &compile(&split)); + let package = proposal_activation::verified_successor( + &fixture, + &fixture.registry, + &fixture.identity, + &split, + ) + .await; + let before = fixture + .database + .admin + .query_one( + "SELECT maintenance_status,maintenance_target_package_digest, + (SELECT count(*)::bigint FROM registry_internal.registry_migrations) + FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .unwrap(); + let before_status: String = before.get(0); + let before_target: Option = before.get(1); + let before_activations: i64 = before.get(2); + + let request = ApplyVerifiedPackageRequest::new( + &fixture.database.migration_config, + &package, + ActivationDeployment::new( + "local", + "proposal-recovery-permissions-test", + &fixture.identity.database_id, + ), + ApplyPrecondition::Successor { + current: &fixture.identity, + }, + ApplyRoles::new( + &fixture.database.migration_role, + &fixture.database.runtime_role, + ), + ApplyTimeouts::new(Duration::from_secs(2), Duration::from_secs(10)).unwrap(), + fixture.database.activation_audit(), + ); + assert_eq!( + apply_verified_package(request).await, + Err(MigrationError::ActiveProposalRecoveryUnavailable) + ); + + let after = fixture + .database + .admin + .query_one( + "SELECT maintenance_status,maintenance_target_package_digest, + (SELECT count(*)::bigint FROM registry_internal.registry_migrations), + EXISTS (SELECT 1 FROM registry_internal.registry_migrations WHERE package_digest=$1) + FROM registry_internal.registry_state WHERE singleton", + &[&package.package_digest()], + ) + .await + .unwrap(); + assert_eq!(after.get::<_, String>(0), before_status); + assert_eq!(after.get::<_, Option>(1), before_target); + assert_eq!(after.get::<_, i64>(2), before_activations); + assert!( + !after.get::<_, bool>(3), + "refusal must not install a ledger row" + ); + + let current = fixture + .http + .client + .read_proposal(&submitted) + .await + .unwrap() + .value; + assert_eq!(current.state(), BRegProposalState::Submitted); + assert_eq!(current.revision(), submitted.revision()); + assert_eq!(current.effect_digest(), submitted.effect_digest()); + assert!(current.blocked_reason().is_none()); + + fixture.finish().await; +} + +#[cfg(feature = "tooling")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn listed_recovery_profile_cancels_after_real_successor_activation() { + let source = registry_source(); + let original = compile(&source); + let fixture = client_http::ClientFixture::start(original.clone(), claims()).await; + let draft = start_proposal(&fixture).await; + let submitted = submit_proposal(&fixture, &draft).await; + let candidate = listed_recovery_source(&source); + assert_same_action_revision(&original, &compile(&candidate)); + + let (registry, identity) = proposal_activation::activate_successor(&fixture, &candidate).await; + assert_ne!(identity.activation_id, fixture.identity.activation_id); + let current = successor_client(&fixture, registry, identity).await; + let metadata = current + .client + .registry_contract(Some("recovery-operator")) + .await + .unwrap(); + let collection = metadata + .value + .select_proposals("recovery-operator") + .unwrap(); + let listed = current + .client + .list_proposals(&collection, &BRegProposalQuery::default()) + .await + .unwrap() + .value + .items + .into_iter() + .find(|proposal| proposal.id() == submitted.id()) + .expect("List returns the pending Proposal under the recovery profile"); + assert!( + listed + .link("cancel") + .and_then(|link| link.availability()) + .is_none_or(|availability| availability.available), + "List promotes a usable same-profile Cancel condition" + ); + let cancel = current + .client + .prepare_proposal( + &listed, + "cancel", + json!({}), + &key("recovery-permissions-cancel"), + ) + .unwrap(); + let cancelled = current + .client + .execute_proposal(&cancel) + .await + .unwrap() + .value; + assert_eq!(cancelled.state(), BRegProposalState::Cancelled); + assert_eq!(cancelled.revision(), submitted.revision() + 1); + + let clerk_metadata = current + .client + .registry_contract(Some("clerk")) + .await + .unwrap(); + let get = clerk_metadata.value.select_proposal_get("clerk").unwrap(); + let observed = current + .client + .get_proposal(&get, submitted.id()) + .await + .unwrap() + .value; + assert_eq!(observed.state(), BRegProposalState::Cancelled); + assert_eq!(observed.revision(), cancelled.revision()); + + drop(current); + fixture.finish().await; +} diff --git a/crates/registry-breg/tests/postgres_proposal_task_authority.rs b/crates/registry-breg/tests/postgres_proposal_task_authority.rs new file mode 100644 index 0000000000..2a42fd9b7c --- /dev/null +++ b/crates/registry-breg/tests/postgres_proposal_task_authority.rs @@ -0,0 +1,997 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Retained task authority governs every uncommitted Proposal application. +#![cfg(feature = "postgres-test")] + +#[path = "support/client_http.rs"] +#[allow(dead_code)] +mod client_http; +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use registry_breg::{ + api::{VerifiedClaimValue, VerifiedRequestClaims}, + auth::{AuthorityClaimConfig, RegistryAuthenticator}, + compiler::{compile_project, CompileProfile}, + contract::parse_project_json, + task_grant::{TaskGrantBinding, TaskGrantError, TaskGrantStatusChecker}, +}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use reqwest::{Method, StatusCode}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicU8, AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::Notify; + +const ACTIVE: u8 = 0; +const UNAVAILABLE: u8 = 1; +const REFUSED: u8 = 2; + +fn registry(automatic: bool, reviewed: bool) -> registry_breg::CompiledRegistry { + let review = if reviewed { + json!({ + "type":"required", + "authority":"local-review", + "source":"proposal-task-authority", + "policy":{ + "id":"name-correction", + "version":"1", + "digest":format!("sha256:{}", "b".repeat(64)) + } + }) + } else { + json!({"type":"none"}) + }; + let source = json!({ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", + "kind":"BRegProject", + "project":{ + "id":"proposal-task-authority", + "version":"1", + "defaultLanguage":"en", + "canonicalBaseIri":"https://proposal-task-authority.example.test" + }, + "datasets":[{"id":"directory"}], + "reviewAuthorities":if reviewed { + json!([{"id":"local-review","protocol":"id.registrystack.org/protocols/review/v1"}]) + } else { + json!([]) + }, + "recordTypes":[{ + "id":"target", + "primaryDataset":"directory", + "route":"targets", + "mutationMode":"mutable", + "mutationPolicy":{"patch":"proposal-required"}, + "fields":[ + {"id":"name","type":"string","required":true,"maximumLength":64,"classification":"internal"}, + {"id":"jurisdiction","type":"string","required":true,"maximumLength":64,"classification":"internal"} + ] + }], + "actions":[{ + "id":"correct-name", + "execution":{"mode":"proposal","proposal":{ + "review":review, + "application":if automatic { + json!({"mode":"automatic","maximumAttempts":3}) + } else { + json!({"mode":"manual"}) + }, + "retention":{"mode":"operator-erase"} + }}, + "inputs":[ + {"id":"target","type":"reference","target":"target","required":true,"classification":"internal"}, + {"id":"value","type":"string","required":true,"maximumLength":64,"classification":"internal"} + ], + "commitGuards":{"targets":[{"input":"target","revision":"unchanged"}]}, + "plan":{"type":"fixed","effects":[{ + "id":"name", + "target":{"fromInput":"target"}, + "operation":"patch", + "set":{"name":{"fromInput":"value"}} + }]} + }], + "accessProfiles":[ + { + "id":"clerk", + "default":true, + "principalClaim":"registry_principal", + "requiredScopes":["proposal:manage"], + "requiredPurposes":["registry-administration"], + "permissions":{ + "records":[{ + "recordType":"target", + "operations":["create","get","list"], + "readableFields":["name","jurisdiction"], + "writableFields":["name","jurisdiction"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "actions":[{ + "action":"correct-name", + "operations":["start"], + "targets":[{"recordType":"target","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}] + }], + "proposals":[{ + "action":"correct-name", + "operations":["get","edit","submit","retry","cancel","apply"], + "ownership":"caller", + "targets":[{"recordType":"target","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}] + }] + } + }, + { + "id":"task-agent", + "principalClaim":"registry_principal", + "actorKind":"agent", + "requesterClients":["agent-client"], + "requiredScopes":["proposal:manage"], + "requiredPurposes":["registry-administration"], + "taskGrant":{"sourceIssuer":"https://casework.example"}, + "permissions":{ + "actions":[{ + "action":"correct-name", + "operations":["start"], + "targets":[{"recordType":"target","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}] + }], + "proposals":[{ + "action":"correct-name", + "operations":["get","edit","submit","cancel"], + "ownership":"caller", + "targets":[{"recordType":"target","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}] + }] + } + } + ] + }); + compile_project( + &parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .expect("task-authority registry compiles") +} + +fn clerk() -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + "author", + BTreeSet::from(["proposal:manage".into()]), + Some("registry-administration".into()), + BTreeMap::from([( + "jurisdiction".into(), + VerifiedClaimValue::direct_string("zone-a").unwrap(), + )]), + ) + .unwrap() +} + +struct Status { + mode: AtomicU8, + refused_grant: AtomicU64, + calls: AtomicUsize, + pause: AtomicBool, + entered: Notify, + release: Notify, +} + +impl TaskGrantStatusChecker for Status { + fn check<'a>( + &'a self, + binding: &'a TaskGrantBinding, + ) -> std::pin::Pin> + Send + 'a>> + { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + if binding.grant_id() == grant_id(2) && self.pause.load(Ordering::SeqCst) { + self.entered.notify_one(); + self.release.notified().await; + } + match self.mode.load(Ordering::SeqCst) { + UNAVAILABLE => Err(TaskGrantError::Unavailable), + REFUSED + if binding.grant_id() + == grant_id(self.refused_grant.load(Ordering::SeqCst)) => + { + Err(TaskGrantError::Refused) + } + _ => Ok(()), + } + }) + } +} + +fn status() -> Arc { + Arc::new(Status { + mode: AtomicU8::new(ACTIVE), + refused_grant: AtomicU64::new(0), + calls: AtomicUsize::new(0), + pause: AtomicBool::new(false), + entered: Notify::new(), + release: Notify::new(), + }) +} + +struct NoToken; + +#[async_trait::async_trait] +impl registry_platform_httputil::TokenProvider for NoToken { + async fn bearer_token( + &self, + ) -> Result + { + Err(registry_platform_httputil::TokenError::Unavailable) + } +} + +fn review_binding() -> Arc { + use registry_breg::review_store::{ReviewAuthorityClient, ReviewAuthorityRegistry}; + let client = registry_review_client::ReviewClient::new( + registry_review_client::ReviewClientConfig::new("http://127.0.0.1:9/".parse().unwrap()), + ) + .unwrap(); + let authority = Arc::new( + ReviewAuthorityClient::new( + "local-review".into(), + client, + Arc::new(NoToken), + "producer".into(), + "proposal-producer".into(), + 7, + None, + None, + ) + .unwrap(), + ); + Arc::new( + ReviewAuthorityRegistry::new(BTreeMap::from([("local-review".into(), authority)])).unwrap(), + ) +} + +struct ApprovedReview { + calls: AtomicUsize, + result_id: uuid::Uuid, +} + +#[async_trait::async_trait] +impl registry_breg::review_store::ReviewResultSource for ApprovedReview { + async fn approved_evidence( + &self, + authority: &str, + accepted: ®istry_review_client::ReviewRequestAccepted, + ) -> Result< + registry_breg::review_integration::AcceptedReviewEvidence, + registry_breg::mutation::MutationError, + > { + self.calls.fetch_add(1, Ordering::SeqCst); + let now = chrono::Utc::now(); + let result = registry_review_client::ReviewResult { + result_id: self.result_id, + request_id: accepted.request_id, + subject: accepted.subject.clone(), + policy: accepted.policy.clone(), + submission_digest: accepted.submission_digest.clone(), + status: registry_review_client::ReviewResultStatus::Approved, + outcome: Some("approved".to_owned()), + public_reason: None, + result: None, + completed_at: now - chrono::Duration::seconds(1), + available_until: now + chrono::Duration::minutes(5), + }; + registry_breg::review_integration::AcceptedReviewEvidence::from_protocol( + authority, accepted, &result, + ) + .map_err(|_| registry_breg::mutation::MutationError::PreconditionFailed) + } +} + +fn grant_id(grant: u64) -> String { + format!("00000000-0000-4000-8000-{grant:012x}") +} + +fn token(grant: u64, permissions: Value) -> Value { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + json!({ + "aud":"urn:example:breg", + "sub":"author", + "azp":"agent-client", + "registry_principal":"author", + "registry_actor_kind":"agent", + "registry_purpose":"registry-administration", + "scope":"proposal:manage", + "registry_grant_id":grant_id(grant), + "registry_approver":"h:approver", + "registry_grant_source_issuer":"https://casework.example", + "registry_grant_client":"agent-client", + "registry_grant_resource":"urn:example:breg", + "registry_grant_exp":now+600, + "registry_grant_bounds":{"type":"breg","permissions":permissions}, + "identity":{"jurisdiction":"zone-a"}, + "jurisdiction":"zone-b" + }) +} + +async fn authenticator( + registry: ®istry_breg::CompiledRegistry, + idp: &MockIdp, +) -> RegistryAuthenticator { + let mut verifier = oidc_verifier_config(idp.issuer(), vec!["urn:example:breg".into()]); + verifier.allowed_clients = vec!["agent-client".into()]; + RegistryAuthenticator::new( + registry, + verifier, + Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + AuthorityClaimConfig::new("registry_principal", Some("registry_purpose".into())), + ) + .unwrap() +} + +async fn send( + http: &client_http::ClientHttp, + method: Method, + path: &str, + body: Value, + key: Option<&str>, + etag: Option<&str>, +) -> (StatusCode, Value, Option) { + let mut request = reqwest::Client::new() + .request(method, format!("{}{path}", http.base_url)) + .header("content-type", "application/json"); + if let Some(key) = key { + request = request.header("idempotency-key", key); + } + if let Some(etag) = etag { + request = request.header("if-match", etag); + } + if !body.is_null() { + request = request.json(&body); + } + let response = request.send().await.unwrap(); + let status = response.status(); + let etag = response + .headers() + .get("etag") + .map(|value| value.to_str().unwrap().to_owned()); + (status, response.json().await.unwrap(), etag) +} + +async fn create_target(fixture: &client_http::ClientFixture, suffix: &str) -> String { + let (status, value, _) = send( + &fixture.http, + Method::POST, + "/v1/records/targets?accessProfile=clerk", + json!({"data":{"name":"Before","jurisdiction":"zone-a"}}), + Some(&format!("create-target-{suffix}")), + None, + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{value}"); + value["data"]["recordIdentifier"] + .as_str() + .unwrap() + .to_owned() +} + +async fn submit_with_task( + fixture: &client_http::ClientFixture, + auth: &RegistryAuthenticator, + idp: &MockIdp, + target: &str, + suffix: &str, +) -> (String, Value, String) { + let starter = fixture + .client_for( + auth.authenticate(&idp.mint_token(token( + 1, + json!([{"collection":"action:correct-name","operations":["start"]}]), + ))) + .await + .unwrap(), + ) + .await; + let (status, draft, etag) = send( + &starter, + Method::POST, + "/v1/actions/correct-name/proposals?accessProfile=task-agent", + json!({"input":{"target":target,"value":"After"}}), + Some(&format!("start-{suffix}")), + None, + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{draft}"); + let proposal_id = draft["id"].as_str().unwrap().to_owned(); + let lifecycle = fixture + .client_for( + auth.authenticate(&idp.mint_token(token( + 2, + json!([{ + "collection":format!("proposal:{proposal_id}"), + "operations":["get","edit","submit","cancel"] + }]), + ))) + .await + .unwrap(), + ) + .await; + let (status, submitted, submitted_etag) = send( + &lifecycle, + Method::POST, + &format!("/v1/proposals/{proposal_id}/submit?accessProfile=task-agent"), + json!({}), + Some(&format!("submit-{suffix}")), + etag.as_deref(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{submitted}"); + (proposal_id, submitted, submitted_etag.unwrap()) +} + +async fn read_target_name(fixture: &client_http::ClientFixture, target: &str) -> String { + let (status, value, _) = send( + &fixture.http, + Method::GET, + &format!("/v1/records/targets/{target}?accessProfile=clerk"), + Value::Null, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + value["data"]["domainData"]["name"] + .as_str() + .unwrap() + .to_owned() +} + +async fn clerk_proposal( + fixture: &client_http::ClientFixture, + proposal_id: &str, +) -> (Value, String) { + let (status, proposal, etag) = send( + &fixture.http, + Method::GET, + &format!("/v1/proposals/{proposal_id}?accessProfile=clerk"), + Value::Null, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{proposal}"); + (proposal, etag.unwrap()) +} + +async fn accept_review( + fixture: &client_http::ClientFixture, + proposal_id: &str, +) -> registry_review_client::ReviewRequestAccepted { + let id = uuid::Uuid::parse_str(proposal_id).unwrap(); + let row = fixture + .database + .admin + .query_one( + "SELECT create_request,expected_policy,expected_submission_digest + FROM registry_internal.registry_proposal_review_submissions WHERE proposal_id=$1", + &[&id], + ) + .await + .unwrap(); + let create: registry_review_client::ReviewCreateRequest = + serde_json::from_value(row.get(0)).unwrap(); + let accepted = registry_review_client::ReviewRequestAccepted { + request_id: uuid::Uuid::new_v4(), + subject: create.subject, + policy: serde_json::from_value(row.get(1)).unwrap(), + submission_digest: registry_review_client::SubmissionDigest::parse( + &row.get::<_, String>(2), + ) + .unwrap(), + }; + fixture + .database + .admin + .execute( + "UPDATE registry_internal.registry_proposal_review_submissions + SET state='accepted',accepted_binding=$2 WHERE proposal_id=$1", + &[&id, &serde_json::to_value(&accepted).unwrap()], + ) + .await + .unwrap(); + accepted +} + +fn apply_body(submitted: &Value) -> Value { + json!({ + "submissionVersion":submitted["submissionVersion"], + "effectDigest":submitted["effectDigest"] + }) +} + +async fn wait_until_epoch(deadline: i64) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + tokio::time::sleep(Duration::from_millis( + (u128::try_from(deadline).unwrap() * 1_000).saturating_sub(now) as u64 + 50, + )) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn automatic_apply_requires_active_retained_task_authority() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "automatic-revoked").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "automatic-revoked").await; + let before = status.calls.load(Ordering::SeqCst); + status.refused_grant.store(2, Ordering::SeqCst); + status.mode.store(REFUSED, Ordering::SeqCst); + + assert!(fixture + .mutations + .run_automatic_proposal_once_for_test() + .await + .unwrap()); + let row = fixture + .database + .admin + .query_one( + "SELECT state,attempt_count FROM registry_internal.registry_proposal_application_jobs + WHERE proposal_id=$1", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap(); + assert_eq!(status.calls.load(Ordering::SeqCst), before + 1); + assert_eq!(row.get::<_, String>(0), "blocked"); + assert_eq!(row.get::<_, i32>(1), 1); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn separately_authorized_manual_apply_requires_active_retained_task_authority() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(false, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "manual-revoked").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, submitted, _) = + submit_with_task(&fixture, &auth, &idp, &target, "manual-revoked").await; + let (_, etag) = clerk_proposal(&fixture, &proposal_id).await; + let before = status.calls.load(Ordering::SeqCst); + status.refused_grant.store(2, Ordering::SeqCst); + status.mode.store(REFUSED, Ordering::SeqCst); + + let (apply_status, refused, _) = send( + &fixture.http, + Method::POST, + &format!("/v1/proposals/{proposal_id}/apply?accessProfile=clerk"), + apply_body(&submitted), + Some("manual-revoked-apply"), + Some(&etag), + ) + .await; + assert_eq!(apply_status, StatusCode::PRECONDITION_FAILED, "{refused}"); + assert_eq!(status.calls.load(Ordering::SeqCst), before + 1); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn reviewed_manual_apply_checks_retained_authority_before_review_evidence() { + let status = status(); + let checker = status.clone(); + let review = Arc::new(ApprovedReview { + calls: AtomicUsize::new(0), + result_id: uuid::Uuid::new_v4(), + }); + let review_source = review.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(false, true), + clerk(), + move |service| { + service + .with_task_status(checker) + .with_proposal_review_substitute_for_test(review_binding(), review_source) + }, + ) + .await; + let target = create_target(&fixture, "reviewed-revoked").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, submitted, _) = + submit_with_task(&fixture, &auth, &idp, &target, "reviewed-revoked").await; + accept_review(&fixture, &proposal_id).await; + let (_, etag) = clerk_proposal(&fixture, &proposal_id).await; + let before = status.calls.load(Ordering::SeqCst); + status.refused_grant.store(2, Ordering::SeqCst); + status.mode.store(REFUSED, Ordering::SeqCst); + + let (apply_status, refused, _) = send( + &fixture.http, + Method::POST, + &format!("/v1/proposals/{proposal_id}/apply?accessProfile=clerk"), + apply_body(&submitted), + Some("reviewed-revoked-apply"), + Some(&etag), + ) + .await; + assert_eq!(apply_status, StatusCode::PRECONDITION_FAILED, "{refused}"); + assert_eq!(status.calls.load(Ordering::SeqCst), before + 1); + assert_eq!( + review.calls.load(Ordering::SeqCst), + 0, + "review evidence was disclosed after retained task authority failed" + ); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn automatic_apply_rechecks_status_for_each_retry() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "automatic-retry").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "automatic-retry").await; + let before = status.calls.load(Ordering::SeqCst); + + status.mode.store(UNAVAILABLE, Ordering::SeqCst); + assert!(fixture + .mutations + .run_automatic_proposal_once_for_test() + .await + .unwrap()); + let after_outage = status.calls.load(Ordering::SeqCst); + assert_eq!(after_outage, before + 1); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + + status.mode.store(ACTIVE, Ordering::SeqCst); + fixture + .database + .admin + .execute( + "UPDATE registry_internal.registry_proposal_application_jobs + SET next_attempt_at=clock_timestamp() WHERE proposal_id=$1", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap(); + assert!(fixture + .mutations + .run_automatic_proposal_once_for_test() + .await + .unwrap()); + assert_eq!(status.calls.load(Ordering::SeqCst), after_outage + 1); + assert_eq!(read_target_name(&fixture, &target).await, "After"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn automatic_apply_rechecks_retained_deadline_before_effects() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "automatic-expired").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "automatic-expired").await; + fixture.database.admin.execute( + "UPDATE registry_internal.registry_proposal_task_authority + SET binding=jsonb_set(binding,'{expiresAt}',to_jsonb(extract(epoch FROM clock_timestamp())::bigint-1)) + WHERE proposal_id=$1", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ).await.unwrap(); + + assert!(fixture + .mutations + .run_automatic_proposal_once_for_test() + .await + .unwrap()); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn retained_deadline_is_rechecked_after_live_status_before_effects() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "deadline-during-status").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "deadline-during-status").await; + let retained_deadline: i64 = fixture + .database + .admin + .query_one( + "UPDATE registry_internal.registry_proposal_task_authority + SET binding=jsonb_set(binding,'{expiresAt}',to_jsonb(extract(epoch FROM clock_timestamp())::bigint+3)) + WHERE proposal_id=$1 RETURNING (binding->>'expiresAt')::bigint", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap() + .get(0); + status.pause.store(true, Ordering::SeqCst); + + let apply = fixture.mutations.run_automatic_proposal_once_for_test(); + let expire = async { + tokio::time::timeout(Duration::from_secs(2), status.entered.notified()) + .await + .expect("automatic Apply checks the retained grant"); + wait_until_epoch(retained_deadline).await; + status.release.notify_one(); + }; + let (worker, ()) = tokio::join!(apply, expire); + assert!(worker.unwrap()); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn exact_retained_binding_is_rechecked_after_live_status() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "binding-substitution").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "binding-substitution").await; + status.pause.store(true, Ordering::SeqCst); + + let apply = fixture.mutations.run_automatic_proposal_once_for_test(); + let substitute = async { + tokio::time::timeout(Duration::from_secs(2), status.entered.notified()) + .await + .expect("automatic Apply checks the retained grant"); + let proposal_uuid = uuid::Uuid::parse_str(&proposal_id).unwrap(); + let replacement_grant = grant_id(99); + fixture + .database + .admin + .execute( + "UPDATE registry_internal.registry_proposal_task_authority + SET binding=jsonb_set(binding,'{grantId}',to_jsonb($2::text)) + WHERE proposal_id=$1", + &[&proposal_uuid, &replacement_grant], + ) + .await + .unwrap(); + status.release.notify_one(); + }; + let (worker, ()) = tokio::join!(apply, substitute); + assert!(worker.unwrap()); + let job = fixture + .database + .admin + .query_one( + "SELECT state,last_error_code FROM registry_internal.registry_proposal_application_jobs + WHERE proposal_id=$1", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap(); + assert_eq!(job.get::<_, String>(0), "blocked"); + assert_eq!( + job.get::<_, Option>(1).as_deref(), + Some("application-refused") + ); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn retained_deadline_is_rechecked_after_slow_effect_before_commit() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(true, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let target = create_target(&fixture, "deadline-during-effect").await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + let (proposal_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &target, "deadline-during-effect").await; + let retained_deadline: i64 = fixture + .database + .admin + .query_one( + "UPDATE registry_internal.registry_proposal_task_authority + SET binding=jsonb_set(binding,'{expiresAt}',to_jsonb(extract(epoch FROM clock_timestamp())::bigint+3)) + WHERE proposal_id=$1 RETURNING (binding->>'expiresAt')::bigint", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap() + .get(0); + let table = &fixture.registry.entities()["target"].physical_table; + fixture.database.admin.batch_execute(&format!( + "CREATE FUNCTION registry_data.pause_task_authority_effect() RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + PERFORM set_config('lock_timeout','10s',true); + PERFORM pg_advisory_xact_lock(732995002); + RETURN NEW; + END $body$; + CREATE TRIGGER pause_task_authority_effect BEFORE UPDATE ON registry_data.\"{table}\" + FOR EACH ROW EXECUTE FUNCTION registry_data.pause_task_authority_effect()" + )).await.unwrap(); + let (barrier, barrier_task) = fixture.database.connect_migration().await; + barrier + .execute("SELECT pg_advisory_lock(732995002)", &[]) + .await + .unwrap(); + + let mutations = fixture.mutations.clone(); + let applying = + tokio::spawn(async move { mutations.run_automatic_proposal_once_for_test().await }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let waiting: i64 = fixture + .database + .admin + .query_one( + "SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted + AND database=(SELECT oid FROM pg_database WHERE datname=current_database())", + &[], + ) + .await + .unwrap() + .get(0); + if waiting != 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("automatic Apply reached the effect after retained status and admission"); + wait_until_epoch(retained_deadline).await; + barrier + .execute("SELECT pg_advisory_unlock(732995002)", &[]) + .await + .unwrap(); + barrier_task.abort(); + + assert!(applying.await.unwrap().unwrap()); + let job = fixture + .database + .admin + .query_one( + "SELECT state,last_error_code FROM registry_internal.registry_proposal_application_jobs + WHERE proposal_id=$1", + &[&uuid::Uuid::parse_str(&proposal_id).unwrap()], + ) + .await + .unwrap(); + assert_eq!(job.get::<_, String>(0), "blocked"); + assert_eq!( + job.get::<_, Option>(1).as_deref(), + Some("application-refused"), + "the retained deadline fence, not a lock or dependency timeout, refused Apply" + ); + assert_eq!(read_target_name(&fixture, &target).await, "Before"); + fixture.finish().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn completed_apply_receipt_and_owner_cancel_do_not_require_retained_status() { + let status = status(); + let checker = status.clone(); + let fixture = client_http::ClientFixture::start_with_mutations( + registry(false, false), + clerk(), + move |service| service.with_task_status(checker), + ) + .await; + let idp = MockIdp::start().await; + let auth = authenticator(&fixture.registry, &idp).await; + + let applied_target = create_target(&fixture, "receipt").await; + let (applied_id, submitted, _) = + submit_with_task(&fixture, &auth, &idp, &applied_target, "receipt").await; + let (_, etag) = clerk_proposal(&fixture, &applied_id).await; + let apply_path = format!("/v1/proposals/{applied_id}/apply?accessProfile=clerk"); + let body = apply_body(&submitted); + let (apply_status, applied, _) = send( + &fixture.http, + Method::POST, + &apply_path, + body.clone(), + Some("receipt-apply"), + Some(&etag), + ) + .await; + assert_eq!(apply_status, StatusCode::OK, "{applied}"); + + let cancelled_target = create_target(&fixture, "cancel").await; + let (cancelled_id, _, _) = + submit_with_task(&fixture, &auth, &idp, &cancelled_target, "cancel").await; + let (_, cancelled_etag) = clerk_proposal(&fixture, &cancelled_id).await; + status.refused_grant.store(2, Ordering::SeqCst); + status.mode.store(REFUSED, Ordering::SeqCst); + let calls_before_exceptions = status.calls.load(Ordering::SeqCst); + + let (replay_status, replay, _) = send( + &fixture.http, + Method::POST, + &apply_path, + body, + Some("receipt-apply"), + Some(&etag), + ) + .await; + assert_eq!(replay_status, StatusCode::OK, "{replay}"); + let (cancel_status, cancelled, _) = send( + &fixture.http, + Method::POST, + &format!("/v1/proposals/{cancelled_id}/cancel?accessProfile=clerk"), + json!({}), + Some("owner-cancel"), + Some(&cancelled_etag), + ) + .await; + assert_eq!(cancel_status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); + assert_eq!(status.calls.load(Ordering::SeqCst), calls_before_exceptions); + fixture.finish().await; +} diff --git a/crates/registry-breg/tests/support/proposal_review_authority.rs b/crates/registry-breg/tests/support/proposal_review_authority.rs index 15b29d7872..f5ccfe972a 100644 --- a/crates/registry-breg/tests/support/proposal_review_authority.rs +++ b/crates/registry-breg/tests/support/proposal_review_authority.rs @@ -122,6 +122,10 @@ impl LocalAuthority { ) } + #[allow( + dead_code, + reason = "The Proposal compatibility target uses this digest control; other targets share the authority." + )] pub fn replace_submission_digest_for_test( &self, digest: registry_review_client::SubmissionDigest, diff --git a/crates/registry-bregctl/src/check.rs b/crates/registry-bregctl/src/check.rs index 3a352ef6f0..4c2e452400 100644 --- a/crates/registry-bregctl/src/check.rs +++ b/crates/registry-bregctl/src/check.rs @@ -1552,6 +1552,18 @@ fn alternatives(steps: &[Step]) -> Vec> { { under_profile("actions", "action", action, profile, rest) } + [Step::Member(actions), Step::Select { value: action, .. }, Step::Member(execution), Step::Member(proposal), Step::Member(permissions), Step::Select { + key, + value: profile, + }, rest @ ..] + if actions == "actions" + && execution == "execution" + && proposal == "proposal" + && permissions == "permissions" + && key.as_deref() == Some("profile") => + { + under_profile("proposals", "action", action, profile, rest) + } _ => Vec::new(), } } @@ -2087,4 +2099,23 @@ mod tests { "/accessProfiles/1/permissions/actions/0/targets/1/rowBoundaries" ); } + + #[test] + fn a_proposal_permission_path_is_found_under_the_profile_that_grants_it() { + let project = document( + "apiVersion: id.registrystack.org/formats/breg/project/v1alpha1\nkind: BRegProject\nproject:\n id: example\n canonicalBaseIri: https://example.invalid\n version: 0.1.0\n defaultLanguage: en\naccessProfiles:\n - id: reader\n principalClaim: registry_principal\n requiredScopes: unrestricted\n - id: steward\n principalClaim: registry_principal\n requiredScopes: unrestricted\n permissions:\n proposals:\n - action: update-record\n operations: [edit]\n targets:\n - {recordType: other, rowBoundaries: unrestricted}\n - {recordType: record, rowBoundaries: unrestricted}\n", + ); + let steps = parse_steps( + "actions[id=update-record].execution.proposal.permissions[profile=steward].targets[recordType=record].rowBoundaries", + ); + let found = alternatives(&steps) + .iter() + .map(|alternative| resolve(project.root(), alternative)) + .find(|found| found.complete) + .expect("the profile's permission names the Proposal action"); + assert_eq!( + found.pointer, + "/accessProfiles/1/permissions/proposals/0/targets/1/rowBoundaries" + ); + } } diff --git a/crates/registry-bregctl/src/explain_report.rs b/crates/registry-bregctl/src/explain_report.rs index 39603e1eab..943030852b 100644 --- a/crates/registry-bregctl/src/explain_report.rs +++ b/crates/registry-bregctl/src/explain_report.rs @@ -174,7 +174,7 @@ pub(crate) fn actions(value: &Value, lines: &mut Lines) { ), ); } - permissions(&action["permissions"], 2, lines); + action_permissions(&action["permissions"], 2, lines); if action["execution"]["mode"] == "proposal" { let proposal = &action["execution"]["proposal"]; let review = &proposal["review"]; @@ -207,18 +207,135 @@ pub(crate) fn actions(value: &Value, lines: &mut Lines) { 2, "Restricted built-in executor holds the compiled system apply grant.", ); + system_apply_grant(&proposal["systemApplyGrant"], 2, lines); } - permissions(&proposal["permissions"], 2, lines); + proposal_permissions(&proposal["permissions"], &action["inputs"], 2, lines); } } } -fn permissions(value: &Value, depth: usize, lines: &mut Lines) { +pub(crate) fn json_list_or(value: &Value, empty: &str) -> String { + if value.as_array().is_some_and(|values| !values.is_empty()) { + value.to_string() + } else { + empty.to_owned() + } +} + +fn row_restrictions(value: &Value) -> String { + if value.is_null() + || value.as_array().is_some_and(Vec::is_empty) + || value.as_str() == Some("unrestricted") + { + "unrestricted".to_owned() + } else { + value.to_string() + } +} + +fn permission_gates(permission: &Value) -> Vec<(&'static str, String)> { + let mut gates = vec![ + ( + "actor kind", + permission["actorKind"] + .as_str() + .unwrap_or("not declared") + .to_owned(), + ), + ( + "requester clients", + json_list_or(&permission["requesterClients"], "any"), + ), + ( + "principal claim", + permission["principalClaim"] + .as_str() + .unwrap_or("none") + .to_owned(), + ), + ( + "required scopes (all)", + json_list_or(&permission["requiredScopes"], "unrestricted"), + ), + ( + "allowed purposes (any)", + json_list_or(&permission["requiredPurposes"], "unrestricted"), + ), + ]; + if permission["taskGrant"].is_object() { + gates.push(( + "task grant source issuer", + text(&permission["taskGrant"]["sourceIssuer"]).to_owned(), + )); + if permission["taskGrant"]["permissions"] + .as_array() + .is_some_and(|permissions| !permissions.is_empty()) + { + let permissions = items(&permission["taskGrant"]["permissions"]) + .map(|grant| { + format!( + "{}: {}", + text(&grant["collection"]), + names(&grant["operations"], "id") + ) + }) + .collect::>() + .join("; "); + gates.push(("task grant permissions", permissions)); + } + } else { + gates.push(("task grant", "none".to_owned())); + } + gates +} + +fn action_permissions(value: &Value, depth: usize, lines: &mut Lines) { + for permission in items(value) { + lines.listed( + depth, + &format!( + "Action profile {}: {}{}", + text(&permission["profileId"]), + names(&permission["operations"], "id"), + if permission["default"] == true { + " (default)" + } else { + "" + } + ), + ); + lines.pairs_at(depth + 1, &permission_gates(permission)); + lines.pairs_at( + depth + 1, + &[("results", names(&permission["results"], "id"))], + ); + for target in items(&permission["targets"]) { + lines.listed( + depth + 1, + &format!( + "target {}: {} from {}", + text(&target["recordType"]), + text(&target["operation"]), + target["source"] + ), + ); + lines.pairs_at( + depth + 2, + &[( + "row restrictions (all)", + row_restrictions(&target["rowBoundaries"]), + )], + ); + } + } +} + +fn proposal_permissions(value: &Value, action_inputs: &Value, depth: usize, lines: &mut Lines) { for permission in items(value) { lines.listed( depth, &format!( - "profile {}: {}{}", + "Proposal profile {}: {}{}", text(&permission["profileId"]), names(&permission["operations"], "id"), if permission["default"] == true { @@ -228,6 +345,85 @@ fn permissions(value: &Value, depth: usize, lines: &mut Lines) { } ), ); + let mut fields = permission_gates(permission); + fields.extend([ + ( + "ownership", + permission["ownership"] + .as_str() + .unwrap_or("unrestricted") + .to_owned(), + ), + ( + "readable context fields", + names(&permission["readableContextFields"], "id"), + ), + ( + "readable inputs", + names(&permission["readableInputs"], "id"), + ), + ( + "writable inputs", + if permission.get("writableInputs").is_none() + || permission["writableInputs"].is_null() + { + format!("all Action inputs ({})", names(action_inputs, "id")) + } else { + names(&permission["writableInputs"], "id") + }, + ), + ( + "readable attachments", + names(&permission["readableAttachments"], "id"), + ), + ( + "writable attachments", + names(&permission["writableAttachments"], "id"), + ), + ]); + lines.pairs_at(depth + 1, &fields); + for target in items(&permission["targets"]) { + lines.listed( + depth + 1, + &format!("target {}", text(&target["recordType"])), + ); + lines.pairs_at( + depth + 2, + &[( + "row restrictions (all)", + row_restrictions(&target["rowBoundaries"]), + )], + ); + } + } +} + +fn system_apply_grant(grant: &Value, depth: usize, lines: &mut Lines) { + lines.item_at(depth, "system apply grant"); + lines.pairs_at( + depth + 1, + &[ + ("action", text(&grant["actionId"]).to_owned()), + ("action revision", text(&grant["actionRevision"]).to_owned()), + ("grant revision", text(&grant["revision"]).to_owned()), + ], + ); + for target in items(&grant["targets"]) { + lines.listed( + depth + 1, + &format!( + "{} {}", + text(&target["operation"]), + text(&target["recordType"]) + ), + ); + lines.pairs_at( + depth + 2, + &[ + ("writable fields", names(&target["fields"], "id")), + ("readable fields", names(&target["readFields"], "id")), + ], + ); } } @@ -405,6 +601,57 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn permission_gates_quote_literal_identifiers_and_label_empty_lists() { + for identifiers in [ + json!(["none"]), + json!(["any"]), + json!(["unrestricted"]), + json!(["none", "any", "unrestricted"]), + ] { + let expected = identifiers.to_string(); + let literal = permission_gates(&json!({ + "requesterClients": identifiers.clone(), + "requiredScopes": identifiers.clone(), + "requiredPurposes": identifiers + })); + for label in [ + "requester clients", + "required scopes (all)", + "allowed purposes (any)", + ] { + assert_eq!( + literal.iter().find_map( + |(candidate, value)| (*candidate == label).then_some(value.as_str()) + ), + Some(expected.as_str()) + ); + } + } + + for empty in [ + permission_gates(&json!({ + "requesterClients": [], + "requiredScopes": [], + "requiredPurposes": [] + })), + permission_gates(&json!({})), + ] { + for (label, expected) in [ + ("requester clients", "any"), + ("required scopes (all)", "unrestricted"), + ("allowed purposes (any)", "unrestricted"), + ] { + assert_eq!( + empty.iter().find_map( + |(candidate, value)| (*candidate == label).then_some(value.as_str()) + ), + Some(expected) + ); + } + } + } + #[test] fn lifecycle_summary_renders_every_possible_turn_and_its_condition() { let value = json!({"lifecycles": [registry_breg::lifecycle::proposal_lifecycle()]}); diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index d344e585bd..80fc9afe5c 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -11342,6 +11342,9 @@ fn push_access_explanation(explanation: &Value, lines: &mut report::Lines) { "purposeMatching", "rowMatching", "profileSelection", + "relationshipMatching", + "missingClaims", + "evaluation", ] { let sentence = explanation[key].as_str().unwrap_or(""); if !sentence.is_empty() { @@ -11375,6 +11378,7 @@ fn push_access_explanation(explanation: &Value, lines: &mut report::Lines) { } } explain_report::actions(&explanation["actions"]["actions"], lines); + push_row_reach(&explanation["rowReach"], lines); explain_report::datasets(&explanation["statisticalDatasets"], lines); if explanation["consent"].is_object() { lines.blank(); @@ -11382,6 +11386,101 @@ fn push_access_explanation(explanation: &Value, lines: &mut report::Lines) { } } +fn push_row_reach(row_reach: &Value, lines: &mut report::Lines) { + let Some(entries) = row_reach.as_array().filter(|entries| !entries.is_empty()) else { + return; + }; + lines.heading("Target row reach:"); + for reach in entries { + lines.item_at( + 1, + &format!( + "{} as {}", + reach["recordType"].as_str().unwrap_or(""), + reach["profile"].as_str().unwrap_or("") + ), + ); + let mut fields = vec![ + ( + "surface", + reach["surface"].as_str().unwrap_or("").to_owned(), + ), + ("rows", reach["rows"].as_str().unwrap_or("").to_owned()), + ( + "configuration path", + reach["sourcePath"].as_str().unwrap_or("").to_owned(), + ), + ]; + if reach["surface"] == "proposal_target" { + fields.push(( + "Proposal ownership", + if reach["ownerOnly"] == true { + "caller only".to_owned() + } else { + "unrestricted".to_owned() + }, + )); + } + if reach["rowBoundaries"] + .as_array() + .is_some_and(|boundaries| !boundaries.is_empty()) + { + fields.push(("row restrictions (all)", reach["rowBoundaries"].to_string())); + } + if reach["membershipBoundaries"] + .as_array() + .is_some_and(|boundaries| !boundaries.is_empty()) + { + fields.push(( + "membership restrictions (all)", + reach["membershipBoundaries"].to_string(), + )); + } + let fields = fields + .iter() + .map(|(label, value)| (*label, value.clone())) + .collect::>(); + lines.pairs_at(2, &fields); + if reach["actionTargetAuthority"].is_object() { + let authority = &reach["actionTargetAuthority"]; + lines.pairs_at( + 2, + &[ + ( + "Action target enforcement", + authority["enforcement"].as_str().unwrap_or("").to_owned(), + ), + ( + "direct Record grant relationship", + authority["recordGrantRelationship"] + .as_str() + .unwrap_or("") + .to_owned(), + ), + ( + "same-profile direct Record grant", + authority["hasDirectRecordGrant"] + .as_bool() + .unwrap_or(false) + .to_string(), + ), + ], + ); + for use_ in authority["uses"].as_array().into_iter().flatten() { + lines.listed( + 2, + &format!( + "target use {}: {} from {}", + use_["role"].as_str().unwrap_or(""), + use_["operation"].as_str().unwrap_or(""), + use_["source"] + ), + ); + } + } + } +} + /// The `module add consent` explanation: what the generated module holds, the /// vocabularies it added versus reused, the `requireConsent` line each gated /// entity still needs, and whether the project compiles yet. @@ -11622,13 +11721,52 @@ fn push_access_profile(profile: &Value, depth: usize, lines: &mut report::Lines) depth, &format!("profile {}", profile["id"].as_str().unwrap_or("")), ); - let mut fields = vec![( - "principal claim", - profile["principalClaim"] - .as_str() - .unwrap_or("none") - .to_owned(), - )]; + let mut fields = vec![ + ( + "actor kind", + profile["actorKind"] + .as_str() + .unwrap_or("not declared") + .to_owned(), + ), + ( + "requester clients", + explain_report::json_list_or(&profile["requesterClients"], "any"), + ), + ( + "principal claim", + profile["principalClaim"] + .as_str() + .unwrap_or("none") + .to_owned(), + ), + ]; + if profile["taskGrant"].is_object() { + fields.push(( + "task grant source issuer", + profile["taskGrant"]["sourceIssuer"] + .as_str() + .unwrap_or("") + .to_owned(), + )); + let permissions = profile["taskGrant"]["permissions"] + .as_array() + .into_iter() + .flatten() + .map(|permission| { + format!( + "{}: {}", + permission["collection"].as_str().unwrap_or(""), + joined_or(&permission["operations"], "none") + ) + }) + .collect::>(); + if !permissions.is_empty() { + fields.push(("task grant permissions", permissions.join("; "))); + } + } else { + fields.push(("task grant", "none".to_owned())); + } let membership_restricted = profile["membershipBoundaries"] .as_array() .is_some_and(|boundaries| !boundaries.is_empty()); diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index d8527b0edd..497a8e008b 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -4961,6 +4961,209 @@ fn explain_access_includes_action_only_grants_and_target_reach() { .any(|finding| finding["code"] == "breg.access.target-unrestricted-rows")); } +#[test] +fn explain_access_human_reports_profile_gates_permissions_and_target_row_reach() { + let mut source = registry_platform_yaml::Reader::new("registry.yaml") + .scan(include_bytes!( + "../../../products/breg/examples/minimal/registry.yaml" + )) + .unwrap() + .unwrap() + .to_json_value(); + source["accessProfiles"][0]["actorKind"] = json!("agent"); + source["accessProfiles"][0]["requesterClients"] = json!(["task-worker"]); + source["accessProfiles"][0]["taskGrant"] = json!({"sourceIssuer":"https://casework.example"}); + source["accessProfiles"][0]["permissions"]["records"][0]["operations"] = json!(["get", "list"]); + source["accessProfiles"][0]["permissions"]["records"][0]["writableFields"] = json!([]); + let project = + TestProject::from_registry_source(serde_norway::to_string(&source).unwrap().as_bytes()); + + let output = bregctl(&["explain", "access", path(project.path())]); + + assert!(output.status.success(), "{output:?}"); + let human = String::from_utf8(output.stdout).expect("access report is UTF-8"); + let aligned = human.split_whitespace().collect::>().join(" "); + let record_profiles = human + .split("Actions:") + .next() + .expect("Record profiles precede Actions"); + let aligned_record_profiles = record_profiles + .split_whitespace() + .collect::>() + .join(" "); + assert!( + aligned_record_profiles.contains( + "profile directory-editor actor kind agent requester clients [\"task-worker\"]" + ), + "{human}" + ); + for expected in [ + "profile directory-editor", + "actor kind agent", + "requester clients [\"task-worker\"]", + "task grant source issuer https://casework.example", + "Action profile directory-editor", + "required scopes (all) [\"registry:directory:write\"]", + "target contact: patch", + "results contact-email", + "Proposal profile directory-editor", + "ownership caller", + "readable inputs email, jurisdiction, name", + "writable inputs all Action inputs", + "Target row reach:", + "surface record", + "surface action_target", + "surface proposal_target", + "rows claim_bound", + ] { + assert!(aligned.contains(expected), "{expected:?} in {human}"); + } + + let row_reach = human + .split("Target row reach:") + .nth(1) + .expect("the human report has a row-reach section"); + let mut blocks = Vec::>::new(); + for line in row_reach.lines() { + if line.starts_with(" ") && !line.starts_with(" ") { + blocks.push(vec![line]); + } else if line.starts_with(" ") { + if let Some(block) = blocks.last_mut() { + block.push(line); + } + } + } + let blocks = blocks + .iter() + .map(|block| { + block + .join(" ") + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>(); + for surface in ["record", "action_target"] { + let block = blocks + .iter() + .find(|block| block.contains(&format!("surface {surface}"))) + .unwrap_or_else(|| panic!("missing {surface} block in {human}")); + assert!(!block.contains("Proposal ownership"), "{block}"); + } + let proposal = blocks + .iter() + .find(|block| block.contains("surface proposal_target")) + .unwrap_or_else(|| panic!("missing Proposal target block in {human}")); + assert!( + proposal.contains("Proposal ownership caller only"), + "{proposal}" + ); +} + +#[test] +fn explain_access_human_distinguishes_proposal_ownership_and_writable_inputs() { + let source = registry_platform_yaml::Reader::new("registry.yaml") + .scan(include_bytes!( + "../../../products/breg/examples/minimal/registry.yaml" + )) + .unwrap() + .unwrap() + .to_json_value(); + let baseline = + TestProject::from_registry_source(serde_norway::to_string(&source).unwrap().as_bytes()); + let baseline = bregctl(&["explain", "access", path(baseline.path())]); + assert!(baseline.status.success(), "{baseline:?}"); + let baseline = String::from_utf8(baseline.stdout) + .unwrap() + .split_whitespace() + .collect::>() + .join(" "); + assert!(baseline.contains("ownership caller"), "{baseline}"); + assert!( + baseline.contains("writable inputs all Action inputs"), + "{baseline}" + ); + + let mut narrowed = source; + let permission = &mut narrowed["accessProfiles"][0]["permissions"]["proposals"][0]; + permission.as_object_mut().unwrap().remove("ownership"); + permission["writableInputs"] = json!(["email"]); + let narrowed = + TestProject::from_registry_source(serde_norway::to_string(&narrowed).unwrap().as_bytes()); + let narrowed = bregctl(&["explain", "access", path(narrowed.path())]); + assert!(narrowed.status.success(), "{narrowed:?}"); + let narrowed = String::from_utf8(narrowed.stdout) + .unwrap() + .split_whitespace() + .collect::>() + .join(" "); + assert!(narrowed.contains("ownership unrestricted"), "{narrowed}"); + assert!(narrowed.contains("writable inputs email"), "{narrowed}"); + assert_ne!(baseline, narrowed); +} + +#[test] +fn explain_access_human_reports_attachment_and_system_apply_authority() { + let project = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/breg/examples/facility") + .canonicalize() + .unwrap(); + + let output = bregctl(&["explain", "access", path(&project)]); + + assert!(output.status.success(), "{output:?}"); + let human = String::from_utf8(output.stdout).expect("access report is UTF-8"); + let aligned = human.split_whitespace().collect::>().join(" "); + for expected in [ + "Proposal profile facility-correction-author", + "readable attachments supporting-document", + "writable attachments supporting-document", + "system apply grant", + "action correct-address", + "patch facility", + "writable fields address-line, city, postal-code", + "readable fields address-line, city, postal-code", + ] { + assert!(aligned.contains(expected), "{expected:?} in {human}"); + } +} + +#[test] +fn proposal_ownership_does_not_suppress_an_unrestricted_target_warning() { + let mut source = registry_platform_yaml::Reader::new("registry.yaml") + .scan(include_bytes!( + "../../../products/breg/examples/minimal/registry.yaml" + )) + .unwrap() + .unwrap() + .to_json_value(); + source["accessProfiles"][0]["permissions"]["proposals"][0]["targets"][0]["rowBoundaries"] = + json!("unrestricted"); + let project = + TestProject::from_registry_source(serde_norway::to_string(&source).unwrap().as_bytes()); + + let output = bregctl(&["--format", "json", "check", path(project.path())]); + + assert!(output.status.success(), "{output:?}"); + let report = json_stdout(&output); + let finding = report["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["code"] == "breg.access.target-unrestricted-rows") + .unwrap_or_else(|| panic!("missing unrestricted target diagnostic in {report:#}")); + assert_eq!( + finding["path"], + "/accessProfiles/0/permissions/proposals/0/targets/0/rowBoundaries" + ); + assert!( + finding["message"] + .as_str() + .is_some_and(|message| message.contains("Review this registry-wide target authority")), + "{finding:#}" + ); +} + #[test] fn explain_actions_reports_the_normalized_ordered_plan() { let project = TestProject::from_registry_source(action_fixture()); diff --git a/docs/site/src/content/docs/configure/breg-access.mdx b/docs/site/src/content/docs/configure/breg-access.mdx index 3a0fc4bc7d..fd1d4b83c5 100644 --- a/docs/site/src/content/docs/configure/breg-access.mdx +++ b/docs/site/src/content/docs/configure/breg-access.mdx @@ -273,14 +273,21 @@ bregctl explain access facility allowed purposes (any) ["facility-administration"] ``` -The report lists every profile under each Record type with its fields and row restrictions. Each +The human report shows the actor kind, requester clients and task-grant requirements beside +each profile's fields and row restrictions. Action and Proposal permissions include their target +rows, inputs, attachments and ownership. Omitted ownership and writable-input defaults are +explicit. Caller ownership limits which Proposal documents can be managed; it does not bound +the target Records. The report also shows the built-in executor's system application ceiling. Each read-path grant names its target Record type, through Record type, and route. `rowReach` also lists the target under a `read_path` surface with the root profile's row boundaries, so a review that starts from the disclosed Record type does not miss incoming paths. Add `--scenario` with a file of synthetic claims to preview a decision without a token or a database. To prove the refusals against a running registry, write [journeys](../breg-journeys/) that read as both readers. -{/* Evidence: crates/registry-bregctl/src/lib.rs, explain access; crates/registry-breg/src/access_preview.rs. */} +{/* Evidence: crates/registry-bregctl/src/lib.rs, explain access; + crates/registry-bregctl/src/explain_report.rs, actions(); + crates/registry-bregctl/tests/cli.rs, explain_access_human_reports_profile_gates_permissions_and_target_row_reach(); + crates/registry-breg/src/access_preview.rs. */} ## Next diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index 3fc35a15c7..1154eab095 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Revalidate a submitted Proposal's retained task authority on every new + application attempt, including automatic application and separately authorized + manual Apply. Preserve completed receipt recovery and owner cancellation. +- Before a successor package activates, require a usable cancellation path for + pending Proposals: one profile must provide Cancel with Get or List. +- Human-readable access explanations include actor, client and task gates, + Action and Proposal targets, inputs, attachments, ownership and row reach. + Omitted ownership and writable-input defaults are explicit. +- Keep related-record disclosure warnings separate from Action target warnings, + and report unrestricted Proposal targets even when Proposal ownership is + restricted to the caller. +- Preserve bounded authentication-refusal warnings after a poisoned timestamp + lock, while retaining their rate limit. - Name one `ALTER .. OWNER TO ` statement for each registry object a split-role runtime role owns when apply refuses that ownership, in place of `REASSIGN OWNED BY`, which also moved what the runtime role owns outside diff --git a/products/breg/SECURITY-REVIEW-NOTES.md b/products/breg/SECURITY-REVIEW-NOTES.md index 3b9c035703..c792c935cc 100644 --- a/products/breg/SECURITY-REVIEW-NOTES.md +++ b/products/breg/SECURITY-REVIEW-NOTES.md @@ -8,6 +8,48 @@ security-invariant matrix in `contracts/security-invariant-matrix.yaml` is the row-per-invariant baseline; this file is the narrative behind the decisions and the residuals the matrix rows do not state. +## Proposal task authority, recovery and access explanations + +A new Proposal application requires both current executor authority and the +original task authority retained for its exact submission, when one exists. +Apply preflight establishes current disclosure and the frozen submission before +loading that binding. The service observes current status outside the mutation +transaction, before acquiring Review or commit Evidence. The mutation then +reloads and compares the exact binding and checks its local deadline before +effects and at the final commit fence. Every new application attempt obtains +its own status observation. A separately authorized human executor and the +restricted built-in system executor keep their existing independent ceilings. + +An exact completed receipt returns under current disclosure without another +retained-task status request. Cancellation and a Review rejection do not need +the old submitter's grant to remain active. Status refusal is a failed +precondition; an unavailable dependency is unavailable and remains eligible for +the existing bounded worker retry. A source can revoke after the remote status +observation and before local commit: BReg and the source share no distributed +transaction. Local expiry and submission-binding checks remain enforced. + +Successor activation keeps pending Proposals inspectable and requires one +Proposal grant that can both obtain a condition, through Get or List, and +Cancel. An inspection grant still has to exist. The condition remains bound to +the selected profile, principal, purpose and target bounds. The activation +guard checks compiled permission shape; it does not prove that an operator has +provisioned a real identity satisfying the profile's claims and row bounds. + +Human access explanations render the compiled actor, client, task, target, +input, attachment and ownership gates, the system application ceiling and row +reach. Caller ownership restricts Proposal documents, not the target Records. +Read paths retain their related-disclosure finding without being classified as +Action targets. These are review aids; runtime admission remains authoritative. +Authentication refusal warnings recover a poisoned timestamp while retaining +their existing rate limit; warning-state recovery supplies no caller authority. + +The PostgreSQL `postgres_proposal_task_authority` and +`postgres_proposal_recovery_permissions` suites exercise the real public +lifecycle and worker boundaries, including successful receipt and cancellation +paths and refused activation preserving pending work. The CLI explanation and +consent-access tests cover human-readable authority and the distinct warning +surfaces. The authentication unit test pins warning recovery and rate limiting. + ## Generated business identifiers The public code is an immutable stored field, while the record UUID remains diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index 07d93557a7..85fb5708c9 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -92,6 +92,12 @@ taskGrantStatus: statusResource: urn:casework:case-management ``` +Use a lowercase `https://` source identifier in each BReg task profile and copy +it exactly into the matching runtime entry. The shared runtime format also +accepts other absolute issuer identifiers, such as URNs; this does not make +them valid BReg profile issuers or grant authority. A source identifier selects +a configured service and is never used as an outbound URL. + `statusScope` and `statusPath` are optional. Omitted, BReg requests the `casework:grants:status` scope and calls Casework's `/v1/task-grants/{grantId}/status` route under `baseUrl`. Set them only for a @@ -125,8 +131,10 @@ original principal, client, resource, purpose, bounds, subjects, and deadline. Submission freezes the original task authority alongside the proposal version and its immutable effects. A later human reviewer remains a separate actor. -Approval and application check the original grant, including automatic -application. A database concurrency retry makes a fresh status check. +Before applying a submitted Proposal, BReg checks the original grant in +addition to the current executor's authority, including for automatic +application and separately authorized manual Apply. Every new application +attempt makes a fresh status check. An exact completed retry recovers the existing receipt under current disclosure authority. It does not perform a new mutation or require a new positive status @@ -146,10 +154,12 @@ a failed precondition. Human rejection and owner cancellation keep their existin permissions. These actions do not require the old submitter grant to remain active. Read cursors and representation ETags do not incorporate grant IDs. -The bounded status check uses the shared platform exact-status transport and -runs while the proposal is locked. Its total five-second bound includes obtaining -the status service credential; positive status is never cached. BReg keeps its -own persisted binding and BReg-only bounds validation. Deferred Casework grants +The bounded status check uses the shared platform exact-status transport +outside the mutation transaction, after preflight identifies the exact retained +submission. Its total five-second bound includes obtaining the status service +credential; positive status is never cached. The mutation transaction reloads +the retained binding and checks equality and local expiry before effects and +again checks expiry before commit. BReg keeps its own persisted binding and BReg-only bounds validation. Deferred Casework grants use the same status contract and original deadline, without changing BReg's write or human approval permissions. Casework revocation can still occur between that check and the local commit. BReg and Casework do diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index a2517e9bf7..c10d4e2f56 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -64,7 +64,7 @@ invariants: - {id: BREG-SEC-59, state: enforced, targetWave: W4, threat: Concurrent delegated calls cross-send bearer credentials., enforcementPoint: immutable request-scoped client configuration and shared pooled transport, refusal: Keep each derived client credential isolated without changing source-bound handles., negativeId: BREG-NEG-59, negativeTest: {path: crates/registry-breg-client/tests/http_boundary.rs, name: caller_scoped_tokens_are_isolated_across_concurrent_requests}} - {id: BREG-SEC-60, state: enforced, targetWave: W4, threat: Durable lifecycle recovery retains unrelated source content or rebinds an uncertain action., enforcementPoint: minimal versioned prepared lifecycle evidence and current metadata authority validation, refusal: Exclude record and preview content and refuse changed source or action bindings while replaying exact original intent., negativeId: BREG-NEG-60, negativeTest: {path: crates/registry-breg/tests/postgres_proposal.rs, name: prepared_lifecycle_recovery_uses_current_authority_and_the_original_exact_condition}} - {id: BREG-SEC-63, state: enforced, targetWave: W5, threat: Internal Proposal audit actor references expose raw principals or link them across databases and issuers., enforcementPoint: 'keyed Proposal actor reference scoped to source database and issuer, separate from stable internal owner identity', refusal: 'Never expose raw principals in the actor reference; vary it across databases, issuers, keys, and principals while preserving activation-independent audit correlation.', negativeId: BREG-NEG-63, negativeTest: {path: crates/registry-breg/src/mutation/proposal.rs, name: proposal_actor_is_keyed_database_and_issuer_scoped_but_stable_across_activation}} - - {id: BREG-SEC-64, state: enforced, targetWave: W5, threat: Revoked task authority commits another draft or proposal mutation or a later reviewer replaces the original authority or a retry reapplies a completed proposal., enforcementPoint: verified identity-bound HTTP claims propagated into SQL context fresh per-attempt status outside the guarded apply transaction and exact source-owned proposal authority revalidation inside that transaction before effects, refusal: Refuse revoked unavailable expired or substituted authority before new effects and each SQL retry while recovering completed receipts only under current disclosure. Revocation after the bounded status response is not cross-service atomic and remains bounded by grant expiration., negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_proposal_security.rs, name: signed_proposal_start_and_lifecycle_receipts_reject_sibling_grants}} + - {id: BREG-SEC-64, state: enforced, targetWave: W5, threat: Revoked task authority commits another draft or proposal mutation or a later reviewer replaces the original authority or a retry reapplies a completed proposal., enforcementPoint: verified identity-bound HTTP claims propagated into SQL context fresh per-attempt status outside the guarded apply transaction and exact source-owned proposal authority revalidation inside that transaction before effects, refusal: Refuse revoked unavailable expired or substituted authority before new effects and each SQL retry while recovering completed receipts only under current disclosure. Revocation after the bounded status response is not cross-service atomic and remains bounded by grant expiration., negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_proposal_task_authority.rs, name: automatic_apply_requires_active_retained_task_authority}} - {id: BREG-SEC-65, state: enforced, targetWave: W5, threat: 'A native action widens a frozen commit Evidence guard, accepts mismatched signed Evidence, or reacquires it during exact receipt recovery.', enforcementPoint: 'compiled commit Evidence selectors and guards, signed assertion verification, frozen candidate binding, and atomic Evidence retention', refusal: Refuse a mismatched or stale guard assertion before effects; commit retained Evidence with effects and replay committed receipts without another acquisition., negativeId: BREG-NEG-65, negativeTest: {path: crates/registry-breg/tests/postgres_action_evidence.rs, name: commit_evidence_is_guard_only_and_replay_never_reacquires}} - {id: BREG-SEC-66, state: enforced, targetWave: W5, threat: "A healthy retained local delivery blocks restart because verification treats its absent logical destination as a broken binding.", enforcementPoint: handler-kind-branched retained-binding verification over the destination and handler seams, refusal: "Verify a retained url row against its activated destination and a retained local row against the deployed reviewed program digest, refusing startup only on a genuine mismatch.", negativeId: BREG-NEG-66, negativeTest: {path: crates/registry-breg/tests/postgres_hook_changes.rs, name: real_postgres_a_retained_local_delivery_survives_restart_verification}} - {id: BREG-SEC-67, state: enforced, targetWave: W5, threat: "A changed or superseded handler answer reapplies or hides one delivery after its first application committed, its activation changed, or its lease was lost.", enforcementPoint: "delivery-scoped idempotency with the accepted answer digest, cancellation-safe activation and proposal locks, live ready-activation classification, exact claimed-lease revalidation before mutation, and terminal URL completion under the same registry/database and lease fence", refusal: "Serialize application and recovery; preserve receipt replay and changed-answer conflict; refuse uncommitted old-activation answers without replay; record only settled terminal URL decisions after activation; keep lost leases, wrong or unreadable identity, maintenance, and uncertain application fail-closed.", negativeId: BREG-NEG-67, negativeTest: {path: crates/registry-breg/tests/support/hook_activation.rs, name: classified_proposal_completion_preserves_registry_and_lease_fences}} @@ -198,3 +198,4 @@ invariants: # Request parsing audit proof: crates/registry-breg/tests/postgres_proposal_security.rs::task_grant_context_malformed_requests_are_audited_and_fail_closed - {"id": "BREG-SEC-192", "state": "enforced", "targetWave": "W5", "threat": "A service obtains task-grant context for a different accepted review, a changed policy or subject, or after current source consent and review authority are withdrawn; an authenticated malformed request bypasses the known Proposal refusal journal.", "enforcementPoint": "Selected-profile GetTaskGrantContext admission before exact media and bounded strict body decoding; every admitted request refusal uses the Proposal audit gate. Source-authored field projection precedes Record field authority resolution, followed by exact accepted review request and concrete policy binding, current row and consent checks, and a shared review-row lock through disclosure.", "refusal": "Return stable media or body refusals only after a minimized audit append, and return unavailable when that append fails. Conceal pre-acceptance, withdrawn, stale or mismatched requests; expose only the requested permitted fields and no human-context fallback. A withdrawal committed first prevents disclosure; a concurrent accepted disclosure completes before withdrawal.", "negativeId": "BREG-NEG-192", "negativeTest": {"path": "crates/registry-breg/tests/postgres_proposal_security.rs", "name": "review_status_service_reads_only_its_exact_frozen_review_binding"}} - {"id": "BREG-SEC-193", "state": "enforced", "targetWave": "W5", "threat": "A caller reuses another source client binding or supplies malformed task identity and field selectors before a service credential is acquired.", "enforcementPoint": "Metadata-selected task-context binding and exact bounded request validation in the public BReg client before credential acquisition or HTTP.", "refusal": "Refuse cross-source bindings, unrelated actions, invalid correlation or policy identities, empty or duplicate fields and fields outside the source-authored subset without acquiring credentials or sending a request.", "negativeId": "BREG-NEG-193", "negativeTest": {"path": "crates/registry-breg-client/src/proposal.rs", "name": "task_context_refuses_invalid_or_cross_source_authority_before_credentials"}} + - {"id": "BREG-SEC-194", "state": "enforced", "targetWave": "W5", "threat": "A successor package leaves pending Proposals with inspection and cancellation grants that cannot produce a usable profile-bound condition.", "enforcementPoint": "Successor activation recovery guard requires Get inspection and one Proposal grant with Cancel plus Get or List; ordinary profile-bound ETags remain enforced.", "refusal": "Refuse activation before changing the ledger or pending work when no complete recovery permission exists. Retain same-profile Get+Cancel and List+Cancel recovery.", "negativeId": "BREG-NEG-194", "negativeTest": {"path": "crates/registry-breg/tests/postgres_proposal_recovery_permissions.rs", "name": "refused_split_recovery_successor_preserves_activation_and_pending_work"}} diff --git a/products/breg/contracts/security-test-traceability.yaml b/products/breg/contracts/security-test-traceability.yaml index 5a5916ca1e..0f2f1ce034 100644 --- a/products/breg/contracts/security-test-traceability.yaml +++ b/products/breg/contracts/security-test-traceability.yaml @@ -63,7 +63,7 @@ traceability: - {id: BREG-SEC-59, state: enforced, negativeId: BREG-NEG-59, negativeTest: {path: crates/registry-breg-client/tests/http_boundary.rs, name: caller_scoped_tokens_are_isolated_across_concurrent_requests}} - {id: BREG-SEC-60, state: enforced, negativeId: BREG-NEG-60, negativeTest: {path: crates/registry-breg/tests/postgres_proposal.rs, name: prepared_lifecycle_recovery_uses_current_authority_and_the_original_exact_condition}} - {id: BREG-SEC-63, state: enforced, negativeId: BREG-NEG-63, negativeTest: {path: crates/registry-breg/src/mutation/proposal.rs, name: proposal_actor_is_keyed_database_and_issuer_scoped_but_stable_across_activation}} - - {id: BREG-SEC-64, state: enforced, negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_proposal_security.rs, name: signed_proposal_start_and_lifecycle_receipts_reject_sibling_grants}} + - {id: BREG-SEC-64, state: enforced, negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_proposal_task_authority.rs, name: automatic_apply_requires_active_retained_task_authority}} - {id: BREG-SEC-65, state: enforced, negativeId: BREG-NEG-65, negativeTest: {path: crates/registry-breg/tests/postgres_action_evidence.rs, name: commit_evidence_is_guard_only_and_replay_never_reacquires}} - {id: BREG-SEC-66, state: enforced, negativeId: BREG-NEG-66, negativeTest: {path: crates/registry-breg/tests/postgres_hook_changes.rs, name: real_postgres_a_retained_local_delivery_survives_restart_verification}} - {id: BREG-SEC-67, state: enforced, negativeId: BREG-NEG-67, negativeTest: {path: crates/registry-breg/tests/support/hook_activation.rs, name: classified_proposal_completion_preserves_registry_and_lease_fences}} @@ -200,3 +200,4 @@ traceability: # Request parsing audit proof: crates/registry-breg/tests/postgres_proposal_security.rs::task_grant_context_malformed_requests_are_audited_and_fail_closed - {"id": "BREG-SEC-192", "state": "enforced", "negativeId": "BREG-NEG-192", "negativeTest": {"path": "crates/registry-breg/tests/postgres_proposal_security.rs", "name": "review_status_service_reads_only_its_exact_frozen_review_binding"}} - {"id": "BREG-SEC-193", "state": "enforced", "negativeId": "BREG-NEG-193", "negativeTest": {"path": "crates/registry-breg-client/src/proposal.rs", "name": "task_context_refuses_invalid_or_cross_source_authority_before_credentials"}} + - {"id": "BREG-SEC-194", "state": "enforced", "negativeId": "BREG-NEG-194", "negativeTest": {"path": "crates/registry-breg/tests/postgres_proposal_recovery_permissions.rs", "name": "refused_split_recovery_successor_preserves_activation_and_pending_work"}} diff --git a/products/breg/scripts/test-postgres.sh b/products/breg/scripts/test-postgres.sh index 0a6908e89d..b0ecd33eed 100755 --- a/products/breg/scripts/test-postgres.sh +++ b/products/breg/scripts/test-postgres.sh @@ -102,6 +102,8 @@ postgres_targets=( postgres_import_authority postgres_refactor postgres_proposal + postgres_proposal_task_authority + postgres_proposal_recovery_permissions postgres_proposal_generated_identifiers postgres_proposal_shape_activation postgres_native_retention diff --git a/products/breg/scripts/test_validate_product.py b/products/breg/scripts/test_validate_product.py index e82a74bae1..4161bc010a 100644 --- a/products/breg/scripts/test_validate_product.py +++ b/products/breg/scripts/test_validate_product.py @@ -71,7 +71,7 @@ def test_security_range_is_closed_through_derived_sql_invariants(self) -> None: ) extension_rows = matrix["invariants"][24:] self.assertEqual( - [f"BREG-NEG-{index:02d}" for index in range(25, 194) if index not in {61, 62}], + [f"BREG-NEG-{index:02d}" for index in range(25, 195) if index not in {61, 62}], [invariant["negativeId"] for invariant in extension_rows], ) for invariant in extension_rows: diff --git a/products/breg/scripts/validate_product.py b/products/breg/scripts/validate_product.py index 4bf9cab26b..6e66089770 100644 --- a/products/breg/scripts/validate_product.py +++ b/products/breg/scripts/validate_product.py @@ -30,7 +30,7 @@ CONTRACT_STATES = {"enforced", "partial", "planned"} V1_REQUIREMENT_IDS = tuple(f"BREG-V1-{index:02d}" for index in range(1, 45)) ACCEPTANCE_JOURNEY_IDS = tuple(f"BREG-J{index:02d}" for index in range(1, 25)) -SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 194) if index not in {61, 62}) +SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 195) if index not in {61, 62}) ACCEPTANCE_FIXTURES = { "BREG-J01": ("asset-site-placement", "acceptance/asset-site-placement"), "BREG-J02": ("asset-site-placement", "acceptance/asset-site-placement"), @@ -139,6 +139,8 @@ "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_import_authority", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_refactor", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_proposal", + "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_proposal_task_authority", + "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_proposal_recovery_permissions", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_proposal_generated_identifiers", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_proposal_shape_activation", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_native_retention", diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index 6dcc55a3ef..f5d724013d 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Upgrade the P-256 dependency stack to use `crypto-bigint` 0.7.6, addressing + RUSTSEC-2026-0387. Key generation retains operating-system randomness and + existing SEC1 and JWK formats; signing algorithms and providers are unchanged. + - `registry-platform-activation` advises an exact, quoted `ALTER ... OWNER TO` statement for each product schema object a runtime role owns or inherits ownership of, in place of `REASSIGN OWNED BY`, which also moves the role's