-- Durable, transactionally-enqueued API-key auth cache invalidation. -- cache_key is always SHA-256 hex; plaintext credentials never leave api_keys. CREATE TABLE IF NOT EXISTS auth_cache_invalidation_outbox ( id BIGSERIAL PRIMARY KEY, cache_key CHAR(64) NOT NULL CHECK (cache_key ~ '^[0-9a-f]{64}$'), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), delivery_stage SMALLINT NOT NULL DEFAULT 0 CHECK (delivery_stage IN (0, 1)), attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), last_error TEXT, claimed_at TIMESTAMPTZ, claimed_by TEXT ); CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_available ON auth_cache_invalidation_outbox (available_at, id) WHERE claimed_at IS NULL; CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_lease ON auth_cache_invalidation_outbox (claimed_at) WHERE claimed_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_cache_key ON auth_cache_invalidation_outbox (cache_key); CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_created_at ON auth_cache_invalidation_outbox (created_at); CREATE OR REPLACE FUNCTION enqueue_auth_cache_invalidation(raw_key TEXT) RETURNS VOID LANGUAGE plpgsql AS $$ BEGIN IF raw_key IS NULL OR raw_key = '' THEN RETURN; END IF; INSERT INTO auth_cache_invalidation_outbox (cache_key) VALUES (encode(sha256(convert_to(raw_key, 'UTF8')), 'hex')); END; $$; CREATE OR REPLACE FUNCTION enqueue_api_key_auth_cache_invalidation() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN PERFORM enqueue_auth_cache_invalidation(OLD.key); RETURN OLD; END IF; IF OLD.key IS DISTINCT FROM NEW.key OR OLD.status IS DISTINCT FROM NEW.status OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at OR OLD.user_id IS DISTINCT FROM NEW.user_id OR OLD.group_id IS DISTINCT FROM NEW.group_id OR OLD.ip_whitelist IS DISTINCT FROM NEW.ip_whitelist OR OLD.ip_blacklist IS DISTINCT FROM NEW.ip_blacklist OR OLD.expires_at IS DISTINCT FROM NEW.expires_at THEN PERFORM enqueue_auth_cache_invalidation(OLD.key); IF NEW.deleted_at IS NULL AND NEW.key IS DISTINCT FROM OLD.key THEN PERFORM enqueue_auth_cache_invalidation(NEW.key); END IF; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_api_keys_auth_cache_invalidation ON api_keys; CREATE TRIGGER trg_api_keys_auth_cache_invalidation AFTER UPDATE OR DELETE ON api_keys FOR EACH ROW EXECUTE FUNCTION enqueue_api_key_auth_cache_invalidation(); CREATE OR REPLACE FUNCTION enqueue_user_auth_cache_invalidation() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE target_user_id BIGINT; BEGIN target_user_id := OLD.id; IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM NEW.status AND OLD.role IS NOT DISTINCT FROM NEW.role AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN RETURN NEW; END IF; INSERT INTO auth_cache_invalidation_outbox (cache_key) SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex') FROM api_keys AS k WHERE k.user_id = target_user_id AND k.deleted_at IS NULL AND k.key <> ''; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_users_auth_cache_invalidation ON users; CREATE TRIGGER trg_users_auth_cache_invalidation AFTER UPDATE OR DELETE ON users FOR EACH ROW EXECUTE FUNCTION enqueue_user_auth_cache_invalidation(); CREATE OR REPLACE FUNCTION enqueue_group_auth_cache_invalidation() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE target_group_id BIGINT; BEGIN target_group_id := OLD.id; IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM NEW.status AND OLD.is_exclusive IS NOT DISTINCT FROM NEW.is_exclusive AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN RETURN NEW; END IF; INSERT INTO auth_cache_invalidation_outbox (cache_key) SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex') FROM api_keys AS k WHERE k.group_id = target_group_id AND k.deleted_at IS NULL AND k.key <> ''; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_groups_auth_cache_invalidation ON groups; CREATE TRIGGER trg_groups_auth_cache_invalidation AFTER UPDATE OR DELETE ON groups FOR EACH ROW EXECUTE FUNCTION enqueue_group_auth_cache_invalidation(); CREATE OR REPLACE FUNCTION enqueue_allowed_group_auth_cache_invalidation() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE target_user_id BIGINT; target_group_id BIGINT; BEGIN IF TG_OP = 'UPDATE' AND (OLD.user_id IS DISTINCT FROM NEW.user_id OR OLD.group_id IS DISTINCT FROM NEW.group_id) THEN IF EXISTS ( SELECT 1 FROM groups g WHERE g.id = OLD.group_id AND g.is_exclusive = TRUE ) THEN INSERT INTO auth_cache_invalidation_outbox (cache_key) SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex') FROM api_keys AS k WHERE k.user_id = OLD.user_id AND k.group_id = OLD.group_id AND k.deleted_at IS NULL AND k.key <> ''; END IF; target_user_id := NEW.user_id; target_group_id := NEW.group_id; ELSIF TG_OP = 'UPDATE' THEN RETURN NEW; ELSIF TG_OP = 'INSERT' THEN target_user_id := NEW.user_id; target_group_id := NEW.group_id; ELSE target_user_id := OLD.user_id; target_group_id := OLD.group_id; END IF; IF EXISTS ( SELECT 1 FROM groups g WHERE g.id = target_group_id AND g.is_exclusive = TRUE ) THEN INSERT INTO auth_cache_invalidation_outbox (cache_key) SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex') FROM api_keys AS k WHERE k.user_id = target_user_id AND k.group_id = target_group_id AND k.deleted_at IS NULL AND k.key <> ''; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_user_allowed_groups_auth_cache_invalidation ON user_allowed_groups; CREATE TRIGGER trg_user_allowed_groups_auth_cache_invalidation AFTER INSERT OR UPDATE OR DELETE ON user_allowed_groups FOR EACH ROW EXECUTE FUNCTION enqueue_allowed_group_auth_cache_invalidation(); COMMENT ON TABLE auth_cache_invalidation_outbox IS 'Durable cross-instance auth cache invalidations; cache_key is SHA-256 hex, never plaintext API key';