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>
124 lines
5.4 KiB
SQL
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.
|
|
-- =============================================================================
|