Lo que un rol de solo lectura de Postgres frena y lo que no cuando se conecta un agente de IA

Un experimento reproducible en PostgreSQL 17.11: el rol cuidadoso, los errores que produce, los ajustes que cualquier sesión puede desactivar y las lecturas que igual hacen daño.

14 min de lectura
POSTGRESQL EXPERIMENTO

Los agentes de IA ya tienen credenciales de bases de datos. Los agentes de código leen DATABASE_URL del proyecto, los servidores MCP exponen una herramienta query y las funciones de text-to-SQL ejecutan lo que escribe un modelo. En 2026 hubo reportes públicos de agentes que borraron tablas y columnas en producción; reunimos 13 casos documentados, con sus fuentes, en un artículo anterior.

La primera respuesta habitual es un rol de solo lectura, y es la correcta para empezar. Este artículo comprueba qué hace ese rol en PostgreSQL 17, sentencia por sentencia: qué errores produce, cuáles de sus ajustes puede desactivar una sesión y qué lecturas permite que quizá no quieras que haga un agente.

Divulgación. Construimos Vericto, así que tenemos interés en el tema. Todo lo que sigue es PostgreSQL puro, sin ningún componente de Vericto en el camino. Cada salida se copió de una ejecución real; los comandos están en el apéndice.

El entorno

Un contenedor desechable de postgres:17 (17.11) en su propia red de Docker, sin puertos publicados, y un segundo contenedor en la misma red como cliente. Todas las sesiones de este artículo son psql 17.11 por TCP con autenticación por contraseña, ejecutado con -e para que cada sentencia aparezca encima de su resultado.

docker network create hnro-net
docker run -d --name hnro-db --network hnro-net -e POSTGRES_PASSWORD=admin-demo-pw postgres:17
docker run -d --name hnro-client --network hnro-net --entrypoint sleep postgres:17 infinity
docker port hnro-db        # prints nothing: no published ports

# every agent session in this post:
docker exec -i -e PGPASSWORD=agent-demo-pw hnro-client \
  psql -X -e "host=hnro-db dbname=shop user=agent_ro"

La base shop pertenece a app_owner y tiene tres tablas: customers (con nombre, email y teléfono), orders, repartida entre los tenants 42 y 7, y api_keys. También hay una función auxiliar, refund_order(int), escrita como muchas aplicaciones las escriben: SECURITY DEFINER con un search_path fijo.

El rol cuidadoso del agente

La tarea del agente es responder preguntas sobre pedidos y clientes. Este es el rol, creado como lo crearía un checklist cuidadoso, como superusuario:

CREATE ROLE agent_ro LOGIN PASSWORD 'agent-demo-pw'
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS
  CONNECTION LIMIT 5;
CREATE ROLE
GRANT CONNECT ON DATABASE shop TO agent_ro;
GRANT
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE
GRANT USAGE ON SCHEMA public TO agent_ro;
GRANT
GRANT SELECT ON orders, customers TO agent_ro;
GRANT
ALTER ROLE agent_ro SET statement_timeout = '5s';
ALTER ROLE
ALTER ROLE agent_ro SET default_transaction_read_only = on;
ALTER ROLE

REVOKE CREATE ON SCHEMA public FROM PUBLIC no cambia nada en una base creada por PostgreSQL 15 o posterior, donde el schema public pertenece a pg_database_owner y solo el dueño de la base puede crear objetos en él. El capítulo de schemas lo dice así: “In databases upgraded from PostgreSQL 14 or earlier, everyone has that privilege on the schema public.” Si tu base llegó con pg_upgrade desde la 14 o anterior, lo necesitas. El ACL del schema después del grant:

SELECT nspname, nspacl FROM pg_namespace WHERE nspname = 'public';
 nspname |                                           nspacl                                           
---------+--------------------------------------------------------------------------------------------
 public  | {pg_database_owner=UC/pg_database_owner,=U/pg_database_owner,agent_ro=U/pg_database_owner}
(1 row)

Los atributos de rol NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS ya son los valores por defecto de CREATE ROLE. Escribirlos deja la intención a la vista de quien revise el script.

Lo que frena

Con los valores por defecto activos, estos son los errores que recibe el agente:

