diff --git a/supabase/migrations/20260807001000_audit_and_integrations.sql b/supabase/migrations/20260807001000_audit_and_integrations.sql new file mode 100644 index 0000000..ec9efc7 --- /dev/null +++ b/supabase/migrations/20260807001000_audit_and_integrations.sql @@ -0,0 +1,227 @@ +-- Audit logging, administrative controls, and third-party integrations — +-- spec sections 16 and 15. +-- +-- The audit log is append-only by construction: authenticated is granted +-- SELECT and nothing else, and rows are written by a SECURITY DEFINER trigger +-- rather than by the client. A user who can edit a student cannot edit the +-- record of them having edited it. + +-- ============================================================================ +-- 1. AUDIT LOG +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.audit_log ( + id BIGGENERATED_PLACEHOLDER +); +DROP TABLE IF EXISTS public.audit_log; + +CREATE TABLE public.audit_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + table_name TEXT NOT NULL, + record_id UUID, + action TEXT NOT NULL, + + actor_id UUID, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + old_data JSONB, + new_data JSONB, + -- Columns that actually changed, so a diff does not have to be recomputed. + changed_columns TEXT[], + + CONSTRAINT audit_action_valid CHECK (action IN ('INSERT','UPDATE','DELETE')) +); + +-- Deliberately no INSERT/UPDATE/DELETE for authenticated: evidence only. +GRANT SELECT ON public.audit_log TO authenticated; +GRANT ALL ON public.audit_log TO service_role; +ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY; + +CREATE INDEX audit_table_record_idx ON public.audit_log (table_name, record_id, occurred_at DESC); +CREATE INDEX audit_actor_idx ON public.audit_log (actor_id, occurred_at DESC); +CREATE INDEX audit_occurred_idx ON public.audit_log (occurred_at DESC); + +DROP POLICY IF EXISTS "audit read" ON public.audit_log; +CREATE POLICY "audit read" ON public.audit_log FOR SELECT TO authenticated + USING (public.is_org_admin() OR public.is_auditor()); + +-- Generic row auditor. Attach with: +-- CREATE TRIGGER trg_audit_ AFTER INSERT OR UPDATE OR DELETE ON +-- FOR EACH ROW EXECUTE FUNCTION public.audit_row(); +CREATE OR REPLACE FUNCTION public.audit_row() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_old JSONB; + v_new JSONB; + v_id UUID; + v_changed TEXT[]; +BEGIN + IF TG_OP = 'DELETE' THEN + v_old := to_jsonb(OLD); + v_new := NULL; + ELSIF TG_OP = 'INSERT' THEN + v_old := NULL; + v_new := to_jsonb(NEW); + ELSE + v_old := to_jsonb(OLD); + v_new := to_jsonb(NEW); + SELECT array_agg(key) INTO v_changed + FROM jsonb_each(v_new) + WHERE v_old -> key IS DISTINCT FROM v_new -> key; + + -- Nothing actually changed; do not manufacture an audit row. + IF v_changed IS NULL THEN RETURN NULL; END IF; + END IF; + + BEGIN + v_id := COALESCE((v_new ->> 'id')::uuid, (v_old ->> 'id')::uuid); + EXCEPTION WHEN OTHERS THEN + v_id := NULL; -- tables whose primary key is not a UUID + END; + + INSERT INTO public.audit_log (table_name, record_id, action, actor_id, + old_data, new_data, changed_columns) + VALUES (TG_TABLE_NAME, v_id, TG_OP, (SELECT auth.uid()), v_old, v_new, v_changed); + + RETURN NULL; -- AFTER trigger; return value is ignored +END; +$$; + +-- Attach to the tables where "who changed this, and when" is a real question: +-- money, safety, permissions, and legally sensitive records. +DO $$ +DECLARE + t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'students','user_roles','invoices','invoice_line_items','ledger_entries', + 'student_credits','student_alerts','parenting_plans','households', + 'household_members','staff_certifications','tuition_rates', + 'tuition_adjustment_rules','student_tuition_assignments','scholarships', + 'payment_plans','staff_campus_assignments','campuses','student_campus_schedules' + ] + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS trg_audit_%1$s ON public.%1$I', t); + EXECUTE format( + 'CREATE TRIGGER trg_audit_%1$s AFTER INSERT OR UPDATE OR DELETE ON public.%1$I + FOR EACH ROW EXECUTE FUNCTION public.audit_row()', t); + END LOOP; +END $$; + +-- ============================================================================ +-- 2. ADMINISTRATIVE CONTROLS +-- ============================================================================ +-- Section 3 reserves "deletion controls" to the Super Administrator. Rather +-- than scatter that rule, destructive intent is recorded here and the actual +-- deletion is gated on a super-admin-approved request. + +CREATE TABLE IF NOT EXISTS public.deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + table_name TEXT NOT NULL, + record_id UUID NOT NULL, + reason TEXT NOT NULL, + + requested_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + review_note TEXT, + + CONSTRAINT dr_status_valid CHECK (status IN ('pending','approved','denied','completed')) +); + +GRANT SELECT, INSERT, UPDATE ON public.deletion_requests TO authenticated; +GRANT ALL ON public.deletion_requests TO service_role; +ALTER TABLE public.deletion_requests ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS dr_status_idx ON public.deletion_requests (status, requested_at DESC); +CREATE INDEX IF NOT EXISTS dr_requested_by_idx ON public.deletion_requests (requested_by); + +DROP POLICY IF EXISTS "deletion requests read" ON public.deletion_requests; +CREATE POLICY "deletion requests read" ON public.deletion_requests FOR SELECT TO authenticated + USING (public.is_org_admin() OR public.is_auditor() OR requested_by = (SELECT auth.uid())); + +DROP POLICY IF EXISTS "deletion requests insert" ON public.deletion_requests; +CREATE POLICY "deletion requests insert" ON public.deletion_requests FOR INSERT TO authenticated + WITH CHECK (requested_by = (SELECT auth.uid()) AND public.is_org_admin()); + +-- Only a super administrator may approve. +DROP POLICY IF EXISTS "deletion requests review" ON public.deletion_requests; +CREATE POLICY "deletion requests review" ON public.deletion_requests FOR UPDATE TO authenticated + USING (public.is_super_admin()) WITH CHECK (public.is_super_admin()); + +-- ============================================================================ +-- 3. INTEGRATIONS +-- ============================================================================ +-- Credentials are NOT stored here. `credential_ref` names a secret held +-- elsewhere — the same separation the existing mail_server_settings / +-- mail-crypto.server.ts pair already uses — so a read of this table never +-- discloses a usable key. + +CREATE TABLE IF NOT EXISTS public.integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + provider TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'other', + + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + + -- Non-secret configuration only: endpoints, IDs, feature switches. + config JSONB NOT NULL DEFAULT '{}'::jsonb, + credential_ref TEXT, + + is_active BOOLEAN NOT NULL DEFAULT FALSE, + last_sync_at TIMESTAMPTZ, + last_sync_status TEXT, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (provider, campus_id), + CONSTRAINT int_category_valid CHECK (category IN + ('payment','accounting','email','sms','calendar','sis','background_check','storage','other')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.integrations TO authenticated; +GRANT ALL ON public.integrations TO service_role; +ALTER TABLE public.integrations ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS int_campus_idx ON public.integrations (campus_id); +CREATE INDEX IF NOT EXISTS int_active_idx ON public.integrations (category) WHERE is_active; + +DROP TRIGGER IF EXISTS trg_int_upd ON public.integrations; +CREATE TRIGGER trg_int_upd BEFORE UPDATE ON public.integrations + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +CREATE TABLE IF NOT EXISTS public.integration_sync_logs ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + integration_id UUID NOT NULL REFERENCES public.integrations(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', + records_processed INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + + CONSTRAINT isl_status_valid CHECK (status IN ('running','success','partial','failed')) +); + +GRANT SELECT ON public.integration_sync_logs TO authenticated; +GRANT ALL ON public.integration_sync_logs TO service_role; +ALTER TABLE public.integration_sync_logs ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS isl_integration_idx + ON public.integration_sync_logs (integration_id, started_at DESC); + +-- Integrations are org-level configuration: super admins manage, org admins +-- and auditors read. Credentials never appear here regardless. +DROP POLICY IF EXISTS "integrations read" ON public.integrations; +CREATE POLICY "integrations read" ON public.integrations FOR SELECT TO authenticated + USING (public.is_org_admin() OR public.is_auditor()); +DROP POLICY IF EXISTS "integrations manage" ON public.integrations; +CREATE POLICY "integrations manage" ON public.integrations FOR ALL TO authenticated + USING (public.is_super_admin()) WITH CHECK (public.is_super_admin()); + +DROP POLICY IF EXISTS "sync logs read" ON public.integration_sync_logs; +CREATE POLICY "sync logs read" ON public.integration_sync_logs FOR SELECT TO authenticated + USING (public.is_org_admin() OR public.is_auditor());