Files
buzz/migrations/0037_authorization_authority_epochs.sql
Cea Stapleton Cordasco 19524a34ff feat(auth): converge revocation and invalidation
Canonical O4 slice: 07_revocation_invalidation
Final-tree source: e45553ea65b66631771c16908513f983d3c0b881

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
2026-08-04 11:43:40 -05:00

245 lines
10 KiB
PL/PgSQL

-- Monotonic authority epoch covering every PostgreSQL-backed V1 authority
-- reduction. The epoch is transactionally advanced by table triggers, so a
-- stale restore cannot hide a principal/key/pair tombstone, membership loss,
-- invalidation, publication transition, or audio-admission transition from
-- the independent restore witness.
ALTER TABLE audio_session_admissions
ADD COLUMN claimant_id UUID,
ADD COLUMN attachment_generation BIGINT NOT NULL DEFAULT 0
CHECK (attachment_generation >= 0),
ADD COLUMN claim_expires_at TIMESTAMPTZ;
-- A pre-cutover process-local attachment cannot be reconstructed safely. End
-- every nonterminal attempt fail-closed, while assigning a stable migration
-- claimant only to already-terminal history so the new invariant is additive
-- on populated databases.
UPDATE audio_session_admissions
SET state = 'aborted',
state_version = state_version + 1,
aborted_at = COALESCE(aborted_at, clock_timestamp()),
updated_at = clock_timestamp(),
failure_code = 'upgrade_reconciliation'
WHERE state IN ('reserved', 'active');
UPDATE audio_session_admissions
SET claimant_id = admission_id,
attachment_generation = 1,
claim_expires_at = lease_expires_at
WHERE state = 'finished';
ALTER TABLE audio_session_admissions
ADD CONSTRAINT audio_session_admissions_claim CHECK (
(state = 'reserved'
AND claimant_id IS NOT NULL
AND attachment_generation = 0
AND claim_expires_at IS NOT NULL)
OR
(state IN ('active', 'finished')
AND claimant_id IS NOT NULL
AND attachment_generation > 0
AND claim_expires_at IS NOT NULL)
OR
(state = 'aborted')
);
CREATE TABLE authorization_authority_epochs (
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
authority_epoch BIGINT NOT NULL DEFAULT 1 CHECK (authority_epoch > 0),
status_revision BIGINT NOT NULL DEFAULT 1 CHECK (status_revision > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (community_id)
);
INSERT INTO authorization_authority_epochs (community_id)
SELECT id FROM communities
ON CONFLICT (community_id) DO NOTHING;
-- Dedicated current-only client projection revision. The event-author key is
-- the presentation scope; issuer/subject and lifecycle history remain solely
-- in the identity authority tables.
CREATE TABLE client_status_revisions (
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
event_author_pubkey BYTEA NOT NULL CHECK (length(event_author_pubkey) = 32),
revision BIGINT NOT NULL CHECK (revision > 0),
disposition TEXT NOT NULL CHECK (disposition IN ('current', 'withdrawn')),
binding_id UUID,
binding_version BIGINT CHECK (binding_version > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (community_id, event_author_pubkey),
CHECK (
(disposition = 'current' AND binding_id IS NOT NULL AND binding_version IS NOT NULL)
OR
(disposition = 'withdrawn' AND binding_id IS NULL AND binding_version IS NULL)
)
);
-- Authorization state has no meaning after its owning community is removed.
-- Earlier additive migrations intentionally used restrictive foreign keys;
-- make teardown atomic now that the complete protected state set is known.
ALTER TABLE authorization_invalidation_domains
DROP CONSTRAINT authorization_invalidation_domains_community_id_fkey,
ADD CONSTRAINT authorization_invalidation_domains_community_id_fkey
FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE;
ALTER TABLE authorization_invalidation_receipts
DROP CONSTRAINT authorization_invalidation_receipts_community_id_fkey,
ADD CONSTRAINT authorization_invalidation_receipts_community_id_fkey
FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE;
ALTER TABLE authorization_invalidation_floors
DROP CONSTRAINT authorization_invalidation_floors_community_id_fkey,
ADD CONSTRAINT authorization_invalidation_floors_community_id_fkey
FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE;
ALTER TABLE authorization_operation_receipts
DROP CONSTRAINT authorization_operation_receipts_community_id_fkey,
ADD CONSTRAINT authorization_operation_receipts_community_id_fkey
FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE;
CREATE FUNCTION advance_authorization_authority_epoch() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
domain_id UUID;
invalidation_event UUID;
invalidation_generation BIGINT;
BEGIN
domain_id := COALESCE(NEW.community_id, OLD.community_id);
-- The nested generation update below has its own table trigger. The outer
-- protected mutation owns the epoch advancement, so the nested trigger is
-- deliberately inert rather than double-counting one authority change.
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
-- Invalidation-domain presence is the durable marker installed during
-- protected-domain initialization. Legacy/Off communities must retain
-- byte-for-byte behavior and must not acquire authorization side effects.
IF TG_TABLE_NAME <> 'authorization_invalidation_domains'
AND NOT EXISTS (
SELECT 1 FROM authorization_invalidation_domains
WHERE community_id = domain_id
)
THEN
RETURN NULL;
END IF;
-- Community teardown removes all protected state through cascading
-- foreign keys; it cannot publish a new floor for a domain that no longer
-- exists.
IF TG_TABLE_NAME = 'authorization_invalidation_domains' AND TG_OP = 'DELETE' THEN
RETURN NULL;
END IF;
INSERT INTO authorization_authority_epochs
(community_id, authority_epoch, status_revision, updated_at)
VALUES (domain_id, 2, 2, clock_timestamp())
ON CONFLICT (community_id) DO UPDATE
SET authority_epoch = authorization_authority_epochs.authority_epoch + 1,
status_revision = authorization_authority_epochs.status_revision + 1,
updated_at = clock_timestamp();
IF TG_TABLE_NAME IN (
'identity_bindings',
'identity_principals',
'identity_revoked_keys',
'identity_retired_pairs',
'relay_members',
'channel_members',
'community_bans',
'channels',
'users'
) THEN
invalidation_event := gen_random_uuid();
UPDATE authorization_invalidation_domains
SET generation = generation + 1,
updated_at = clock_timestamp()
WHERE community_id = domain_id
RETURNING generation INTO invalidation_generation;
IF invalidation_generation IS NULL THEN
RETURN NULL;
END IF;
INSERT INTO authorization_invalidation_receipts
(community_id, event_id, generation, request_fingerprint)
VALUES (
domain_id,
invalidation_event,
invalidation_generation,
digest(invalidation_event::text, 'sha256')
);
INSERT INTO authorization_invalidation_floors
(community_id, selector_kind, selector_fingerprint, generation,
sticky_deny, binding_version_floor)
VALUES (
domain_id,
'domain',
decode('a3c641c058d6498e4cc4177eb5f9cf6ba32e01c05a21f69aa85661a8044a5c78', 'hex'),
invalidation_generation,
FALSE,
NULL
)
ON CONFLICT (community_id, selector_kind, selector_fingerprint) DO UPDATE
SET generation = EXCLUDED.generation,
updated_at = clock_timestamp();
END IF;
RETURN NULL;
END
$$;
CREATE TRIGGER identity_bindings_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON identity_bindings
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER identity_principals_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON identity_principals
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER identity_revoked_keys_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON identity_revoked_keys
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER identity_retired_pairs_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON identity_retired_pairs
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER relay_members_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON relay_members
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER channel_members_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON channel_members
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER community_bans_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON community_bans
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER channels_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON channels
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER users_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER authorization_invalidation_domains_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON authorization_invalidation_domains
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER git_repo_publications_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON git_repo_publications
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER media_publications_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON media_publications
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER protected_object_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON protected_object_authority
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();
CREATE TRIGGER audio_session_admissions_authority_epoch
AFTER INSERT OR UPDATE OR DELETE ON audio_session_admissions
FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch();