-- ============================================================================= -- 003_roles.sql — least privilege inside pg-ai. -- -- psql -h pg-ai -U postgres -d plant -f 003_roles.sql -- -- Passwords are NOT in this file. Set them from the 0600 env files: -- \set agent_pw `echo "$AGENT_DB_PASSWORD"` -- or ALTER ROLE ... PASSWORD after creation, from a shell that reads ~/ai/*.env. -- -- Three roles, deliberately different: -- agent_ro the API. SELECT only, everywhere. It must not be able to write. -- cube_rw Cube. SELECT on reference data, full rights on cube_preagg only, -- because pre-aggregation refresh creates and drops tables there. -- ingest_rw ingestion. The ONLY role that writes doc_chunks. -- -- ingest_rw is created here, at Phase 1, and not with the Phase 9 upload tables -- that also use it. Ingestion has needed a writing role since Phase 3; it just -- never had one. ai-ingest took PGUSER from api.env, which is agent_ro, so -- `ai-ingest --all` connected as a SELECT-only role and could not insert a -- single chunk. Phase 3 was unrunnable as documented. -- ============================================================================= -- --- agent_ro — the application role. SELECT and nothing else. --------------- DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'agent_ro') THEN CREATE ROLE agent_ro LOGIN; END IF; END $$; REVOKE ALL ON DATABASE plant FROM agent_ro; GRANT CONNECT ON DATABASE plant TO agent_ro; REVOKE ALL ON SCHEMA public FROM agent_ro; GRANT USAGE ON SCHEMA public TO agent_ro; REVOKE ALL ON ALL TABLES IN SCHEMA public FROM agent_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_ro; -- Applies to tables created later, including the fixture tables. ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO agent_ro; -- No sequences, no functions, no temp tables, no schema creation. REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM agent_ro; -- Revoking TEMPORARY from the role alone is not enough: Postgres grants TEMP on -- every database to PUBLIC by default, and agent_ro inherits it. Phase 1 gate -- caught agent_ro creating a temp table. Revoke it from PUBLIC as well. -- Affects every non-superuser on plant (agent_ro, cube_rw). postgres is a -- superuser and is unaffected, so the seed load still works. If Cube ever needs -- temp tables, grant TEMPORARY back to cube_rw explicitly - never to PUBLIC. REVOKE TEMPORARY ON DATABASE plant FROM PUBLIC; REVOKE TEMPORARY ON DATABASE plant FROM agent_ro; REVOKE CREATE ON SCHEMA public FROM agent_ro; -- --- cube_rw — Cube. Read reference data, own cube_preagg. ------------------- DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cube_rw') THEN CREATE ROLE cube_rw LOGIN; END IF; END $$; GRANT CONNECT ON DATABASE plant TO cube_rw; GRANT USAGE ON SCHEMA public TO cube_rw; GRANT SELECT ON ALL TABLES IN SCHEMA public TO cube_rw; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO cube_rw; GRANT ALL ON SCHEMA cube_preagg TO cube_rw; ALTER SCHEMA cube_preagg OWNER TO cube_rw; -- agent_ro reads pre-aggregations but never writes them. GRANT USAGE ON SCHEMA cube_preagg TO agent_ro; ALTER DEFAULT PRIVILEGES FOR ROLE cube_rw IN SCHEMA cube_preagg GRANT SELECT ON TABLES TO agent_ro; -- --- ingest_rw — ingestion. The only role that writes doc_chunks. ----------- -- Used by `ai-ingest` from the command line and, from Phase 9, by -- ai-docs-worker. Never used by ai-api: the component reachable from the -- internet is not the component that can put a document chunk in front of an -- operator. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ingest_rw') THEN CREATE ROLE ingest_rw LOGIN; END IF; END $$; GRANT CONNECT ON DATABASE plant TO ingest_rw; GRANT USAGE ON SCHEMA public TO ingest_rw; GRANT SELECT ON ALL TABLES IN SCHEMA public TO ingest_rw; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ingest_rw; -- doc_chunks only. DELETE is required: re-ingesting a file deletes its chunks -- and reinserts them in one transaction, which is what stops re-runs -- duplicating. It writes no other table in this schema. GRANT INSERT, UPDATE, DELETE ON doc_chunks TO ingest_rw; GRANT USAGE, SELECT ON SEQUENCE doc_chunks_id_seq TO ingest_rw; REVOKE CREATE ON SCHEMA public FROM ingest_rw; REVOKE TEMPORARY ON DATABASE plant FROM ingest_rw; -- ============================================================================= -- Phase 1 gate — verify, do not assume. As agent_ro: -- -- SELECT count(*) FROM equipment; -- must work -- INSERT INTO equipment VALUES ('X'); -- must be REJECTED -- CREATE TABLE t (i int); -- must be REJECTED -- -- And as ingest_rw, because Phase 3 depends on it: -- -- INSERT INTO doc_chunks (source_file, doc_type, chunk_text) -- VALUES ('gate','manual','gate'); -- must SUCCEED -- DELETE FROM doc_chunks WHERE source_file = 'gate'; -- must SUCCEED -- INSERT INTO equipment VALUES ('X'); -- must be REJECTED -- -- An INSERT that succeeds as agent_ro is a Phase 1 failure, not a detail to fix -- later. An INSERT that FAILS as ingest_rw is the same failure in reverse: it -- means Phase 3 will not run. -- The API's SQL allow-list in guardrails.py is the second line of defence, not -- the first; this role is the first. -- =============================================================================