SELECT current_user,
       current_setting('default_transaction_read_only') AS read_only_default,
       current_setting('statement_timeout') AS statement_timeout;
 current_user | read_only_default | statement_timeout 
--------------+-------------------+-------------------
 agent_ro     | on                | 5s
(1 row)

DELETE FROM orders WHERE id = 1;
ERROR:  cannot execute DELETE in a read-only transaction
UPDATE orders SET total = 0;
ERROR:  cannot execute UPDATE in a read-only transaction
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1);
ERROR:  cannot execute INSERT in a read-only transaction
CREATE TABLE notes (body text);
ERROR:  cannot execute CREATE TABLE in a read-only transaction
COPY (SELECT 1) TO PROGRAM 'id';
ERROR:  permission denied to COPY to or from an external program
DETAIL:  Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
HINT:  Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
SELECT * FROM api_keys;
ERROR:  permission denied for table api_keys
Timing is on.
SELECT pg_sleep(30);
ERROR:  canceling statement due to statement timeout
Time: 5000.515 ms (00:05.001)

Fallan las escrituras y el DDL, falla COPY ... TO PROGRAM, no se puede leer una tabla sin grant y un sleep de 30 segundos se cancela a los 5. COPY TO PROGRAM exige superusuario o pertenecer a pg_execute_server_program, que la página de roles predefinidos describe así: “Allow executing programs on the database server as the user the database runs as with COPY and other functions which allow executing a server-side program.” Por eso el rol de un agente nunca debe ser superusuario: con superusuario, una herramienta de solo lectura se convierte en una shell en el host de la base.

Pero mira qué error recibió cada escritura. Las cuatro primeras fallaron con cannot execute ... in a read-only transaction, no con un error de permisos. PostgreSQL revisa si la transacción es de solo lectura antes de revisar los privilegios de la tabla, así que respondió el ajuste. Esto es lo que dicen los GRANTs por sí solos, después de que la propia sesión desactiva el ajuste:

SET default_transaction_read_only = off;
SET
SHOW default_transaction_read_only;
 default_transaction_read_only 
-------------------------------
 off
(1 row)

DELETE FROM orders WHERE id = 1;
ERROR:  permission denied for table orders
UPDATE orders SET total = 0;
ERROR:  permission denied for table orders
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1);
ERROR:  permission denied for table orders
CREATE TABLE notes (body text);
ERROR:  permission denied for schema public
LINE 1: CREATE TABLE notes (body text);
                     ^

Las mismas sentencias, otros errores: permission denied for table orders y permission denied for schema public. Vienen de los GRANTs, y son los que se sostienen.

Lo que no frena, o solo parece frenar

default_transaction_read_only es un valor por defecto

La página de valores por defecto del cliente lo describe con precisión: “This parameter controls the default read-only status of each new transaction.” Un valor por defecto es aquello con lo que empieza una sesión. En el bloque anterior, el agente ejecutó SET default_transaction_read_only = off y recibió SET. Una sola transacción puede hacer lo mismo con BEGIN READ WRITE. Y como PUBLIC tiene TEMPORARY en toda base nueva, el agente puede escribir en una tabla temporal:

CREATE TEMP TABLE scratch AS SELECT * FROM orders;
ERROR:  cannot execute CREATE TABLE AS in a read-only transaction
BEGIN READ WRITE;
BEGIN
SHOW transaction_read_only;
 transaction_read_only 
-----------------------
 off
(1 row)

CREATE TEMP TABLE scratch AS SELECT * FROM orders;
SELECT 6
COMMIT;
COMMIT

Una tabla temporal vive en la sesión y no hace daño por sí misma, pero muestra que decide la sesión, no el rol. Hay un paso menos obvio. ALTER ROLE ... SET guarda un valor por defecto para las sesiones futuras, y la página de ALTER ROLE dice: “Ordinary roles can only set defaults for themselves.” Eso funciona en los dos sentidos:

ALTER ROLE agent_ro SET statement_timeout = 0;
ERROR:  cannot execute ALTER ROLE in a read-only transaction
BEGIN READ WRITE;
BEGIN
ALTER ROLE agent_ro SET statement_timeout = 0;
ALTER ROLE
COMMIT;
COMMIT
SHOW statement_timeout;
 statement_timeout 
-------------------
 0
(1 row)

