Development

A Read-Only Database Role for Agent Tools: Grants and Limits

Antonio Evans7 min read

An agent arm reaches for a database cabinet, and a READ ONLY keycard opens only two drawers and some folders inside, while a red folder behind glass stays shut and a rejected write slip lies on the floor.
Key points

Key points

  1. 1A read-only role caps what a bad agent query can reach, even when the query is well indexed.
  2. 2Grants are additive and PUBLIC sits underneath everything, so revoke first, then grant only what the tool needs.
  3. 3Column-level SELECT grants work, but they fail differently from table-level grants and require the tool to name columns.
  4. 4The only proof of a read-only boundary is a test that attempts a write and asserts the database rejects it with SQLSTATE 42501.

Why an agent needs two walls, not one

Index design and access control solve different problems, and teams conflate them. An index makes one query shape cheaper: a composite index on three columns took a three-condition query from 16.6 ms to 0.04 ms by moving every predicate into the index condition and removing the sort (Milan Jovanovic). That is a performance outcome. It says nothing about what happens when the agent writes a query against a table you never indexed, or against a table you never intended it to see.

Query planners answer any request for any table the connecting role can read. The same asymmetry that governs composite indexes governs permissions. Postgres uses a composite index for a filter on its leading column, and the leftmost prefix rule means an index on (region, product) helps a filter on region alone but does nothing for product alone (Ken Wagatsuma). Permissions are blunter still: a table grant is all or nothing at the table level unless you go to columns, and a role either can or cannot see a table.

The decision rule is simple. Index work is for latency. Grants are for reach. If you can only do one, do the grants, because a permission error is recoverable and a data exfiltration is not.

The four ways a "read-only" role leaks

Most read-only roles people hand to tools are less read-only than they think. These are the four leak paths.

Failure modeWhat happensHow to detect it
PUBLIC grantsEvery role inherits privileges granted to PUBLIC, so a fresh role may already read more than intendedQuery information_schema.role_table_grants for grantee PUBLIC
ALL TABLES shortcutGRANT SELECT ON ALL TABLES IN SCHEMA later covers tables you never meant to exposeDiff the catalog against your committed allow-list
Default privilegesA table created later carries inherited default privileges from its creating roleCheck pg_default_acl for the tool role
OwnershipAn owner can always do everything and cannot be limited by grants aloneConfirm the tool role owns no objects

The ownership point surprises people. The PostgreSQL documentation states plainly that the right to modify or destroy an object is inherent in being the object's owner and cannot be granted or revoked in itself (PostgreSQL documentation). If your agent tool connects with the same role that ran the migrations, the grants you write later do not constrain it at all. The role must be created fresh, own nothing and inherit from nothing.

Step 1: Create a login role that owns nothing

Start with a dedicated login. The pattern most read-only guides converge on is CONNECT on the database, USAGE on the schema, then SELECT only on the tables you name (Simon Willison). The canonical Stack Overflow answer makes the same point: granting CONNECT alone lets a user connect but gives them no other privileges, and USAGE on the schema is what lets them see objects (Stack Overflow).

CREATE ROLE agent_analytics LOGIN PASSWORD :'pw'
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;

GRANT CONNECT ON DATABASE analytics TO agent_analytics;
GRANT USAGE ON SCHEMA public TO agent_analytics;

NOINHERIT is not decorative. If the role is ever added to a group, or a group is added to it by accident, inheritance is how privileges travel without anyone writing a GRANT against the tool role. Keeping it off makes every effective privilege traceable to one line of your allow-list.

Do not grant USAGE on schemas the agent has no business seeing. USAGE on a schema is what lets a role see the object names inside it, and a tool that can enumerate names will try them.

Step 2: Grant SELECT on named tables, then narrow to columns

Table-level grants are the baseline. Column-level grants are where least privilege pays off, and the syntax is supported directly in GRANT: the privilege is applied per column (PostgreSQL documentation).

-- Baseline: whole table
GRANT SELECT ON public.orders TO agent_analytics;

-- Narrower: named columns only
GRANT SELECT (order_id, placed_at, status, total_cents, region_code)
  ON public.order_items TO agent_analytics;

GRANT SELECT (order_id, customer_id, sku, quantity, total_cents)
  ON public.order_items TO agent_analytics;

Two operational consequences follow, and both bite in production.

