yau-plant-assistant/db/003_roles.sql
Claude 3262f1a050 Give ingestion a role that can write
ai-ingest built its DSN from PGUSER/PGPASSWORD and takes its environment from
~/ai/api.env, where PGUSER=agent_ro - SELECT and nothing else, deliberately,
because it is what the answer path runs as. So

    docker compose -f ~/ai-compose.yml run --rm ai-ingest --all

connected as a role that cannot INSERT INTO doc_chunks, and Phase 3 was
unrunnable exactly as the README documents it. Nothing had reached Phase 3 yet,
so nobody had hit it.

The failure would also have landed at the worst possible moment: at the final
INSERT, after the Docling parse, after a person had typed the header
confirmations for every file, and after a billed embeddings call - with a
permission error naming no cause.

  - ingest_rw moves to 003_roles.sql, at Phase 1 with the other roles. It is
    not a Phase 9 concept; ingestion has needed a writing role since Phase 3
    and never had one. 004 keeps only its grants on the upload queue, and its
    idempotent role creation so it still applies to an older database.
  - ingest.py connects through INGEST_DB_USER / INGEST_DB_PASSWORD, falling
    back to PGUSER only for a local shell where one pair is set.
  - require_write_access() checks INSERT, UPDATE and DELETE on doc_chunks
    before anything is parsed or embedded, and fails with the fix in the
    message. Falling back to PGUSER cannot smuggle agent_ro past it.
  - Keyword connection parameters rather than a URL: a generated password
    containing @ or / breaks a DSN string silently.
  - A missing doc_chunks now says "apply 001_schema.sql" instead of raising
    UndefinedTable.

Phase 1's gate gains the check that would have caught this: ingest_rw must be
able to write doc_chunks. An ingestion role that cannot write is the same class
of failure as an API role that can - it just surfaces two phases later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:45:20 +10:00

124 lines
5.4 KiB
SQL

-- =============================================================================
-- 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.
-- =============================================================================