Comparison

PgBeam vs a read-only Postgres role

A read-only role blocks writes and nothing else. PgBeam enforces read-only per credential at the wire and adds allowlists, PII masking, query budgets, a kill-switch, and a per-statement audit trail, without touching your database roles.

a read-only Postgres role: A read-only Postgres role is a database role with SELECT granted and writes withheld, created with GRANT inside the database.

A read-only role is the first thing most teams reach for when an agent needs database access, and for blocking writes it works. The gap shows up the moment you need anything more than read-only: a role cannot mask a column, cap query volume, audit per agent, or be revoked without a rotation. PgBeam enforces read-only at the wire and layers the rest on top, per credential, without changing your roles.

PgBeam vs a read-only Postgres role, side by side

CapabilityPgBeama read-only Postgres role
Block writes and DDLYes, at the wireYes (GRANT)
Table allowlistsPer credentialCoarse (GRANT per object)
Row-level WHERE filter per agentYesRLS, hand-written policies
PII masking (redact / null / hash)YesNo
Query budgets / max rowsYesNo
Per-statement audit trailYeslog_statement only
Instant revoke / kill-switchYes, per credentialRotate the role
Hosted MCP endpointYesNo
Works without touching DB rolesYesLives in the database

Read-only is the floor, not the policy

A read-only role enforces exactly one rule: no writes. It gives you no column masking, no per-query budget, no max-rows ceiling, and no per-agent audit beyond what log_statement records into the database log. For a single trusted reader that is fine. For an autonomous agent that can be prompt-injected, it leaves most of the blast radius open.

PgBeam enforces read-only at the wire and adds the rest as policy on the same credential: table allowlists, a row-level WHERE filter, PII masking, query-count budgets, max rows, and statement timeouts. An UPDATE or DELETE without a WHERE clause is blocked outright for agent credentials even where writes are allowed.

Revoke without a rotation

Revoking a read-only role means rotating it and updating every consumer that used it, which is slow enough that it rarely happens mid-incident. A PgBeam credential is revoked with one click, and a per-credential or project-wide kill-switch refuses the next statement immediately, with no change inside the database.

Because PgBeam lives in the wire protocol, none of this touches your database roles or requires a migration. The same policy works on RDS, Aurora, self-hosted, or any managed Postgres, and every decision is recorded in an exportable audit trail.

When a read-only Postgres role is the better fit

A read-only role is free, native, and needs no extra hop, and it is enforced by Postgres itself rather than by anything you or we operate. If a single trusted, non-agent reader needs SELECT on a few tables, and you never need masking, budgets, per-agent audit, or instant revoke, a role is the simplest possible answer and adding a proxy would be a worse design. It is also the control that keeps working when everything else is down, which is a real property and not a consolation prize. Start with the role in every case. This page is about the four questions it cannot answer once the reader is an agent rather than a person, and if none of those four apply to you, stop at the role.

Keep the role, add the policy it cannot express

These compose. The role stays as the floor Postgres enforces on its own, and the policy carries the rules that refer to columns, volume, and identity, which a role has no grammar for. Nothing here changes your database roles or needs a migration.

  1. Keep the read-only role as the floor

    Do not remove it. A proxy is a process, and a process can be misconfigured or bypassed by anyone holding the database's own connection string. The role is the control that holds when that happens, so it stays, and the session-level setting is worth adding because it survives a transaction being committed out from under it.

    psql, as an admin
    CREATE ROLE agent_ro LOGIN PASSWORD 'rotate-me';
    ALTER ROLE agent_ro SET default_transaction_read_only = on;
    GRANT CONNECT ON DATABASE mydb TO agent_ro;
    GRANT USAGE ON SCHEMA public TO agent_ro;
    GRANT SELECT ON public.orders, public.order_items TO agent_ro;
  2. Write the four rules a GRANT cannot say

    Column masking, a row cap, a query budget, and a statement timeout. Each of these is a sentence about a column or about volume, and GRANT's vocabulary is objects and privileges, so none of them can be written as a grant at all.

    Terminal
    pgbeam policies create \
      --name agent-readonly \
      --mode read_only \
      --allow public.orders --allow public.order_items \
      --mask customers.email=redact \
      --mask customers.ssn=hash \
      --max-rows 500 \
      --budget-queries-per-hour 300 \
      --statement-timeout-ms 8000
  3. Issue one credential per agent, not one role per agent

    This is the step that makes the audit trail answer the question an incident starts with. Three agents sharing agent_ro are one row in pg_stat_activity and one name in the log. Three credentials against the same upstream role are three identities, and revoking one does not touch the other two.

    Terminal
    pgbeam agents create --name analytics-agent --policy pol_xxx --expires 30d
    pgbeam agents create --name support-copilot --policy pol_xxx --expires 30d
    
    # revoke one without rotating anything
    pgbeam agents revoke agt_xxx --yes
  4. Check the rules you wrote are the rules that fire

    dry-eval runs a statement through the data plane's own policy engine and prints the verdict without touching your database. Run the statements you are afraid of before an agent does.

    Terminal
    pgbeam policies dry-eval --policy pol_xxx --sql "UPDATE public.orders SET status = 'paid'"
    pgbeam policies dry-eval --policy pol_xxx --sql "SELECT ssn FROM public.customers"
    pgbeam policies dry-eval --policy pol_xxx --sql "SELECT * FROM public.orders FOR UPDATE"