SELECT rolname, setconfig FROM pg_db_role_setting s JOIN pg_roles r ON r.oid = s.setrole;
 rolname  |                       setconfig                        
----------+--------------------------------------------------------
 agent_ro | {statement_timeout=0,default_transaction_read_only=on}
(1 row)

El agente cambió su propio valor guardado, y cada sesión nueva del rol empezó sin timeout hasta que lo restablecimos como administrador. Con default_transaction_read_only funciona igual. La página de atributos de rol lo dice sin rodeos para cualquier ajuste por rol: “You can still alter this setting during the session; it will only be the default.” Trata estos ajustes como cinturones de seguridad contra accidentes y haz que los GRANTs sean el límite. Si los usas, revisa pg_db_role_setting de vez en cuando; el checklist de abajo incluye la consulta.

statement_timeout también es un valor por defecto

Lo mismo vale para el timeout. Un cross join sobre generate_series no necesita ningún grant de tabla, y una sesión puede quitar el límite antes de dormir:

Timing is on.
SELECT count(*) FROM generate_series(1, 100000) a CROSS JOIN generate_series(1, 100000) b;
ERROR:  canceling statement due to statement timeout
Time: 5000.608 ms (00:05.001)
SET statement_timeout = 0;
SET
Time: 0.084 ms
SELECT pg_sleep(8);
 pg_sleep 
----------
 
(1 row)

Time: 8004.167 ms (00:08.004)

generate_series y pg_sleep se pueden ejecutar desde PUBLIC, como la mayoría de las funciones incluidas. PostgreSQL 17 agregó transaction_timeout, que está en el mismo capítulo de ajustes del cliente y tiene la misma limitación. Si necesitas un tope firme, aplícalo desde fuera de la sesión: en un pooler o un proxy delante de la base, o con un rol vigilante que tenga pg_signal_backend y pg_read_all_stats y termine las queries largas del agente. Lo probamos contra una sesión que había puesto su timeout en 0:

SELECT pid, usename, now() - query_start > interval '3 seconds' AS over_limit, left(query, 30) AS query,
       pg_terminate_backend(pid) AS terminated
  FROM pg_stat_activity
 WHERE usename = 'agent_ro' AND state = 'active'
   AND now() - query_start > interval '3 seconds';
 pid | usename  | over_limit |        query         | terminated 
-----+----------+------------+----------------------+------------
  86 | agent_ro | t          | SELECT pg_sleep(60); | t
(1 row)

-- the agent's session, which had run SET statement_timeout = 0:
SET statement_timeout = 0;
SET
SELECT pg_sleep(60);
FATAL:  terminating connection due to administrator command
server closed the connection unexpectedly
	This probably means the server terminated abnormally
	before or while processing the request.
connection to server was lost

CONNECTION LIMIT sí se sostiene, porque es un atributo del rol y no un ajuste de la sesión. La documentación: “Connection limit can specify how many concurrent connections a role can make.” Con cinco sesiones ya abiertas, la sexta recibe:

psql: error: connection to server at "hnro-db" (172.19.0.2), port 5432 failed: FATAL:  too many connections for role "agent_ro"

Los wrappers de transacción de solo lectura en las herramientas

Muchas herramientas para agentes no dependen del rol. Envuelven cada query en una transacción de solo lectura. En agosto de 2025, Datadog Security Labs publicó un caso de estudio sobre una inyección SQL en el servidor MCP de referencia para Postgres. Enviaba BEGIN TRANSACTION READ ONLY, luego el SQL del agente y luego ROLLBACK. El driver aceptaba varias sentencias en un solo string, así que un SQL que empieza con COMMIT; cierra la transacción de solo lectura y ejecuta el resto fuera de ella. Su ejemplo era COMMIT; DROP SCHEMA public CASCADE;. El servidor ya estaba deprecado y archivado, y un fork publicó la corrección.

Reproducimos el mecanismo con psql: cada -c se envía como un mensaje Query del protocolo simple, lo mismo que hace client.query(sql) en node-postgres cuando no hay parámetros. La conexión usa app_rw, un rol con grants de escritura sobre orders, porque es común apuntar estas herramientas al rol de la propia aplicación:

