From 01f762cf56e654bb9812e1149690180ff2116ef6 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Sun, 30 Aug 2026 19:18:21 +0530 Subject: [PATCH] feat(consent): add the user_consents table with immutability triggers Adds user_consents per the RFC's Storage section: the table, the documents_not_empty CHECK, the partial unique index giving a user at most one signup consent, and BEFORE UPDATE / BEFORE DELETE triggers that both raise 45000. Schema only. The repository and the write path land with their caller, following audit_records, whose table migration shipped in #1118 and whose repository followed in #1124. Four choices are deliberate and read as mistakes without the RFC: no foreign key to users, ip_address as nullable TEXT rather than INET, document versions and URLs copied rather than referenced, and the partial unique index. Each is commented in the migration itself. Part of RFC 0002: https://github.com/raystack/frontier/blob/main/docs/rfcs/0002-explicit-consent-at-signup.md Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VW3nysiE4H83VQk6BroMYc --- ...260830100000_create_user_consents.down.sql | 12 ++++ ...20260830100000_create_user_consents.up.sql | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 internal/store/postgres/migrations/20260830100000_create_user_consents.down.sql create mode 100644 internal/store/postgres/migrations/20260830100000_create_user_consents.up.sql diff --git a/internal/store/postgres/migrations/20260830100000_create_user_consents.down.sql b/internal/store/postgres/migrations/20260830100000_create_user_consents.down.sql new file mode 100644 index 0000000000..46ce7c39bb --- /dev/null +++ b/internal/store/postgres/migrations/20260830100000_create_user_consents.down.sql @@ -0,0 +1,12 @@ +DROP TRIGGER IF EXISTS trg_user_consents_prevent_delete ON user_consents; +DROP TRIGGER IF EXISTS trg_user_consents_prevent_update ON user_consents; + +DROP FUNCTION IF EXISTS prevent_user_consent_deletes(); +DROP FUNCTION IF EXISTS prevent_user_consent_updates(); + +DROP INDEX IF EXISTS uq_user_consents_signup; + +-- The BEFORE DELETE trigger fires per row and does not block DROP TABLE. +DROP TABLE IF EXISTS user_consents; + +-- uuid_generate_v7() belongs to 20250901054744_create_audits_table and is left in place. diff --git a/internal/store/postgres/migrations/20260830100000_create_user_consents.up.sql b/internal/store/postgres/migrations/20260830100000_create_user_consents.up.sql new file mode 100644 index 0000000000..18509b5994 --- /dev/null +++ b/internal/store/postgres/migrations/20260830100000_create_user_consents.up.sql @@ -0,0 +1,63 @@ +-- One consent record per consent, listing the documents it covers. +-- See docs/rfcs/0002-explicit-consent-at-signup.md, "Storage". +-- +-- There is deliberately no foreign key to users(id). UserRepository.Delete does a hard +-- DELETE, so ON DELETE CASCADE would drop these records along with the account and +-- ON DELETE RESTRICT would block account deletion outright. The records have to outlive +-- the user, which is also why user_email is denormalized onto the row. +CREATE TABLE user_consents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL, -- no FK to users(id): see above. + user_email TEXT NOT NULL, -- denormalized so the record survives the user row. + documents JSONB NOT NULL, -- [{id, title, version, url}, ...], copied from config at write time. + source TEXT NOT NULL DEFAULT 'signup', + auth_strategy TEXT, -- the flow's own strategy_name: oidc, mailotp or passkey. + ip_address TEXT, -- TEXT and nullable, not INET: it comes from a request header. + consented_at TIMESTAMPTZ NOT NULL, -- when the user accepted, not when the row was written. + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT documents_not_empty CHECK ( + jsonb_typeof(documents) = 'array' AND jsonb_array_length(documents) > 0 + ) +); + +-- At most one signup consent per user. Nothing repairs a record, so a second signup write +-- is a bug; this makes it fail rather than leave two rows disagreeing. +CREATE UNIQUE INDEX uq_user_consents_signup + ON user_consents(user_id) WHERE source = 'signup'; + +-- Immutability, following 20250904105226_add_audit_records_immutability.up.sql. That +-- migration guards UPDATE only; DELETE is guarded here as well, because a deleted record +-- leaves a user who looks like they never consented. DROP TABLE is unaffected by a row +-- level trigger, so the down migration still works. +CREATE OR REPLACE FUNCTION prevent_user_consent_updates() + RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'user_consents cannot be updated to maintain consent integrity' + USING ERRCODE = '45000', -- User-defined error (Postgres convention: user-defined error codes are in the 45000-45999 range) + DETAIL = 'Consent records are immutable once created'; +END; + $$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_user_consents_prevent_update + BEFORE UPDATE ON user_consents + FOR EACH ROW EXECUTE FUNCTION prevent_user_consent_updates(); + +COMMENT ON TRIGGER trg_user_consents_prevent_update ON user_consents IS + 'Enforces immutability of consent records by preventing any UPDATE operation.'; + +CREATE OR REPLACE FUNCTION prevent_user_consent_deletes() + RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'user_consents cannot be deleted to maintain consent integrity' + USING ERRCODE = '45000', -- User-defined error (Postgres convention: user-defined error codes are in the 45000-45999 range) + DETAIL = 'Consent records must outlive the user they describe'; +END; + $$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_user_consents_prevent_delete + BEFORE DELETE ON user_consents + FOR EACH ROW EXECUTE FUNCTION prevent_user_consent_deletes(); + +COMMENT ON TRIGGER trg_user_consents_prevent_delete ON user_consents IS + 'Enforces immutability of consent records by preventing any DELETE operation.';