mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
PR 2 of the NIP-FI plan: the schema foundation. Establishes the durable server-side identity ledger and final-admission surface that the runtime phases build on. All of Phase A's migrations live here; later phases own their own deltas. Depends on nothing — PR 1 (#6776, merged) owned zero migration files. This PR's relations are shaped to store exactly what PR 1's verifier produces: issuer-qualified identity and the four denial classes. They meet in a later PR that writes a verified assertion into these tables in one transaction. ## Two internally-ordered migrations - `0041_nip_fi_identity_foundation.sql` (migration A) — core identity + base-lifecycle relations (5 tables): issuer-qualified `(iss, sub)` bindings, lifecycle history/selectors, enrollment policies, and operation receipts. Applies cleanly to current `main`. - `0042_nip_fi_authorization_foundation.sql` (migration B) — the final-admission surface (10 tables): authorization events + capacity, admission results, replay/receipt guards, audit, invalidation domains/floors, protected-object authority, authority epochs, and restore version deltas. Applies to A's resulting state. Fifteen NIP-FI relations total, zero dangling foreign keys. Identity is issuer-qualified throughout — no single-global-issuer assumption in any relation, no `Block`-hardcoding. A single deployment may run one issuer; that is config, not schema. ## Durable, immutable ledger posture All 15 relations are append-only (immutable `no_delete`/`no_truncate` triggers) and carry `community_id` as provenance, not ownership. Both migrations widen the single SQL source of truth `community_write_fence_excluded_table` so the relations are never fence-attached, never purged on community deletion, and never counted as tenant-scoped drift by the deletion control plane's exact-set catalog check — the same posture main already applies to `product_feedback` and `rate_limit_violations`. `schema/schema.sql` keeps one consolidated definition of that function whose exclusion array byte-matches `0042`, guarded by a parity assertion so a future consolidation cannot silently drop NIP-FI relations from the ledger. This makes a tenant's identity/authorization ledger survive community deletion, per the spec's `FI-INV-02` (durable binding) and `FI-INV-03` (tombstone monotonicity) and `NIP-FI.md`'s "durable server state" ruling. `communities(id)` FK never dangles: community rows become permanent tombstones, never hard-deleted. ## Authorization shape and cardinality contracts Authenticated `OperatorDenied` events (`actor_kind` 1–3, non-null `request_fingerprint`) carry a null `semantic_fingerprint` and commit without a denial-attempt row. The denial-attempt cardinality and shape guards are scoped to unresolved pre-auth kind-9 events (`actor_kind = 4`). Applied and no-op lifecycle receipts (`outcome_code IN (1, 3)`) require exactly one mapped success-transition event; denied lifecycle receipts (`outcome_code = 2`) require zero events from the complete core lifecycle success-transition class (kinds 1, 2, 3, 6: enrolled, revoked, rotated, retired) — any such event paired with a denied receipt would record a transition that never occurred. ## Mined vs. new Re-cut from Franco's #1476 (`0029`/`0030`) and Cea's #4772 committer schema, re-cut along FK topology and renumbered above the live `main` tip. The buzz-auth core of #1476 is Cea-authored; `Co-authored-by` reflects verified per-commit authorship of the mined schema. Zero Rust/`deletion.rs` edits — the migration-only exclusion widening keeps `EXPECTED_SCOPED_TABLES` untouched. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
1039 lines
49 KiB
PL/PgSQL
1039 lines
49 KiB
PL/PgSQL
-- Provider-free NIP-FI authorization, audit, fencing, and restore foundation.
|
|
--
|
|
-- There is no provider registry/SPI/profile/evidence table, durable lease or
|
|
-- audio admission ledger, 30382 projection, delivery queue, exporter claim,
|
|
-- acknowledgement, retry scheduler, or online retention/compaction workflow.
|
|
--
|
|
-- This migration applies to migration 0041's resulting state. Its scope is the
|
|
-- NIP-FI *final-admission* surface: replay/receipt, audit events, invalidation,
|
|
-- capacity, protected-object authority, restore version deltas, and the closed
|
|
-- admission result. Closed vocabularies below carry only the core subset;
|
|
-- delegation coordinates (owner/relationship columns, invalidation selector 7,
|
|
-- version-delta component kind 6) are deferred to the FI-DELEG migration and
|
|
-- extended-lifecycle audit kinds (recover, enable, disable, admission-loss;
|
|
-- version-delta component kind 7) to the FI-LIFECYCLE migration, matching
|
|
-- 0041's carve. A later migration widens these additively; nothing here
|
|
-- presumes a single global issuer.
|
|
|
|
-- Durable one-way activation marker and current domain invalidation generation.
|
|
CREATE TABLE authorization_invalidation_domains (
|
|
community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id),
|
|
current_generation BIGINT NOT NULL CHECK (current_generation >= 0),
|
|
activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp()
|
|
);
|
|
|
|
-- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain,
|
|
-- 6 configuration revision. Selector 7 (delegated relationship) and its
|
|
-- relationship-revision floor are deferred to the FI-DELEG migration.
|
|
CREATE TABLE authorization_invalidation_floors (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)),
|
|
selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32),
|
|
floor_generation BIGINT NOT NULL CHECK (floor_generation > 0),
|
|
binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0),
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
PRIMARY KEY (community_id, selector_kind, selector_fingerprint),
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
CHECK (
|
|
(selector_kind = 3 AND binding_version_floor IS NOT NULL)
|
|
OR (selector_kind <> 3 AND binding_version_floor IS NULL)
|
|
)
|
|
);
|
|
|
|
-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media,
|
|
-- 5 moderation target, 6 audio session. Kind 7 is retired: current binding
|
|
-- status is connection-local evidence and never a durable protected object.
|
|
CREATE TABLE authorization_authority_epochs (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)),
|
|
object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32),
|
|
authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0),
|
|
fence BYTEA NOT NULL CHECK (
|
|
octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex')
|
|
),
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
PRIMARY KEY (community_id, object_kind, object_key),
|
|
UNIQUE (
|
|
community_id,
|
|
object_kind,
|
|
object_key,
|
|
authority_epoch,
|
|
fence,
|
|
operation_id,
|
|
request_fingerprint
|
|
),
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint)
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
);
|
|
|
|
-- Direct-final current authority for a protected object. The authorization
|
|
-- lease itself is sealed in memory and dies on restart; this durable row is the
|
|
-- exact source re-fenced immediately before a protected mutation or emission.
|
|
CREATE TABLE protected_object_authority (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)),
|
|
object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32),
|
|
capability SMALLINT NOT NULL CHECK (
|
|
capability IN (
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
|
|
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
|
28, 29
|
|
)
|
|
),
|
|
actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32),
|
|
binding_id UUID NOT NULL,
|
|
binding_version BIGINT NOT NULL CHECK (binding_version > 0),
|
|
policy_revision BIGINT NOT NULL CHECK (policy_revision > 0),
|
|
invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0),
|
|
authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0),
|
|
fence BYTEA NOT NULL CHECK (
|
|
octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex')
|
|
),
|
|
issued_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32),
|
|
PRIMARY KEY (community_id, object_kind, object_key),
|
|
FOREIGN KEY (community_id, binding_id, binding_version)
|
|
REFERENCES identity_bindings (community_id, binding_id, binding_version)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
FOREIGN KEY (
|
|
community_id,
|
|
object_kind,
|
|
object_key,
|
|
authority_epoch,
|
|
fence,
|
|
operation_id,
|
|
request_fingerprint
|
|
) REFERENCES authorization_authority_epochs (
|
|
community_id,
|
|
object_kind,
|
|
object_key,
|
|
authority_epoch,
|
|
fence,
|
|
operation_id,
|
|
request_fingerprint
|
|
) DEFERRABLE INITIALLY DEFERRED,
|
|
CHECK (issued_at < expires_at)
|
|
);
|
|
|
|
-- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings
|
|
-- match buzz-auth; installation limits must be sized explicitly below them.
|
|
-- V1 has no online pruning/export/reset workflow.
|
|
CREATE TABLE authorization_event_capacity (
|
|
community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id),
|
|
max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK (
|
|
max_events_per_domain BETWEEN 1 AND 10000
|
|
),
|
|
max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK (
|
|
max_bytes_per_domain BETWEEN 1 AND 16777216
|
|
),
|
|
max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK (
|
|
max_envelope_bytes BETWEEN 1 AND 16384
|
|
),
|
|
retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0),
|
|
retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0),
|
|
-- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1
|
|
-- online workflow; enabled runtime latches failure when insertion aborts.
|
|
health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)),
|
|
failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)),
|
|
failure_observed_at TIMESTAMPTZ,
|
|
configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
CHECK (max_envelope_bytes <= max_bytes_per_domain),
|
|
CHECK (retained_event_count <= max_events_per_domain),
|
|
CHECK (retained_envelope_bytes <= max_bytes_per_domain),
|
|
CHECK (
|
|
(health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL)
|
|
OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
-- Durable versioned pseudonymous authorization envelope. event_kind:
|
|
-- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied,
|
|
-- 10 protected allowed, 11 protected denied, 14 invalidation advanced.
|
|
-- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled,
|
|
-- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE
|
|
-- migration, matching 0041's core lifecycle carve. Kinds 12 and 13 are
|
|
-- retired: kind 24244 publication/withdrawal is ephemeral connection state and
|
|
-- never a durable authorization event.
|
|
CREATE TABLE authorization_events (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
event_id UUID NOT NULL,
|
|
schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
|
event_kind SMALLINT NOT NULL CHECK (
|
|
event_kind IN (1, 2, 3, 6, 9, 10, 11, 14)
|
|
),
|
|
outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)),
|
|
reason_code SMALLINT NOT NULL CHECK (
|
|
reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
|
|
),
|
|
actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)),
|
|
actor_fingerprint BYTEA CHECK (
|
|
actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32
|
|
),
|
|
subject_fingerprint BYTEA CHECK (
|
|
subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32
|
|
),
|
|
-- Always retains attempted operation identity. Only unresolved pre-auth
|
|
-- event kind 9 omits the canonical receipt fingerprint; authenticated
|
|
-- OperatorDenied events remain linked to their exact canonical receipt.
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA CHECK (
|
|
request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32
|
|
),
|
|
correlation_id UUID NOT NULL,
|
|
attempt_id UUID NOT NULL,
|
|
-- Redaction-safe pre-authentication denial identity. Present and non-zero
|
|
-- 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
|
|
),
|
|
occurred_at TIMESTAMPTZ NOT NULL,
|
|
accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK (
|
|
octet_length(canonical_envelope) BETWEEN 1 AND 16384
|
|
),
|
|
envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32),
|
|
PRIMARY KEY (community_id, event_id),
|
|
UNIQUE (community_id, event_id, operation_id),
|
|
UNIQUE (community_id, event_id, event_kind, operation_id),
|
|
UNIQUE (community_id, operation_id, event_kind, attempt_id),
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (
|
|
(actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL)
|
|
OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL)
|
|
),
|
|
CHECK (
|
|
(actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL)
|
|
OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL)
|
|
),
|
|
-- Unresolved pre-auth kind-9 events (actor_kind = 4) carry a non-zero
|
|
-- semantic_fingerprint; authenticated kind-9 events (actor_kind 1-3) and
|
|
-- all other event kinds must not.
|
|
CHECK (
|
|
(event_kind = 9 AND actor_kind = 4 AND semantic_fingerprint IS NOT NULL
|
|
AND semantic_fingerprint <> decode(repeat('00', 32), 'hex'))
|
|
OR (event_kind = 9 AND actor_kind IN (1, 2, 3) AND semantic_fingerprint IS NULL)
|
|
OR (event_kind <> 9 AND semantic_fingerprint IS NULL)
|
|
)
|
|
);
|
|
|
|
-- Credential-free pre-authentication denial attempts. The five-column key is
|
|
-- exact replay identity; no row or FK occupies canonical operation/result,
|
|
-- effect, authority, approval, or consumption state.
|
|
CREATE TABLE authorization_authentication_denial_attempts (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
operation_id UUID NOT NULL,
|
|
correlation_id UUID NOT NULL,
|
|
semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32),
|
|
denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)),
|
|
expected_revision BIGINT NOT NULL CHECK (expected_revision > 0),
|
|
action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)),
|
|
reason_code SMALLINT NOT NULL CHECK (
|
|
reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
|
|
),
|
|
attempt_id UUID NOT NULL,
|
|
audit_event_id UUID NOT NULL,
|
|
audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9),
|
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
PRIMARY KEY (
|
|
community_id,
|
|
operation_id,
|
|
correlation_id,
|
|
semantic_fingerprint,
|
|
denial_reason
|
|
),
|
|
UNIQUE (community_id, audit_event_id),
|
|
FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id)
|
|
REFERENCES authorization_events (community_id, event_id, event_kind, operation_id)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id)
|
|
REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
-- Canonical denial_reason ↔ reason_code binding: MissingCredential(1)↔Missing(2),
|
|
-- InvalidCredential(2)↔Invalid(3), Unauthenticated(3)↔Unauthenticated(4).
|
|
CONSTRAINT authorization_denial_reason_reason_code_binding CHECK (
|
|
(denial_reason = 1 AND reason_code = 2)
|
|
OR (denial_reason = 2 AND reason_code = 3)
|
|
OR (denial_reason = 3 AND reason_code = 4)
|
|
),
|
|
CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
|
CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
|
);
|
|
|
|
-- Exact per-operation authority-version attribution for restore. Empty
|
|
-- manifests are valid; every stored component must advance strictly.
|
|
CREATE TABLE authorization_operation_version_delta_manifests (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32),
|
|
component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024),
|
|
before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32),
|
|
after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32),
|
|
manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32),
|
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
PRIMARY KEY (community_id, operation_id),
|
|
UNIQUE (community_id, operation_id, request_fingerprint),
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint)
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
);
|
|
|
|
-- component_kind: 1 binding version, 2 policy revision,
|
|
-- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship
|
|
-- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector
|
|
-- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable
|
|
-- client-status revisions; retained kinds keep their original identities.
|
|
CREATE TABLE authorization_operation_version_deltas (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
operation_id UUID NOT NULL,
|
|
component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)),
|
|
component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32),
|
|
before_version BIGINT NOT NULL CHECK (before_version >= 0),
|
|
after_version BIGINT NOT NULL,
|
|
component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32),
|
|
PRIMARY KEY (community_id, operation_id, component_kind, component_key),
|
|
FOREIGN KEY (community_id, operation_id)
|
|
REFERENCES authorization_operation_version_delta_manifests
|
|
(community_id, operation_id),
|
|
CHECK (after_version > before_version)
|
|
);
|
|
|
|
CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
policy authorization_event_capacity%ROWTYPE;
|
|
envelope_bytes BIGINT;
|
|
BEGIN
|
|
SELECT * INTO policy
|
|
FROM authorization_event_capacity
|
|
WHERE community_id = NEW.community_id
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'authorization event capacity policy missing'
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_event_capacity_policy_required';
|
|
END IF;
|
|
IF policy.health_state <> 1 THEN
|
|
RAISE EXCEPTION 'authorization audit is unavailable'
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_event_capacity_health';
|
|
END IF;
|
|
|
|
envelope_bytes := octet_length(NEW.canonical_envelope);
|
|
IF envelope_bytes > policy.max_envelope_bytes
|
|
OR policy.retained_event_count + 1 > policy.max_events_per_domain
|
|
OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain
|
|
THEN
|
|
-- The INSERT and protected mutation abort together. The runtime maps
|
|
-- this stable constraint to typed CapacityExhausted and latches audit
|
|
-- health outside the rolled-back transaction.
|
|
RAISE EXCEPTION 'authorization event capacity exhausted'
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_event_capacity_exhausted';
|
|
END IF;
|
|
|
|
UPDATE authorization_event_capacity
|
|
SET retained_event_count = retained_event_count + 1,
|
|
retained_envelope_bytes = retained_envelope_bytes + envelope_bytes,
|
|
updated_at = transaction_timestamp()
|
|
WHERE community_id = NEW.community_id;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER authorization_events_capacity
|
|
BEFORE INSERT ON authorization_events
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1();
|
|
|
|
CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW IS NOT DISTINCT FROM OLD THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
|
OR NEW.activated_at IS DISTINCT FROM OLD.activated_at
|
|
OR NEW.current_generation <= OLD.current_generation
|
|
OR NEW.updated_at <= OLD.updated_at
|
|
THEN
|
|
RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER authorization_invalidation_domains_monotonic
|
|
BEFORE UPDATE ON authorization_invalidation_domains
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1();
|
|
CREATE TRIGGER authorization_invalidation_domains_no_delete
|
|
BEFORE DELETE ON authorization_invalidation_domains
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_invalidation_domains_no_truncate
|
|
BEFORE TRUNCATE ON authorization_invalidation_domains
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW IS NOT DISTINCT FROM OLD THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
|
OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind
|
|
OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint
|
|
OR NEW.floor_generation < OLD.floor_generation
|
|
OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0)
|
|
OR (
|
|
NEW.floor_generation = OLD.floor_generation
|
|
AND COALESCE(NEW.binding_version_floor, 0)
|
|
= COALESCE(OLD.binding_version_floor, 0)
|
|
)
|
|
OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id
|
|
OR NEW.updated_at <= OLD.updated_at
|
|
THEN
|
|
RAISE EXCEPTION 'authorization invalidation floor cannot move backward'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER authorization_invalidation_floors_monotonic
|
|
BEFORE UPDATE ON authorization_invalidation_floors
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1();
|
|
CREATE TRIGGER authorization_invalidation_floors_no_delete
|
|
BEFORE DELETE ON authorization_invalidation_floors
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_invalidation_floors_no_truncate
|
|
BEFORE TRUNCATE ON authorization_invalidation_floors
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW IS NOT DISTINCT FROM OLD THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
|
OR NEW.object_kind IS DISTINCT FROM OLD.object_kind
|
|
OR NEW.object_key IS DISTINCT FROM OLD.object_key
|
|
OR NEW.authority_epoch <= OLD.authority_epoch
|
|
OR NEW.fence IS NOT DISTINCT FROM OLD.fence
|
|
OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id
|
|
OR NEW.updated_at <= OLD.updated_at
|
|
THEN
|
|
RAISE EXCEPTION 'authorization authority epoch cannot move backward'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER authorization_authority_epochs_monotonic
|
|
BEFORE UPDATE ON authorization_authority_epochs
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1();
|
|
CREATE TRIGGER authorization_authority_epochs_no_delete
|
|
BEFORE DELETE ON authorization_authority_epochs
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_authority_epochs_no_truncate
|
|
BEFORE TRUNCATE ON authorization_authority_epochs
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
|
OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain
|
|
OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain
|
|
OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes
|
|
OR NEW.configured_at IS DISTINCT FROM OLD.configured_at
|
|
OR NEW.retained_event_count < OLD.retained_event_count
|
|
OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes
|
|
OR NEW.updated_at < OLD.updated_at
|
|
OR (OLD.health_state = 2 AND (
|
|
NEW.health_state <> 2
|
|
OR NEW.failure_code IS DISTINCT FROM OLD.failure_code
|
|
OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at
|
|
))
|
|
OR (OLD.health_state = 1 AND NEW.health_state = 1 AND (
|
|
NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL
|
|
))
|
|
THEN
|
|
RAISE EXCEPTION 'authorization event capacity cannot be reset online'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW IS NOT DISTINCT FROM OLD THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
|
OR NEW.object_kind IS DISTINCT FROM OLD.object_kind
|
|
OR NEW.object_key IS DISTINCT FROM OLD.object_key
|
|
OR NEW.authority_epoch <= OLD.authority_epoch
|
|
OR NEW.fence IS NOT DISTINCT FROM OLD.fence
|
|
OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id
|
|
OR NEW.issued_at <= OLD.issued_at
|
|
THEN
|
|
RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER authorization_event_capacity_monotonic
|
|
BEFORE UPDATE ON authorization_event_capacity
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1();
|
|
CREATE TRIGGER authorization_event_capacity_no_delete
|
|
BEFORE DELETE ON authorization_event_capacity
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_event_capacity_no_truncate
|
|
BEFORE TRUNCATE ON authorization_event_capacity
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE TRIGGER authorization_events_immutable
|
|
BEFORE UPDATE OR DELETE ON authorization_events
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_events_no_truncate
|
|
BEFORE TRUNCATE ON authorization_events
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE TRIGGER authorization_authentication_denial_attempts_immutable
|
|
BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate
|
|
BEFORE TRUNCATE ON authorization_authentication_denial_attempts
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
-- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit
|
|
-- event must commit with exactly one denial attempt; a denial attempt must
|
|
-- commit with its audit event present, kind-9, and matching semantic
|
|
-- coordinates (correlation_id, reason_code, and semantic_fingerprint). Both
|
|
-- directions deferred so event and attempt may be inserted in any order inside
|
|
-- one transaction. The static denial_reason↔reason_code mapping is enforced
|
|
-- by an immediate CHECK on the denial attempt table; the guard enforces the
|
|
-- matching semantic coordinates between event and attempt.
|
|
CREATE FUNCTION authorization_denial_attempt_guard_v1()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
found_event_kind SMALLINT;
|
|
found_actor_kind SMALLINT;
|
|
found_request_fingerprint BYTEA;
|
|
found_correlation_id UUID;
|
|
found_reason_code SMALLINT;
|
|
found_semantic_fingerprint BYTEA;
|
|
attempt_count BIGINT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'authorization_events' THEN
|
|
-- Firing from the event side: only unresolved pre-auth kind-9 events
|
|
-- (actor_kind = 4) require a denial attempt row. Authenticated
|
|
-- OperatorDenied events (actor_kind 1-3) have a canonical receipt and
|
|
-- no denial attempt.
|
|
IF NEW.event_kind <> 9 OR NEW.actor_kind <> 4 THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
SELECT count(*) INTO attempt_count
|
|
FROM authorization_authentication_denial_attempts
|
|
WHERE community_id = NEW.community_id
|
|
AND audit_event_id = NEW.event_id;
|
|
|
|
IF attempt_count <> 1 THEN
|
|
RAISE EXCEPTION
|
|
'kind-9 audit event requires exactly one denial attempt, found %',
|
|
attempt_count
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_event_cardinality';
|
|
END IF;
|
|
|
|
-- Verify semantic coordinates match between event and denial attempt.
|
|
SELECT correlation_id, reason_code, semantic_fingerprint
|
|
INTO found_correlation_id, found_reason_code, found_semantic_fingerprint
|
|
FROM authorization_authentication_denial_attempts
|
|
WHERE community_id = NEW.community_id
|
|
AND audit_event_id = NEW.event_id;
|
|
|
|
IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt correlation_id % does not match event correlation_id % for event %',
|
|
found_correlation_id, NEW.correlation_id, NEW.event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
|
|
IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt reason_code % does not match event reason_code % for event %',
|
|
found_reason_code, NEW.reason_code, NEW.event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
|
|
IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %',
|
|
NEW.event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
ELSE
|
|
-- Firing from the denial-attempt side: verify the audit event is the
|
|
-- unresolved pre-auth kind-9 shape (actor_kind = 4, null receipt
|
|
-- fingerprint) and that exactly one denial attempt references it.
|
|
SELECT event_kind, actor_kind, request_fingerprint,
|
|
correlation_id, reason_code, semantic_fingerprint
|
|
INTO found_event_kind, found_actor_kind, found_request_fingerprint,
|
|
found_correlation_id, found_reason_code,
|
|
found_semantic_fingerprint
|
|
FROM authorization_events
|
|
WHERE community_id = NEW.community_id
|
|
AND event_id = NEW.audit_event_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt references non-existent audit event %',
|
|
NEW.audit_event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_event_kind';
|
|
END IF;
|
|
|
|
IF found_event_kind <> 9 THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt audit event must be kind 9, got %',
|
|
found_event_kind
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_event_kind';
|
|
END IF;
|
|
|
|
-- The referenced event must be the unresolved pre-auth shape: actor_kind
|
|
-- 4 with a null receipt fingerprint. Attaching a denial attempt to an
|
|
-- authenticated OperatorDenied (actor_kind 1-3) would violate the
|
|
-- credential-free pre-authentication contract.
|
|
IF found_actor_kind <> 4 OR found_request_fingerprint IS NOT NULL THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt must reference an unresolved pre-auth kind-9 event '
|
|
'(actor_kind 4, null request_fingerprint); got actor_kind % '
|
|
'and request_fingerprint % for event %',
|
|
found_actor_kind,
|
|
CASE WHEN found_request_fingerprint IS NULL THEN 'null' ELSE 'non-null' END,
|
|
NEW.audit_event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_event_kind';
|
|
END IF;
|
|
|
|
-- Verify semantic coordinates match.
|
|
IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt correlation_id % does not match event correlation_id % for event %',
|
|
NEW.correlation_id, found_correlation_id, NEW.audit_event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
|
|
IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt reason_code % does not match event reason_code % for event %',
|
|
NEW.reason_code, found_reason_code, NEW.audit_event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
|
|
IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN
|
|
RAISE EXCEPTION
|
|
'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %',
|
|
NEW.audit_event_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_semantic_binding';
|
|
END IF;
|
|
|
|
SELECT count(*) INTO attempt_count
|
|
FROM authorization_authentication_denial_attempts
|
|
WHERE community_id = NEW.community_id
|
|
AND audit_event_id = NEW.audit_event_id;
|
|
|
|
IF attempt_count <> 1 THEN
|
|
RAISE EXCEPTION
|
|
'exactly one denial attempt must reference audit event %, found %',
|
|
NEW.audit_event_id, attempt_count
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denial_attempt_event_cardinality';
|
|
END IF;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality
|
|
AFTER INSERT ON authorization_events
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1();
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality
|
|
AFTER INSERT ON authorization_authentication_denial_attempts
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1();
|
|
|
|
CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
manifest authorization_operation_version_delta_manifests%ROWTYPE;
|
|
actual_component_count BIGINT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN
|
|
manifest := NEW;
|
|
ELSE
|
|
SELECT * INTO STRICT manifest
|
|
FROM authorization_operation_version_delta_manifests
|
|
WHERE community_id = NEW.community_id
|
|
AND operation_id = NEW.operation_id
|
|
FOR NO KEY UPDATE;
|
|
END IF;
|
|
|
|
SELECT count(*) INTO actual_component_count
|
|
FROM authorization_operation_version_deltas
|
|
WHERE community_id = manifest.community_id
|
|
AND operation_id = manifest.operation_id;
|
|
|
|
IF actual_component_count <> manifest.component_count THEN
|
|
RAISE EXCEPTION 'operation version manifest declares % components, found %',
|
|
manifest.component_count, actual_component_count
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_operation_version_delta_cardinality';
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality
|
|
AFTER INSERT ON authorization_operation_version_delta_manifests
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1();
|
|
CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality
|
|
AFTER INSERT ON authorization_operation_version_deltas
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1();
|
|
|
|
CREATE TRIGGER authorization_operation_version_delta_manifests_immutable
|
|
BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate
|
|
BEFORE TRUNCATE ON authorization_operation_version_delta_manifests
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE TRIGGER authorization_operation_version_deltas_immutable
|
|
BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_operation_version_deltas_no_truncate
|
|
BEFORE TRUNCATE ON authorization_operation_version_deltas
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
CREATE TRIGGER protected_object_authority_no_delete
|
|
BEFORE DELETE ON protected_object_authority
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER protected_object_authority_no_truncate
|
|
BEFORE TRUNCATE ON protected_object_authority
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
CREATE TRIGGER protected_object_authority_strict_replacement
|
|
BEFORE UPDATE ON protected_object_authority
|
|
FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1();
|
|
|
|
-- Canonical admission keeps its complete logical intent and the closed,
|
|
-- credential-free application result beside the immutable receipt. This is
|
|
-- what lets an identical request replay reconstruct the same typed result
|
|
-- without repeating membership or other application DML. Object kinds match
|
|
-- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media,
|
|
-- 5 moderation target, 6 audio session.
|
|
CREATE TABLE authorization_admission_results (
|
|
community_id UUID NOT NULL,
|
|
operation_id UUID NOT NULL,
|
|
request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32),
|
|
semantic_fingerprint BYTEA NOT NULL CHECK (
|
|
octet_length(semantic_fingerprint) = 32
|
|
AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')
|
|
),
|
|
object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6),
|
|
object_key BYTEA NOT NULL CHECK (
|
|
octet_length(object_key) = 32
|
|
AND object_key <> decode(repeat('00', 32), 'hex')
|
|
),
|
|
application_type BYTEA CHECK (
|
|
application_type IS NULL
|
|
OR (octet_length(application_type) = 32
|
|
AND application_type <> decode(repeat('00', 32), 'hex'))
|
|
),
|
|
application_version SMALLINT CHECK (application_version > 0),
|
|
application_code SMALLINT CHECK (application_code > 0),
|
|
application_payload BYTEA CHECK (
|
|
application_payload IS NULL OR octet_length(application_payload) <= 4096
|
|
),
|
|
application_intent_digest BYTEA CHECK (
|
|
application_intent_digest IS NULL
|
|
OR (octet_length(application_intent_digest) = 32
|
|
AND application_intent_digest <> decode(repeat('00', 32), 'hex'))
|
|
),
|
|
application_effect_digest BYTEA CHECK (
|
|
application_effect_digest IS NULL
|
|
OR (octet_length(application_effect_digest) = 32
|
|
AND application_effect_digest <> decode(repeat('00', 32), 'hex'))
|
|
),
|
|
application_result_digest BYTEA CHECK (
|
|
application_result_digest IS NULL
|
|
OR (octet_length(application_result_digest) = 32
|
|
AND application_result_digest <> decode(repeat('00', 32), 'hex'))
|
|
),
|
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
PRIMARY KEY (community_id, operation_id),
|
|
FOREIGN KEY (community_id, operation_id, request_fingerprint)
|
|
REFERENCES authorization_operation_receipts
|
|
(community_id, operation_id, request_fingerprint),
|
|
CHECK (
|
|
(application_type IS NULL
|
|
AND application_version IS NULL
|
|
AND application_code IS NULL
|
|
AND application_payload IS NULL
|
|
AND application_intent_digest IS NULL
|
|
AND application_effect_digest IS NULL
|
|
AND application_result_digest IS NULL)
|
|
OR (application_type IS NOT NULL
|
|
AND application_version IS NOT NULL
|
|
AND application_code IS NOT NULL
|
|
AND application_payload IS NOT NULL
|
|
AND application_intent_digest IS NOT NULL
|
|
AND application_effect_digest IS NOT NULL
|
|
AND application_result_digest IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE TRIGGER authorization_admission_results_no_update
|
|
BEFORE UPDATE OR DELETE ON authorization_admission_results
|
|
FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1();
|
|
CREATE TRIGGER authorization_admission_results_no_truncate
|
|
BEFORE TRUNCATE ON authorization_admission_results
|
|
FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();
|
|
|
|
-- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation)
|
|
-- receipt must commit with exactly one admission result; an admission result
|
|
-- must commit against a kind-11 receipt. Deferred so receipt and result may
|
|
-- be inserted in any order inside one transaction.
|
|
CREATE FUNCTION authorization_admission_result_guard_v1()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
receipt authorization_operation_receipts%ROWTYPE;
|
|
result_count BIGINT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN
|
|
receipt := NEW;
|
|
ELSE
|
|
-- Firing from authorization_admission_results: look up the receipt.
|
|
SELECT * INTO receipt
|
|
FROM authorization_operation_receipts
|
|
WHERE community_id = NEW.community_id
|
|
AND operation_id = NEW.operation_id;
|
|
IF NOT FOUND THEN
|
|
-- FK on the result table already guards the non-existent receipt
|
|
-- case; this path should not occur in normal operation.
|
|
RAISE EXCEPTION
|
|
'admission result references non-existent receipt for operation %',
|
|
NEW.operation_id
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_admission_result_receipt_kind';
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Non-kind-11 receipts require no admission result.
|
|
IF receipt.operation_kind <> 11 THEN
|
|
-- If this fired from the result side and the receipt is not kind 11,
|
|
-- the result is attaching to the wrong receipt kind.
|
|
IF TG_TABLE_NAME = 'authorization_admission_results' THEN
|
|
RAISE EXCEPTION
|
|
'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %',
|
|
receipt.operation_kind
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_admission_result_receipt_kind';
|
|
END IF;
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
SELECT count(*) INTO result_count
|
|
FROM authorization_admission_results
|
|
WHERE community_id = receipt.community_id
|
|
AND operation_id = receipt.operation_id;
|
|
|
|
IF result_count <> 1 THEN
|
|
RAISE EXCEPTION
|
|
'kind-11 receipt requires exactly one admission result, found %',
|
|
result_count
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_admission_result_cardinality';
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality
|
|
AFTER INSERT ON authorization_operation_receipts
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1();
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality
|
|
AFTER INSERT ON authorization_admission_results
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1();
|
|
|
|
-- Every successful/no-op core lifecycle receipt has exactly one privacy-safe
|
|
-- audit event with the closed transition-kind mapping. Both directions are
|
|
-- deferred so receipt, history, event, selectors, and binding may be inserted
|
|
-- in any order inside one transaction but can never commit partially. The
|
|
-- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover,
|
|
-- 8 enable, 9 admission loss) and their event kinds arrive with the
|
|
-- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/
|
|
-- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no
|
|
-- audit-event cardinality requirement.
|
|
CREATE FUNCTION authorization_operation_receipt_event_guard_v1()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
receipt authorization_operation_receipts%ROWTYPE;
|
|
expected_event_kind SMALLINT;
|
|
matching_event_count BIGINT;
|
|
expected_event_count BIGINT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN
|
|
receipt := NEW;
|
|
ELSE
|
|
SELECT * INTO receipt
|
|
FROM authorization_operation_receipts
|
|
WHERE community_id = NEW.community_id
|
|
AND operation_id = NEW.operation_id;
|
|
IF NOT FOUND THEN
|
|
-- Credential-free pre-authentication denials intentionally have no
|
|
-- canonical receipt. Their separate FK/shape guards still run.
|
|
RETURN NULL;
|
|
END IF;
|
|
END IF;
|
|
|
|
expected_event_kind := CASE receipt.operation_kind
|
|
WHEN 1 THEN 1 -- enroll
|
|
WHEN 3 THEN 6 -- retire
|
|
WHEN 5 THEN 2 -- revoke
|
|
WHEN 6 THEN 3 -- rotate
|
|
ELSE NULL
|
|
END;
|
|
IF expected_event_kind IS NULL THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
-- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle
|
|
-- receipts require exactly one paired success-transition event. A denied
|
|
-- lifecycle receipt (outcome_code = 2) requires zero events from the
|
|
-- complete core lifecycle success-transition class (kinds 1, 2, 3, 6:
|
|
-- enrolled, revoked, rotated, retired). Forbidding only the mapped kind
|
|
-- would allow a wrong-kind transition event to attach to the denied receipt,
|
|
-- which is equally a contradictory durable fact. Legitimate audit/denial
|
|
-- events of other kinds (e.g., authenticated kind 9) remain allowed.
|
|
-- 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;
|
|
|
|
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 IN (1, 2, 3, 6))
|
|
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 expected_event_count <> 0 THEN
|
|
RAISE EXCEPTION
|
|
'denied lifecycle receipt must not have any core success-transition event '
|
|
'(kinds 1/2/3/6); found % — contradictory durable facts are not permitted',
|
|
expected_event_count
|
|
USING ERRCODE = 'check_violation',
|
|
CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event';
|
|
END IF;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality
|
|
AFTER INSERT ON authorization_operation_receipts
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1();
|
|
|
|
CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality
|
|
AFTER INSERT ON authorization_events
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1();
|
|
|
|
-- Same ledger posture as migration 0041's identity relations: the admission,
|
|
-- replay, audit, and invalidation relations below are append-only denial and
|
|
-- authority facts protected by immutable no_delete/no_truncate triggers, so
|
|
-- they carry community_id as provenance rather than deletable ownership. Widen
|
|
-- the single SQL source of truth so the universal write fence and the deletion
|
|
-- catalog treat all NIP-FI relations as ledger — never fence-attached, never
|
|
-- purged, never counted as tenant-scoped drift. This re-declares the full set
|
|
-- (0041's identity relations plus these) because CREATE OR REPLACE FUNCTION
|
|
-- replaces the whole body.
|
|
CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN
|
|
LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$
|
|
SELECT target::TEXT = ANY (ARRAY[
|
|
'community_deletion_requests', 'community_deletion_approvals',
|
|
'community_deletion_checkpoints', 'community_serving_write_leases',
|
|
'community_deletion_executor_heartbeats', 'product_feedback',
|
|
'rate_limit_violations',
|
|
'authorization_operation_receipts', 'identity_enrollment_policies',
|
|
'identity_bindings', 'identity_lifecycle_history',
|
|
'identity_lifecycle_selectors',
|
|
'authorization_invalidation_domains', 'authorization_invalidation_floors',
|
|
'authorization_authority_epochs', 'protected_object_authority',
|
|
'authorization_event_capacity', 'authorization_events',
|
|
'authorization_authentication_denial_attempts',
|
|
'authorization_operation_version_delta_manifests',
|
|
'authorization_operation_version_deltas', 'authorization_admission_results'
|
|
]::TEXT[])
|
|
$$;
|