171 lines
5.5 KiB
PL/PgSQL
171 lines
5.5 KiB
PL/PgSQL
-- =====================================================
|
|
-- GDPR Data Retention Automation
|
|
--
|
|
-- Configurable retention policies per account.
|
|
-- Automatic anonymization of resigned/excluded/deceased
|
|
-- members after retention period expires.
|
|
-- =====================================================
|
|
|
|
-- Retention policy configuration per account
|
|
CREATE TABLE IF NOT EXISTS public.gdpr_retention_policies (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
|
policy_name text NOT NULL DEFAULT 'Standard',
|
|
retention_days int NOT NULL DEFAULT 1095, -- 3 years
|
|
auto_anonymize boolean NOT NULL DEFAULT false,
|
|
applies_to_status text[] NOT NULL DEFAULT ARRAY['resigned', 'excluded', 'deceased'],
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE(account_id)
|
|
);
|
|
|
|
ALTER TABLE public.gdpr_retention_policies ENABLE ROW LEVEL SECURITY;
|
|
REVOKE ALL ON public.gdpr_retention_policies FROM authenticated, service_role;
|
|
GRANT SELECT, INSERT, UPDATE ON public.gdpr_retention_policies TO authenticated;
|
|
GRANT ALL ON public.gdpr_retention_policies TO service_role;
|
|
|
|
CREATE POLICY gdpr_retention_select
|
|
ON public.gdpr_retention_policies FOR SELECT TO authenticated
|
|
USING (public.has_role_on_account(account_id));
|
|
|
|
CREATE POLICY gdpr_retention_mutate
|
|
ON public.gdpr_retention_policies FOR ALL TO authenticated
|
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
|
|
|
-- Anonymize a single member (replaces all PII with placeholder)
|
|
CREATE OR REPLACE FUNCTION public.anonymize_member(
|
|
p_member_id uuid,
|
|
p_performed_by uuid DEFAULT NULL
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
v_member record;
|
|
v_user_id uuid;
|
|
BEGIN
|
|
v_user_id := COALESCE(p_performed_by, auth.uid());
|
|
|
|
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
|
IF v_member IS NULL THEN
|
|
RAISE EXCEPTION 'Member not found';
|
|
END IF;
|
|
|
|
-- Verify caller access
|
|
IF v_user_id IS NOT NULL AND NOT public.has_permission(v_user_id, v_member.account_id, 'members.write'::public.app_permissions) THEN
|
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Snapshot full record to audit log before anonymization
|
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
|
VALUES (
|
|
p_member_id, v_member.account_id, v_user_id, 'gdpr_anonymized',
|
|
jsonb_build_object(
|
|
'original_first_name', v_member.first_name,
|
|
'original_last_name', v_member.last_name,
|
|
'original_email', v_member.email,
|
|
'reason', 'GDPR retention policy'
|
|
)
|
|
);
|
|
|
|
-- Replace all PII with anonymized placeholders
|
|
UPDATE public.members SET
|
|
first_name = 'ANONYMISIERT',
|
|
last_name = 'ANONYMISIERT',
|
|
email = NULL,
|
|
phone = NULL,
|
|
mobile = NULL,
|
|
phone2 = NULL,
|
|
fax = NULL,
|
|
street = NULL,
|
|
house_number = NULL,
|
|
street2 = NULL,
|
|
postal_code = NULL,
|
|
city = NULL,
|
|
date_of_birth = NULL,
|
|
birthplace = NULL,
|
|
birth_country = NULL,
|
|
iban = NULL,
|
|
bic = NULL,
|
|
account_holder = NULL,
|
|
sepa_mandate_reference = NULL,
|
|
sepa_mandate_id = NULL,
|
|
primary_mandate_id = NULL,
|
|
guardian_name = NULL,
|
|
guardian_phone = NULL,
|
|
guardian_email = NULL,
|
|
notes = '[GDPR anonymisiert am ' || to_char(now(), 'YYYY-MM-DD') || ']',
|
|
custom_data = '{}'::jsonb,
|
|
online_access_key = NULL,
|
|
online_access_blocked = true,
|
|
gdpr_consent = false,
|
|
gdpr_newsletter = false,
|
|
gdpr_internet = false,
|
|
gdpr_print = false,
|
|
gdpr_birthday_info = false,
|
|
is_archived = true,
|
|
updated_by = v_user_id
|
|
WHERE id = p_member_id;
|
|
|
|
-- Anonymize SEPA mandates (can't DELETE due to ON DELETE RESTRICT from Phase 1)
|
|
-- primary_mandate_id already cleared above in the members UPDATE
|
|
-- Anonymize SEPA PII fields (keep row for audit, revoke mandate)
|
|
UPDATE public.sepa_mandates
|
|
SET iban = 'DE00ANON0000000000000', bic = NULL, account_holder = 'ANONYMISIERT',
|
|
mandate_reference = 'ANON-' || id::text, status = 'revoked',
|
|
notes = '[GDPR anonymisiert]'
|
|
WHERE member_id = p_member_id;
|
|
|
|
-- Remove communications (may contain PII)
|
|
BEGIN
|
|
DELETE FROM public.member_communications WHERE member_id = p_member_id;
|
|
EXCEPTION WHEN undefined_table THEN NULL;
|
|
END;
|
|
|
|
-- Remove portal invitations
|
|
DELETE FROM public.member_portal_invitations WHERE member_id = p_member_id;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.anonymize_member(uuid, uuid)
|
|
TO authenticated, service_role;
|
|
|
|
-- Batch enforcement: find and anonymize members matching retention criteria
|
|
CREATE OR REPLACE FUNCTION public.enforce_gdpr_retention_policies()
|
|
RETURNS int
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
v_policy record;
|
|
v_member record;
|
|
v_count int := 0;
|
|
BEGIN
|
|
FOR v_policy IN
|
|
SELECT * FROM public.gdpr_retention_policies
|
|
WHERE auto_anonymize = true
|
|
LOOP
|
|
FOR v_member IN
|
|
SELECT m.id
|
|
FROM public.members m
|
|
WHERE m.account_id = v_policy.account_id
|
|
AND m.status = ANY(v_policy.applies_to_status::public.membership_status[])
|
|
AND m.first_name != 'ANONYMISIERT' -- not already anonymized
|
|
AND m.exit_date IS NOT NULL -- only retain based on actual exit date
|
|
AND m.exit_date + (v_policy.retention_days || ' days')::interval <= current_date
|
|
LOOP
|
|
PERFORM public.anonymize_member(v_member.id, NULL);
|
|
v_count := v_count + 1;
|
|
END LOOP;
|
|
END LOOP;
|
|
|
|
RETURN v_count;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.enforce_gdpr_retention_policies()
|
|
TO service_role;
|