$ psql -c "BEGIN TRANSACTION READ ONLY" -c "<agent SQL>" -c "ROLLBACK"   # one -c = one Query message
BEGIN TRANSACTION READ ONLY
BEGIN
DELETE FROM orders
ERROR:  cannot execute DELETE in a read-only transaction
ROLLBACK
ROLLBACK
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; DELETE FROM orders
COMMIT
DELETE 6
ROLLBACK
WARNING:  there is no transaction in progress
ROLLBACK
 count 
-------
     0
(1 row)

El DELETE solo se rechaza. El que va detrás de COMMIT; borra las seis filas, y el ROLLBACK del wrapper solo produce un aviso. El mismo wrapper, conectado como agent_ro:

BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; DELETE FROM customers
COMMIT
ERROR:  cannot execute DELETE in a read-only transaction
ROLLBACK
WARNING:  there is no transaction in progress
ROLLBACK
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; BEGIN READ WRITE; DELETE FROM customers; COMMIT
COMMIT
BEGIN
ERROR:  permission denied for table customers
ROLLBACK
ROLLBACK

Aquí el valor por defecto del rol frenó el primer intento, porque la transacción implícita después de COMMIT empezó en solo lectura. El segundo intento abrió su propio BEGIN READ WRITE y llegó a la revisión de GRANTs, que se sostuvo. La corrección que describe Datadog tiene dos partes: enviar el SQL del agente como una sola sentencia preparada (el protocolo extendido admite una sentencia por Parse) y descartar la conexión después de cada llamada, porque su artículo también muestra un SET statement_timeout apilado que persiste en una conexión del pool para el siguiente usuario. Las dos son buenas. Ninguna reemplaza a un rol sin grants de escritura.

Lecturas que igual hacen daño

Todas las columnas de una tabla con grant. GRANT SELECT ON customers da acceso a todas las columnas, incluidas las que la tarea nunca necesitó:

SELECT name, email, phone FROM customers ORDER BY id;
      name       |       email        |      phone       
-----------------+--------------------+------------------
 Ada Lovelace    | ada@example.com    | +44 20 7946 0001
 Alan Turing     | alan@example.com   | +44 20 7946 0002
 Grace Hopper    | grace@example.com  | +1 202 555 0103
 Edsger Dijkstra | edsger@example.com | +31 20 555 0104
(4 rows)

Los grants por columna lo resuelven. Después del cambio, falla una query que nombra una columna sin grant, y también falla SELECT *, algo a tener en cuenta si tu herramienta o los hábitos del modelo producen SELECT *:

REVOKE SELECT ON customers FROM agent_ro;
REVOKE
GRANT SELECT (id, tenant_id, country) ON customers TO agent_ro;
GRANT
SELECT email FROM customers;
ERROR:  permission denied for table customers
SELECT * FROM customers;
ERROR:  permission denied for table customers
SELECT id, country FROM customers ORDER BY id;
 id | country 
----+---------
  1 | GB
  2 | GB
  3 | US
  4 | NL
(4 rows)

Filas de otros tenants. Si la tarea se limita a un tenant, row-level security puede imponerlo para este rol:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE
CREATE POLICY agent_tenant_42 ON orders FOR SELECT TO agent_ro USING (tenant_id = 42);
CREATE POLICY
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id ORDER BY tenant_id;
 tenant_id | count 
-----------+-------
        42 |     3
(1 row)

Dos salvedades. RLS no se aplica al dueño de la tabla salvo con FORCE ROW LEVEL SECURITY, ni a los superusuarios ni a los roles con BYPASSRLS, una razón más para que el agente tenga su propio rol. Y una política basada en current_setting('app.tenant_id') no es un límite frente a un agente que escribe su propio SQL, porque el agente también puede ejecutar SET app.tenant_id. Ata la política al rol, como arriba, o a algo que la sesión no pueda cambiar.

Lecturas caras. Ya las vimos arriba: el cross join y pg_sleep corren hasta un timeout que controla la sesión. CONNECTION LIMIT acota cuántas sesiones así puede mantener abiertas el rol a la vez.

Los catálogos. information_schema filtra por privilegios. De la vista tables: “Only those tables and views are shown that the current user has access to (by way of being the owner or having some privilege).” pg_catalog no filtra, y cualquier rol puede leerlo:

SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1;
 table_name 
------------
 customers
 orders
(2 rows)