Point PgBeam at the read-only role you already have. The agent credential's policy is a ceiling under whatever that role can do, never a way around it, so adopting this cannot widen an agent's access beyond what the role already granted.

Six statements a read-only role permits

Every statement below is allowed by a role granted SELECT on the public schema, because every one of them is a read or is refused for a reason the role cannot state. The second column is what the same statement meets under a policy. Verdicts and rule names are the ones the policy engine emits.

The statements
1  SELECT id, total FROM public.orders LIMIT 10
2  SELECT email, ssn FROM public.customers WHERE id = 42
3  SELECT * FROM public.customers
4  UPDATE public.orders SET status = 'paid'
5  SELECT id FROM public.orders FOR UPDATE
6  SELECT most_common_vals FROM pg_stats WHERE tablename = 'customers'
pgbeam policies dry-eval, one line per statement
1  Verdict: allow    Rule: ok
2  Verdict: mask     Rule: masked_column      Masked: customers.email (redact), customers.ssn (hash)
3  Verdict: block    Rule: table_not_allowed
   Reason:  table "public.customers" is not in this credential's allowlist
4  Verdict: block    Rule: read_only
   Reason:  this credential is read-only; UPDATE is not permitted
5  Verdict: block    Rule: locking_select
   Reason:  SELECT ... FOR UPDATE/SHARE takes row locks and is blocked in read-only mode
6  Verdict: block    Rule: table_not_allowed
   Reason:  relation "pg_stats" is not in this credential's allowlist

Line 4 is the only one a read-only role also stops, and it is the line everybody designs for. The other five are the reason this page exists: a role that blocks writes has said everything it has to say, and five of six statements here are reads.

Line 2 is not a block. The statement runs, the agent gets its row, and two columns come back as a redaction token and a hash. A role has no way to express this: column-level GRANTs can withhold a column entirely, which breaks the query, and they have to be re-applied by hand after every migration that touches the table.

Line 5 is a read as far as GRANT is concerned, and it takes row locks. A read-only role permits it, and a lock held by a stuck agent session is an outage in a way that a slow SELECT is not.

Line 6 selects nothing from customers and reads sampled values of its columns out of the planner statistics. A role granted SELECT on the schema permits it. Catalogs carrying query text, credential material, raw object bytes, or sampled row values are excluded from the automatic catalog grant, so an operator has to name one in the allowlist for it to be readable.

Line 3 is the volume case. It is a legitimate read-only query against a table the role may read, and nothing in Postgres bounds how many rows leave. statement_timeout bounds how long it runs, which is a different question.

What happens when the thing you are worried about happens

The four questions a role cannot answer, in the order they usually get asked, with the honest boundary on each.

The agent is prompt-injected and told to dump the customer table

The statement is refused with SQLSTATE 42501 and a reason written for a model to read, so the agent's next turn is a corrected query rather than a retry loop. The refusal is recorded with the rule that fired and the credential that sent it.

blocked by PgBeam agent policy: table "public.customers" is not in
this credential's allowlist

Where this stops: If public.customers is in the allowlist because the agent legitimately needs it, the read is allowed, and masking, the row cap and the row filter are what bound the damage instead. A policy is only as narrow as what you wrote.

We need to cut one agent off right now, mid-incident

Revoke that credential, or throw the project-wide kill-switch. The next statement is refused. Nothing inside the database changes, and the other agents on the same upstream role keep working.

pgbeam agents revoke agt_xxx --yes

Where this stops: This governs connections that go through PgBeam. An agent still holding your database's own connection string is outside it, and the fix there is to stop issuing that string rather than to add a layer.

Someone asks which agent ran a statement three months ago

The audit trail carries the statement, the decision, the rule, rows returned, bytes out, latency, region and credential, hash-chained so an edited or deleted row breaks the chain and a verify endpoint reports where.

pgbeam audit list --event blocked --limit 20
pgbeam audit verify

Where this stops: It covers what crossed PgBeam. Statements that reached the database another way are in your own Postgres log against the role, with no per-agent identity attached, because that identity does not exist at the role level.

An agent runs an unbounded scan and pulls the whole table

The row cap truncates the result and the egress and query budgets bound the total. Row-returning MCP results carry a budget block with the limit, the amount used, the remainder and the reset time, so a well-behaved agent can pace itself rather than discovering the wall.

Where this stops: Budget counts are per-region approximations rather than a globally coordinated ledger, and the egress figure can trail the last statement by a moment. Leave headroom rather than setting a limit you intend to run right up to.

The proxy is down, or somebody bypasses it

Your read-only role is still there, still enforced by Postgres, and still blocking every write. That is the reason step one of the runbook above keeps it rather than replacing it.

Where this stops: The role is the floor and it is only the floor. During a bypass you have writes blocked and you do not have masking, budgets, allowlists, or per-agent attribution, because all four of those live in the layer that was bypassed.

Questions

Safe Postgres access for your agents

Start with a 14-day free trial. No credit card required.

Get Started