yau-plant-assistant/db/003_roles.sql
Claude 12439fa387 Close two Phase 1 gate failures found on lin001
agent_ro could still create temporary tables: Postgres grants TEMP on every
database to PUBLIC, and revoking it from the role alone does not remove what
PUBLIC grants. Revoke it from PUBLIC too. postgres is a superuser and keeps
its temp tables, so the seed load is unaffected.

Nothing but the superuser could read schema fixture: 003_roles.sql grants only
on schema public, and the fixture schema is created afterwards. Grant read to
agent_ro and cube_rw. The grants live in 002_fixtures.sql because that file
opens with DROP SCHEMA fixture CASCADE, which would destroy grants held
anywhere else on every fixture reload.

Both re-verified on pg-ai: 11/11 privilege tests and 10/10 fixture access
tests pass, reads work for both roles, writes are rejected for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:02:51 +10:00

81 lines
3.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.
--
-- Two 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.
-- =============================================================================
-- --- 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;
-- =============================================================================
-- 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
--
-- An INSERT that succeeds here is a Phase 1 failure, not a detail to fix later.
-- The API's SQL allow-list in guardrails.py is the second line of defence, not
-- the first; this role is the first.
-- =============================================================================