SELECT c.relname, has_table_privilege(c.oid, 'SELECT') AS can_select
  FROM pg_class c
 WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' ORDER BY 1;
  relname  | can_select 
-----------+------------
 api_keys  | f
 customers | f
 orders    | t
(3 rows)

SELECT attname, format_type(atttypid, atttypmod) AS type
  FROM pg_attribute WHERE attrelid = 'public.api_keys'::regclass AND attnum > 0 ORDER BY attnum;
 attname |  type   
---------+---------
 id      | integer
 service | text
 secret  | text
(3 rows)

SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;
  relname  | n_live_tup 
-----------+------------
 api_keys  |          2
 customers |          4
 orders    |          6
(3 rows)

SELECT rolname, rolsuper, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY 1;
  rolname  | rolsuper | rolcreaterole | rolbypassrls 
-----------+----------+---------------+--------------
 agent_ro  | f        | f             | f
 app_owner | f        | f             | f
 postgres  | t        | t             | t
(3 rows)

SELECT datname FROM pg_database ORDER BY 1;
  datname  
-----------
 postgres
 shop
 template0
 template1
(4 rows)

SELECT proname, prosecdef, proowner::regrole, prosrc FROM pg_proc WHERE pronamespace = 'public'::regnamespace;
   proname    | prosecdef | proowner  |                                          prosrc                                           
--------------+-----------+-----------+-------------------------------------------------------------------------------------------
 refund_order | t         | app_owner |  UPDATE orders SET status = 'refunded' WHERE id = p_id RETURNING 'refunded order ' || id 
(1 row)

SELECT usename, datname, state, left(query, 40) AS query FROM pg_stat_activity WHERE usename IS NOT NULL ORDER BY usename;
  usename  | datname | state  |                  query                   
