-- 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 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());