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>
81 lines
3.4 KiB
SQL
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.
|
|
-- =============================================================================
|