-----------+---------+--------+------------------------------------------
 agent_ro  | shop    | active | SELECT usename, datname, state, left(que
 app_owner | shop    |        | <insufficient privilege>
 postgres  |         |        | <insufficient privilege>
(3 rows)

SELECT polname, pg_get_expr(polqual, polrelid) FROM pg_policy;
     polname     |   pg_get_expr    
-----------------+------------------
 agent_tenant_42 | (tenant_id = 42)
(1 row)

Desde la sesión del propio agente: la tabla que no puede leer (api_keys), sus columnas, cuántas filas tiene, todos los roles y si son superusuarios, todas las bases, el código completo de cada función, la política de RLS que acota al agente y el hecho de que app_owner tiene una sesión abierta en shop. El texto de esa query queda oculto. La documentación de estadísticas dice que “the existence of a session and its general properties such as its sessions user and database are visible to all users”; el resto de las columnas requiere pertenecer a ese rol o a pg_read_all_stats. Quitar el acceso a pg_catalog rompe a los clientes y no es una configuración soportada. Asume que el agente ve tu schema, y no pongas secretos en el cuerpo de las funciones, en comentarios ni en nombres de objetos.

Funciones que PUBLIC puede ejecutar

El capítulo de privilegios enumera lo que recibe PUBLIC al crear cada objeto: “CONNECT and TEMPORARY (create temporary tables) privileges for databases; EXECUTE privilege for functions and procedures; and USAGE privilege for languages and data types (including domains).” Eso incluye refund_order, que se ejecuta como su dueño:

SELECT has_function_privilege('refund_order(int)', 'EXECUTE');
 has_function_privilege 
------------------------
 t
(1 row)

SELECT refund_order(4);
ERROR:  cannot execute UPDATE in a read-only transaction
CONTEXT:  SQL function "refund_order" statement 1
BEGIN READ WRITE;
BEGIN
SELECT refund_order(4);
   refund_order   
------------------
 refunded order 4
(1 row)

COMMIT;
COMMIT
SELECT id, status FROM orders WHERE id = 4;
 id |  status  
----+----------
  4 | refunded
(1 row)

El agente no tiene grant de UPDATE sobre orders, pero la función tiene los privilegios de su dueño, y el pedido 4 ahora figura como reembolsado. Solo el valor por defecto de solo lectura frenó la primera llamada. Una función SECURITY DEFINER es una puerta a través de tus GRANTs, y la página de CREATE FUNCTION advierte que “by default, execute privilege is granted to PUBLIC for newly created functions”. Revócalo en las funciones existentes y cambia el valor por defecto para las que el dueño cree más adelante:

REVOKE EXECUTE ON FUNCTION refund_order(int) FROM PUBLIC;
REVOKE
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
ALTER DEFAULT PRIVILEGES
CREATE FUNCTION order_total(p_id int) RETURNS numeric LANGUAGE sql STABLE
  AS $$ SELECT total FROM orders WHERE id = p_id $$;
CREATE FUNCTION
SELECT p.proname, has_function_privilege('agent_ro', p.oid, 'EXECUTE') AS agent_can_execute
  FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace ORDER BY 1;
   proname    | agent_can_execute 
--------------+-------------------
 order_total  | f
 refund_order | f
(2 rows)

               Default access privileges
   Owner   | Schema |   Type   |   Access privileges   
-----------+--------+----------+-----------------------
 app_owner |        | function | app_owner=X/app_owner
(1 row)

BEGIN READ WRITE;
BEGIN
SELECT refund_order(5);
ERROR:  permission denied for function refund_order
ROLLBACK;
ROLLBACK
SELECT p.oid::regprocedure, p.proowner::regrole, p.proconfig
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
 WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema');
          oid          | proowner  |      proconfig       
-----------------------+-----------+----------------------
 refund_order(integer) | app_owner | {search_path=public}
(1 row)

ALTER DEFAULT PRIVILEGES solo cubre los objetos que se creen después, y solo para el rol de FOR ROLE (“the current role if unspecified”), así que ejecútalo para cada rol que cree funciones. La última consulta lista las funciones SECURITY DEFINER fuera de los schemas del sistema; revisa cada una como si fuera un endpoint de una API.

La credencial misma

Nada de esto importa si el agente puede leer una credencial más fuerte. Los agentes de código leen archivos y variables de entorno: un DATABASE_URL en .env, un ~/.pgpass o un PGPASSWORD en la shell, que libpq toma solo. Si ahí están las credenciales del dueño o de la aplicación, el agente puede conectarse con ese rol y todo lo anterior deja de aplicar. Dale al agente solo la credencial de su rol, deja las demás fuera de su directorio de trabajo y de su entorno, y usa un rol distinto por entorno para que una credencial de staging no llegue a producción.

Checklist

Probado en PostgreSQL 17.11 sobre una base nueva con las mismas tablas, ejecutado como superusuario. Cambia los nombres y la contraseña.

-- 1. One role per agent, with no attribute it does not need
CREATE ROLE agent_ro LOGIN PASSWORD 'change-me'
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION
  CONNECTION LIMIT 5;

-- 2. Database: connect, nothing else (PUBLIC gets CONNECT and TEMPORARY by default)
GRANT CONNECT ON DATABASE shop TO agent_ro;
REVOKE TEMPORARY ON DATABASE shop FROM PUBLIC;

-- 3. Schema: usage only. The REVOKE matters on databases upgraded from 14 or earlier
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO agent_ro;

-- 4. Tables and columns the task needs, nothing else
GRANT SELECT ON orders TO agent_ro;
GRANT SELECT (id, tenant_id, country) ON customers TO agent_ro;

-- 5. Rows, where the task is scoped to a tenant
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY agent_tenant_42 ON orders FOR SELECT TO agent_ro
  USING (tenant_id = 42);

-- 6. Functions: no EXECUTE for PUBLIC, now and for future functions
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner
  REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

-- 7. Defaults: seat belts, not walls. The role can change them itself; step 8 shows it
ALTER ROLE agent_ro SET statement_timeout = '5s';
ALTER ROLE agent_ro SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE agent_ro SET default_transaction_read_only = on;

-- 8. Verify, as an admin: what can the role actually do?
SELECT table_name, column_name, privilege_type
  FROM information_schema.column_privileges
 WHERE grantee = 'agent_ro' ORDER BY 1, 2;
SELECT has_database_privilege('agent_ro', 'shop', 'CREATE') AS db_create,
       has_database_privilege('agent_ro', 'shop', 'TEMP')   AS db_temp,
       has_schema_privilege('agent_ro', 'public', 'CREATE') AS schema_create;
SELECT m.roleid::regrole AS member_of
  FROM pg_auth_members m WHERE m.member = 'agent_ro'::regrole;
SELECT p.oid::regprocedure AS function, p.prosecdef AS security_definer
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
   AND has_function_privilege('agent_ro', p.oid, 'EXECUTE');
SELECT r.rolname, d.datname, s.setconfig
  FROM pg_db_role_setting s
  JOIN pg_roles r ON r.oid = s.setrole
  LEFT JOIN pg_database d ON d.oid = s.setdatabase
 WHERE r.rolname = 'agent_ro';

En esa ejecución, las consultas del paso 8 devolvieron lo esperado: SELECT sobre las ocho columnas listadas, sin CREATE ni TEMP en la base, sin CREATE en el schema, sin pertenencia a otros roles y sin ninguna función que el agente pueda ejecutar. El noveno punto no es SQL: el agente solo ve la credencial de su propio rol.

Las páginas de la documentación de PostgreSQL 17 en las que se apoya este artículo, con las citas revisadas el 9 de noviembre de 2026:

Lo que agregamos encima

Los roles deciden qué puede tocar la identidad de un agente. No miran la sentencia: un rol con permiso para DELETE en orders puede borrar una fila o todas. Vericto, que construimos nosotros, agrega guardrails de base de datos para agentes de IA en ese hueco: revisa cada sentencia antes de que se ejecute con reglas determinísticas, así que la misma sentencia recibe el mismo veredicto. Va encima del rol de arriba, no en su lugar.

Apéndice: cómo se ejecutó

PostgreSQL 17.11 desde la imagen postgres:17 (linux/arm64; el digest está abajo) en Docker 29.2.1, con psql 17.11 en un segundo contenedor; los dos en una red de Docker privada, sin puertos publicados. Las sesiones corrieron en este orden: el entorno, el rol, lo que frena, los valores por defecto, el límite de conexiones, las columnas, RLS, los catálogos, las funciones, el wrapper y el REVOKE de TEMPORARY. El rol vigilante y el checklist corrieron sobre una segunda base nueva. La salida estándar y la de error de psql se unieron dentro del contenedor y sin buffer, para que cada error aparezca debajo de su sentencia. Algunos bloques son extractos de sesiones más largas; ninguna salida se editó. Los contenedores y la red se borraron después de cada ejecución.

postgres@sha256:b8faeff7712663c2773a190181e1246e37e364b887611c04658c28b44e2408fe

El esquema y los datos:

CREATE ROLE app_owner LOGIN PASSWORD 'owner-demo-pw';
CREATE ROLE
CREATE DATABASE shop OWNER app_owner;
CREATE DATABASE
CREATE TABLE customers (
  id int PRIMARY KEY, tenant_id int NOT NULL,
  name text, email text, phone text, country text);
CREATE TABLE
CREATE TABLE orders (
  id int PRIMARY KEY, tenant_id int NOT NULL,
  customer_id int REFERENCES customers, total numeric(10,2),
  status text NOT NULL DEFAULT 'paid');
CREATE TABLE
CREATE TABLE api_keys (id int PRIMARY KEY, service text, secret text);
CREATE TABLE
INSERT INTO customers VALUES
  (1, 42, 'Ada Lovelace',  'ada@example.com',   '+44 20 7946 0001', 'GB'),
  (2, 42, 'Alan Turing',   'alan@example.com',  '+44 20 7946 0002', 'GB'),
  (3, 7,  'Grace Hopper',  'grace@example.com', '+1 202 555 0103',  'US'),
  (4, 7,  'Edsger Dijkstra','edsger@example.com','+31 20 555 0104',  'NL');
INSERT 0 4
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES
  (1, 42, 1, 120.00), (2, 42, 2, 35.50), (3, 42, 1, 9.99),
  (4, 7, 3, 410.00), (5, 7, 4, 18.25), (6, 7, 3, 72.00);
INSERT 0 6
INSERT INTO api_keys VALUES (1, 'payments', 'sk_live_demo_not_real'), (2, 'email', 'key_demo_not_real');
INSERT 0 2
CREATE FUNCTION refund_order(p_id int) RETURNS text
  LANGUAGE sql SECURITY DEFINER SET search_path = public AS
  $$ UPDATE orders SET status = 'refunded' WHERE id = p_id RETURNING 'refunded order ' || id $$;