First, SELECT * against a table where the role holds only column grants fails. The query needs every column, and the role cannot read email, phone or whatever else you withheld. So the agent's query builder must name columns. That is a feature: it forces the tool to declare what it reads and keeps sensitive columns out of the model context entirely.

Second, column grants and table grants fail differently. A table-level denial is a whole-table error. A column-level denial fires when the planner resolves the target list. Your error handling must tolerate both, and your test suite must exercise both.

A useful decision rule: grant at table level for dimensions the tool legitimately reads end to end (country codes, product categories), and at column level for anything carrying a person or a payment. If a column never belongs in a prompt, it should never be in the grant.

Step 3: Revoke the rest, including PUBLIC and defaults

Revoking is not symmetric with granting, and doing it in the wrong order leaves gaps. Run the sequence below as the object owner or a superuser.

-- 1. Remove the accidental PUBLIC surface
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM PUBLIC;

-- 2. Remove anything previously granted to the tool role
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM agent_analytics;

-- 3. Remove what the tool role inherited from groups
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM analyst_group;

-- 4. Remove future-object defaults
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  REVOKE SELECT ON TABLES FROM agent_analytics;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  REVOKE SELECT ON SEQUENCES FROM agent_analytics;

-- 5. Re-apply the allow-list from step 2

Steps 1 through 3 handle the present. Step 4 handles the future, and it is the one people skip. ALTER DEFAULT PRIVILEGES sets privileges applied to objects created later and does not affect objects that already exist; it also applies only to objects created by the role whose defaults you are altering (PostgreSQL documentation). If your migration role and your schema owner are different roles, you need one ALTER DEFAULT PRIVILEGES block per creating role. A single block against the wrong owner is silently ineffective.

Note the sequence revokes. A read-only analytics tool rarely needs sequence USAGE, and sequence access is a write-path primitive.

For a second wall, set the role's default transaction mode to read-only so that even a mistaken GRANT cannot produce a successful write:

ALTER ROLE agent_analytics SET default_transaction_read_only = on;

This is session state, not a privilege, and it can be changed inside the session unless you also prevent that. Treat it as a speed bump behind the grants, not a replacement for them.

Step 4: Test that a write fails cleanly

A grant set you have not tested is a hypothesis. Write the test as part of your migration and assert on the error class rather than the message text, which changes with version and locale.

import psycopg

WRITE_ATTEMPTS = [
    "INSERT INTO public.orders (order_id, status) VALUES (1, 'test')",
    "UPDATE public.orders SET status = 'test' WHERE order_id = 1",
    "DELETE FROM public.orders WHERE order_id = 1",
    "TRUNCATE public.orders",
]

with psycopg.connect(dsn, user="agent_analytics") as conn:
    for stmt in WRITE_ATTEMPTS:
        try:
            with conn.transaction():
                conn.execute(stmt)
        except psycopg.errors.InsufficientPrivilege as exc:
            assert exc.sqlstate == "42501", exc.sqlstate
        else:
            raise AssertionError(f"write succeeded: {stmt}")

SQLSTATE 42501 is insufficient_privilege, the class PostgreSQL raises when a role lacks a needed privilege. Asserting on the class rather than on English text means the test survives a locale change or a server upgrade.

Add four more assertions to the same file:

  1. A SELECT * on the column-restricted table fails.
  2. A SELECT naming only granted columns succeeds and returns rows.
  3. A SELECT on a table outside the allow-list fails.
  4. After each failed write, the target row count is unchanged.

The third catches a PUBLIC leak, because it fails only if you actually removed PUBLIC access. The fourth catches a driver that swallows the error and retries, which is the failure case the test exists for.

Run the suite in CI against a database restored from a recent production dump where possible, and against a schema-only fixture where it is not. A fixture that mirrors the real object list is what catches "a new table appeared and the role can read it" before the next deploy.

Where to put the credential, and what pooling does to it

The role is only as strong as the credential handling around it. Two practical points.

If you reach Postgres through PgBouncer, the authentication path matters. PgBouncer authenticates against an internal user list or passes the attempt through to Postgres, and it pools connections per user and per database (ScaleGrid). Transaction pooling, the recommended mode for most applications, assigns a server connection only for the duration of a transaction and breaks session-based features such as SET and LISTEN (Heroku Dev Center). That matters here because SET default_transaction_read_only is a session-level statement and does not persist across statements under transaction pooling. Grants do persist, because they are catalog checks and not session state. This is the strongest argument for putting the boundary in GRANT rather than in a session variable.

