Terminal
This commit is contained in:
@@ -1,222 +0,0 @@
|
|||||||
-- 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 IF NOT EXISTS audit_table_record_idx ON public.audit_log (table_name, record_id, occurred_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS audit_actor_idx ON public.audit_log (actor_id, occurred_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS 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_<t> AFTER INSERT OR UPDATE OR DELETE ON <t>
|
|
||||||
-- 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());
|
|
||||||
Reference in New Issue
Block a user