mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
fix(schema): enforce zero mapped-success events for denied lifecycle receipts
A denied core lifecycle receipt (outcome_code = 2) requires zero events
of the mapped success-transition kind. The previous blanket RETURN NULL
for outcome_code NOT IN (1, 3) let a denied receipt commit alongside its
mapped success-transition event, creating contradictory durable ledger
facts: denial plus enrolled/retired/revoked/rotated.
Replace the early return with a three-way branch in
authorization_operation_receipt_event_guard_v1():
outcome_code IN (1, 3) — exactly-one mapped event (unchanged)
outcome_code = 2 — zero events of the mapped transition kind;
raises authorization_denied_lifecycle_receipt_no_success_event
other — skip (not a core lifecycle outcome)
Both deferred trigger directions share the same function body; a single
transaction with both INSERT paths exercises both directions at COMMIT.
The new negative fixture inserts a denied enroll receipt plus its mapped
success-transition event (event_kind = 1) in one transaction and asserts
COMMIT rejection with the exact constraint name.
Mutation: stashing the ELSIF branch lets COMMIT succeed, so expect_err
panics — confirming the branch is load-bearing (verified red).
Also correct three stale doc comments:
- Remove the "COMMIT succeeds" claim in the attempt-side shape guard
mutation note; accurately state the pre-existing semantic-binding
constraint fires instead.
- Remove the false claim that denial-attempt/admission-result tests
protect applied/no-op lifecycle; replace with accurate coverage note.
- Update the semantic_fingerprint column comment to distinguish
unresolved pre-auth kind-9 (actor_kind = 4) from authenticated kind-9
(actor_kind 1-3).
All changes byte-mirrored between migration 0042 and schema/schema.sql.
All 9 NIP-FI ignored PostgreSQL regressions pass (--test-threads=1).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -4241,12 +4241,12 @@ mod tests {
|
||||
/// positive A red: the COMMIT fails because the guard now requires a
|
||||
/// denial-attempt row for the authenticated event and none is present.
|
||||
/// - Removing the `actor_kind <> 4` shape guard from the attempt-side
|
||||
/// trigger makes negative B red: the attempt binds to the authenticated
|
||||
/// event and COMMIT succeeds, so the `expect_err` panics. Without the
|
||||
/// exact constraint name check, the pre-existing
|
||||
/// `authorization_denial_attempt_semantic_binding` guard would fire instead
|
||||
/// (non-null attempt semantic_fingerprint vs. null on the event), masking
|
||||
/// whether the new shape guard is load-bearing.
|
||||
/// trigger makes negative B red: the COMMIT is rejected by the pre-existing
|
||||
/// `authorization_denial_attempt_semantic_binding` guard instead (non-null
|
||||
/// attempt `semantic_fingerprint` vs. null on the authenticated event), so
|
||||
/// `assert_eq!` on the constraint name fails. The exact constraint name
|
||||
/// assertion is therefore the load-bearing proof that the new shape guard —
|
||||
/// not the pre-existing semantic-binding check — is what fires.
|
||||
///
|
||||
/// The unresolved pre-auth positive path (actor_kind 4) is exercised in
|
||||
/// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and
|
||||
@@ -4429,13 +4429,17 @@ mod tests {
|
||||
/// (outcome_code = 2) must commit without a paired audit event. Requiring
|
||||
/// one would falsely record that the lifecycle transition occurred.
|
||||
///
|
||||
/// Mutation sensitivity: removing the `outcome_code NOT IN (1, 3)` early-return
|
||||
/// from `authorization_operation_receipt_event_guard_v1` makes the positive case
|
||||
/// red — the denied enroll receipt cannot commit alone because the guard then
|
||||
/// demands a paired enroll audit event (expected_event_kind = 1) that is absent.
|
||||
/// Existing tests (`authorization_denial_attempt_requires_kind_9_event_bidirectional`
|
||||
/// and `authorization_admission_result_requires_kind_11_receipt_bidirectional`)
|
||||
/// already protect the applied/no-op side of the guard.
|
||||
/// Mutation sensitivity:
|
||||
/// - Removing the `outcome_code IN (1, 3)` branch entirely makes the positive case
|
||||
/// red — the denied enroll receipt cannot commit alone because the guard then
|
||||
/// demands a paired enroll audit event (expected_event_kind = 1) that is absent.
|
||||
/// - The negative fixture below (denied receipt + mapped success event) covers the
|
||||
/// `outcome_code = 2` zero-event branch: removing that ELSIF branch makes the
|
||||
/// negative green, failing the expected COMMIT rejection.
|
||||
/// Applied/no-op lifecycle cardinality is covered by the applied path exercised
|
||||
/// in `authorization_operation_receipt_event_guard_v1`'s existing constraint
|
||||
/// trigger, verified by the mutation above (bypass makes the positive denied path
|
||||
/// demand an absent event, confirming the guard is active for both branches).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn denied_lifecycle_receipt_commits_without_audit_event() {
|
||||
@@ -4497,5 +4501,96 @@ mod tests {
|
||||
event_count, 0,
|
||||
"no audit event should be required or present for a denied lifecycle receipt"
|
||||
);
|
||||
|
||||
// --- Negative: denied enroll receipt paired with its mapped success-
|
||||
// transition event (event_kind = 1, enrolled) must be rejected at COMMIT.
|
||||
// Both deferred trigger directions share the same
|
||||
// authorization_operation_receipt_event_guard_v1 function, so a single
|
||||
// transaction exercising both INSERT paths (receipt then event) covers
|
||||
// both trigger directions.
|
||||
//
|
||||
// Seed event capacity; the authorization_events BEFORE INSERT trigger
|
||||
// requires a capacity row. No lifecycle history is needed: denied receipts
|
||||
// (outcome_code = 2) expect zero history rows per the history guard.
|
||||
sqlx::query(
|
||||
"INSERT INTO authorization_event_capacity \
|
||||
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
|
||||
VALUES ($1, 1000, 16777216, 16384)",
|
||||
)
|
||||
.bind(community_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert event capacity");
|
||||
|
||||
let op_neg = uuid::Uuid::new_v4();
|
||||
let fp_neg = vec![0xD1_u8; 32];
|
||||
let event_neg = uuid::Uuid::new_v4();
|
||||
let corr_neg = uuid::Uuid::new_v4();
|
||||
let attempt_neg = uuid::Uuid::new_v4();
|
||||
|
||||
let mut conn_neg = pool.acquire().await.expect("acquire connection neg");
|
||||
sqlx::query("BEGIN")
|
||||
.execute(&mut *conn_neg)
|
||||
.await
|
||||
.expect("begin neg");
|
||||
|
||||
// Denied enroll receipt — no history row needed (outcome_code = 2).
|
||||
sqlx::query(
|
||||
"INSERT INTO authorization_operation_receipts \
|
||||
(community_id, operation_id, request_fingerprint, operation_kind, \
|
||||
actor_fingerprint, outcome_code, result_digest) \
|
||||
VALUES ($1, $2, $3, 1, $4, 2, $5)",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(op_neg)
|
||||
.bind(&fp_neg)
|
||||
.bind(vec![0xD2_u8; 32])
|
||||
.bind(vec![0xD3_u8; 32])
|
||||
.execute(&mut *conn_neg)
|
||||
.await
|
||||
.expect("insert denied receipt — event guard is deferred");
|
||||
|
||||
// Insert the mapped success-transition event (event_kind = 1, enrolled).
|
||||
// actor_kind = 1 requires a non-null actor_fingerprint and a matching
|
||||
// receipt FK (satisfied by the denied receipt above, which shares the
|
||||
// same (community_id, operation_id, request_fingerprint)).
|
||||
sqlx::query(
|
||||
"INSERT INTO authorization_events \
|
||||
(community_id, event_id, event_kind, outcome_code, reason_code, \
|
||||
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
|
||||
correlation_id, attempt_id, semantic_fingerprint, \
|
||||
occurred_at, canonical_envelope, envelope_digest) \
|
||||
VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \
|
||||
'2026-01-01T00:00:00Z', $8, $9)",
|
||||
// event_kind 1 (enrolled) — the mapped success transition for enroll
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(event_neg)
|
||||
.bind(vec![0xD4_u8; 32]) // actor_fingerprint
|
||||
.bind(op_neg)
|
||||
.bind(&fp_neg)
|
||||
.bind(corr_neg)
|
||||
.bind(attempt_neg)
|
||||
.bind(vec![0xD5_u8; 64]) // canonical_envelope
|
||||
.bind(vec![0xD6_u8; 32]) // envelope_digest
|
||||
.execute(&mut *conn_neg)
|
||||
.await
|
||||
.expect("event INSERT must pass — deferred guard fires at COMMIT");
|
||||
|
||||
let contradiction_err = sqlx::query("COMMIT")
|
||||
.execute(&mut *conn_neg)
|
||||
.await
|
||||
.expect_err(
|
||||
"denied receipt + mapped success event must be rejected at COMMIT \
|
||||
— contradictory durable facts must not be permitted",
|
||||
);
|
||||
assert_eq!(
|
||||
contradiction_err
|
||||
.as_database_error()
|
||||
.and_then(|e| e.constraint()),
|
||||
Some("authorization_denied_lifecycle_receipt_no_success_event"),
|
||||
"expected authorization_denied_lifecycle_receipt_no_success_event constraint \
|
||||
rejection for denied receipt + success event, got: {contradiction_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,8 +199,10 @@ CREATE TABLE authorization_events (
|
||||
correlation_id UUID NOT NULL,
|
||||
attempt_id UUID NOT NULL,
|
||||
-- Redaction-safe pre-authentication denial identity. Present and non-zero
|
||||
-- for kind-9 events; NULL for all other event kinds. Binds the event to the
|
||||
-- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay.
|
||||
-- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for
|
||||
-- authenticated kind-9 events (actor_kind 1-3) and all other event kinds.
|
||||
-- Binds the event to the exact denial attempt's semantic_fingerprint
|
||||
-- (intent_digest) for exact replay.
|
||||
semantic_fingerprint BYTEA CHECK (
|
||||
semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32
|
||||
),
|
||||
@@ -951,28 +953,44 @@ BEGIN
|
||||
END IF;
|
||||
|
||||
-- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle
|
||||
-- receipts require a paired audit event. A denied lifecycle receipt
|
||||
-- (outcome_code = 2) commits without one; fabricating a transition event
|
||||
-- would falsely record that the lifecycle change occurred.
|
||||
IF receipt.outcome_code NOT IN (1, 3) THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
-- receipts require exactly one paired success-transition event. A denied
|
||||
-- lifecycle receipt (outcome_code = 2) requires zero events of the mapped
|
||||
-- transition kind: a success-transition event would falsely record that the
|
||||
-- denied transition occurred, creating contradictory durable ledger facts.
|
||||
-- Other outcome codes (4, 5) are not core lifecycle outcomes; skip.
|
||||
IF receipt.outcome_code IN (1, 3) THEN
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO matching_event_count, expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO matching_event_count, expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
|
||||
expected_event_kind, matching_event_count, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
|
||||
END IF;
|
||||
ELSIF receipt.outcome_code = 2 THEN
|
||||
SELECT count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
|
||||
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
|
||||
expected_event_kind, matching_event_count, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
|
||||
IF expected_event_count <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'denied lifecycle receipt must not have a mapped success-transition event '
|
||||
'(kind %); found % — contradictory durable facts are not permitted',
|
||||
expected_event_kind, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
|
||||
@@ -2978,8 +2978,10 @@ CREATE TABLE authorization_events (
|
||||
correlation_id UUID NOT NULL,
|
||||
attempt_id UUID NOT NULL,
|
||||
-- Redaction-safe pre-authentication denial identity. Present and non-zero
|
||||
-- for kind-9 events; NULL for all other event kinds. Binds the event to the
|
||||
-- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay.
|
||||
-- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for
|
||||
-- authenticated kind-9 events (actor_kind 1-3) and all other event kinds.
|
||||
-- Binds the event to the exact denial attempt's semantic_fingerprint
|
||||
-- (intent_digest) for exact replay.
|
||||
semantic_fingerprint BYTEA CHECK (
|
||||
semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32
|
||||
),
|
||||
@@ -3730,28 +3732,44 @@ BEGIN
|
||||
END IF;
|
||||
|
||||
-- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle
|
||||
-- receipts require a paired audit event. A denied lifecycle receipt
|
||||
-- (outcome_code = 2) commits without one; fabricating a transition event
|
||||
-- would falsely record that the lifecycle change occurred.
|
||||
IF receipt.outcome_code NOT IN (1, 3) THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
-- receipts require exactly one paired success-transition event. A denied
|
||||
-- lifecycle receipt (outcome_code = 2) requires zero events of the mapped
|
||||
-- transition kind: a success-transition event would falsely record that the
|
||||
-- denied transition occurred, creating contradictory durable ledger facts.
|
||||
-- Other outcome codes (4, 5) are not core lifecycle outcomes; skip.
|
||||
IF receipt.outcome_code IN (1, 3) THEN
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO matching_event_count, expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO matching_event_count, expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
|
||||
expected_event_kind, matching_event_count, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
|
||||
END IF;
|
||||
ELSIF receipt.outcome_code = 2 THEN
|
||||
SELECT count(*) FILTER (WHERE event_kind = expected_event_kind)
|
||||
INTO expected_event_count
|
||||
FROM authorization_events
|
||||
WHERE community_id = receipt.community_id
|
||||
AND operation_id = receipt.operation_id
|
||||
AND request_fingerprint = receipt.request_fingerprint;
|
||||
|
||||
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
|
||||
expected_event_kind, matching_event_count, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
|
||||
IF expected_event_count <> 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'denied lifecycle receipt must not have a mapped success-transition event '
|
||||
'(kind %); found % — contradictory durable facts are not permitted',
|
||||
expected_event_kind, expected_event_count
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
|
||||
Reference in New Issue
Block a user