Separately, keep the credential distinct per tool. One agent_readonly shared by five tools gives you one revocation event for five consumers, and the audit trail, which comes from the role name in the server log, collapses to one indistinguishable actor. Per-tool roles also let you revoke one tool without breaking the other four.

If several tools talk to several databases and you want the key rotation and call logging in one place rather than in each tool's environment, a gateway in front of the models does not remove the database boundary, but it does centralize the key. Quanta Inference (https://quantaintelligence.ai/inference) puts text, image and embedding models behind one API key, which helps when the agent that writes the SQL and the agent that reads the results are different models. The database still enforces the grants.

The quarterly audit that actually happens

The allow-list drifts. New tables appear, someone adds a grant to fix an incident, and the boundary widens without anyone deciding to widen it. Run this and compare against the allow-list you keep in version control.

SELECT table_schema, table_name, privilege_type, grantee
FROM information_schema.role_table_grants
WHERE grantee IN ('agent_analytics', 'PUBLIC')
ORDER BY table_schema, table_name, grantee;

SELECT table_name, column_name, privilege_type
FROM information_schema.column_privileges
WHERE grantee = 'agent_analytics'
ORDER BY table_name, column_name;

SELECT defaclrole::regrole, defaclnamespace::regnamespace, defaclacl
FROM pg_default_acl;

The first query is the reachability map. The second is the column allow-list. The third is where default privileges live, and a row naming the tool role is the one you did not expect.

Diff that output against the committed allow-list in CI and fail the build on any addition. That converts an access-control review into a unit test, which is the only form of review that reliably happens.

One last calibration point on indexing, since teams often reach for index tuning to fix an agent that is misbehaving. Index advisors that optimize against a workload can and do produce recommendations that regress query performance when cost estimates are off (arXiv). A grant does not have that failure mode: it either permits a table or it does not. That asymmetry is the reason to settle the grants before tuning anything.

FAQ

FAQ

Does a table-level SELECT grant override a column-level restriction on the same table?

A table-level grant covers every column, because a table grant is the privilege to read the relation. If you want the column boundary, the role must not hold a table-level grant on that table. Revoke the table grant first, apply the column grants, then let your SELECT * test confirm it.

Why does my read-only role get permission denied on a table I never granted, when other roles can read it?

Almost certainly PUBLIC. Privileges granted to PUBLIC are available to every role on the system and a fresh role inherits them, which is why the standard advice is to revoke ALL on the schema and its tables from PUBLIC before granting anything to the tool role (PostgreSQL documentation). Query information_schema.role_table_grants for grantee PUBLIC to confirm.

Should the agent role be able to see the whole schema, or only its tables?

USAGE on the schema is what lets a role see object names, and without it the tool cannot even list what exists (Simon Willison). Grant USAGE on the schemas whose tables you have allow-listed and no others. Seeing a name is not the same as reading rows, but an agent that can enumerate names will try them.

Can I enforce read-only with a session setting instead of grants?

You can set default_transaction_read_only on the role, and it helps as a second wall, but it is session state. Under transaction pooling it does not persist across statements, and it can be changed inside a session unless you prevent that (PgBouncer features). Grants are checked against the catalog on every statement and survive pooling, so they belong first.

What exactly should the write test assert on?

Assert on SQLSTATE 42501 (insufficient_privilege) rather than on message text, and assert on the absence of side effects as well: after the failed INSERT, count the rows in the target table and confirm the count did not change. A driver that swallows the error and retries is the failure case the test exists to catch. Run the suite against a restored dump where possible so the object list matches production.

Sources

Sources

  1. From 17ms to 0.04ms: How to Design the Right SQL Index
  2. PostgreSQL Multi-Column Indexes Column Orders
  3. Documentation: 18: 5.8. Privileges
  4. Granting a PostgreSQL user read-only access to some tables
  5. How do you create a read-only user in PostgreSQL?
  6. PostgreSQL: Documentation: 9.1: GRANT
  7. Documentation: 18: ALTER DEFAULT PRIVILEGES
  8. PostgreSQL Connection Pooling: Part 2, PgBouncer - ScaleGrid
  9. PgBouncer Configuration and Best Practices - Heroku Dev Center
  10. Evaluating the Practical Effectiveness of LLM-Driven Index ...
  11. PgBouncer features
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles