brain-university-api / migrations /005_org_scope.pg.sql
jang0294's picture
Upload folder using huggingface_hub
6952bcb verified
Raw
History Blame Contribute Delete
5.82 kB
-- 005_org_scope (postgres) β€” Phase 2 tenancy (docs/TENANCY.md): org_id
-- columns + (org_id, <pk>) indexes on every tenant table, PLUS layer 3:
-- Row-Level Security.
--
-- The 'org-demo' default is the backward-compat bridge: all pre-tenancy rows
-- (and any writer not yet passing an org) belong to the bootstrap demo org.
--
-- RLS model β€” DENY BY DEFAULT, no permissive fallback:
-- * Policy predicate: org_id = current_setting('app.org_id', true).
-- current_setting(..., true) returns NULL when the setting is absent, so
-- the predicate is NULL β†’ row invisible / write rejected. A session that
-- forgets to set app.org_id sees ZERO rows β€” that is intentional; do NOT
-- "fix" it by adding an `OR current_setting(...) IS NULL` fallback.
-- * FORCE ROW LEVEL SECURITY: on managed Postgres (Render) the app role is
-- usually the table OWNER, and owners bypass plain ENABLE'd RLS. FORCE
-- applies the policy to the owner too, so there is no bypass path from
-- the application credentials.
-- * The app sets the org per transaction via atp/tenant_db.py:
-- SELECT set_config('app.org_id', :org, true) -- == SET LOCAL
-- inside the same transaction as the scoped statement. ALL application
-- access to these tables must therefore go through atp/tenant_db.py
-- (raw atp.db query()/execute() on tenant tables returns nothing / fails
-- WITH CHECK β€” by design).
-- * Superuser / a separate migration-owner role can still ALTER; app-role
-- credentials cannot disable their own policies unless they own the
-- tables β€” for defense in depth run the 002-documented REVOKEs and,
-- where possible, run the app as a non-owner role.
--
-- Ordering dependency: `access_log` is created by migration 004 (orgs /
-- users / access_log); 004 < 005 so it exists by the time this file runs.
--
-- Statement separator convention: a line containing only `--;;` splits this
-- file into statements (see atp/db.py).
ALTER TABLE atp_hitl ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
ALTER TABLE atp_requests ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
ALTER TABLE atp_evidence ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
ALTER TABLE atp_cert_awards ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
-- (org_id, <pk>) per docs/TENANCY.md invariants. jobs has no `id` column β€”
-- its pk is job_id; atp_requests' pk is TEXT id.
CREATE INDEX IF NOT EXISTS idx_atp_hitl_org ON atp_hitl(org_id, id);
--;;
CREATE INDEX IF NOT EXISTS idx_atp_requests_org ON atp_requests(org_id, id);
--;;
CREATE INDEX IF NOT EXISTS idx_atp_evidence_org ON atp_evidence(org_id, id);
--;;
CREATE INDEX IF NOT EXISTS idx_atp_cert_awards_org ON atp_cert_awards(org_id, id);
--;;
CREATE INDEX IF NOT EXISTS idx_jobs_org ON jobs(org_id, job_id);
--;;
-- ── Row-Level Security (layer 3) ───────────────────────────────────────────
-- CREATE POLICY has no IF NOT EXISTS β†’ DROP IF EXISTS first (002 trigger
-- pattern) so a partially-applied database can be re-run safely.
ALTER TABLE atp_hitl ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE atp_hitl FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON atp_hitl;
--;;
CREATE POLICY org_isolation ON atp_hitl FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));
--;;
ALTER TABLE atp_requests ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE atp_requests FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON atp_requests;
--;;
CREATE POLICY org_isolation ON atp_requests FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));
--;;
ALTER TABLE atp_evidence ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE atp_evidence FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON atp_evidence;
--;;
CREATE POLICY org_isolation ON atp_evidence FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));
--;;
ALTER TABLE atp_cert_awards ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE atp_cert_awards FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON atp_cert_awards;
--;;
CREATE POLICY org_isolation ON atp_cert_awards FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));
--;;
ALTER TABLE jobs ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE jobs FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON jobs;
--;;
CREATE POLICY org_isolation ON jobs FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));
--;;
-- access_log is created in 004 with an org_id column (nullable β€” rows with
-- NULL org_id are simply invisible under the policy below, which is the
-- correct deny-by-default). The ADD COLUMN is a belt-and-braces no-op that
-- keeps this file valid even if 004's shape drifts; the (org_id, id) index
-- lives with its DDL in 004.
ALTER TABLE access_log ADD COLUMN IF NOT EXISTS org_id TEXT NOT NULL DEFAULT 'org-demo';
--;;
ALTER TABLE access_log ENABLE ROW LEVEL SECURITY;
--;;
ALTER TABLE access_log FORCE ROW LEVEL SECURITY;
--;;
DROP POLICY IF EXISTS org_isolation ON access_log;
--;;
CREATE POLICY org_isolation ON access_log FOR ALL
USING (org_id = current_setting('app.org_id', true))
WITH CHECK (org_id = current_setting('app.org_id', true));