Ground Truth

Read-Only Is Not a Safety Boundary: It Protects Your Data From the Agent, Not Your Users From It

Mostafa DhouibMostafa Dhouib·7 min read
The short answer

Giving an agent a read-only database role stops it writing. It does nothing about what it can see, because Postgres owner roles and the pg_read_all_data role bypass row-level security by default. If your product's promise is that one party cannot see another party's identity until they release it, that promise has to be enforced in the database, and read-only does not enforce it.

Read-Only Is Not a Safety Boundary: It Protects Your Data From the Agent, Not Your Users From It

The short answer. Read-only is the standard first safety measure when you connect an agent to a production database, and it is a reasonable one, but it answers the wrong question. It guarantees the agent cannot change anything. It says nothing about what the agent can read, and in Postgres a role that owns the tables, or one granted pg_read_all_data, bypasses row-level security entirely. So if your product promises that one party cannot see another party's identity until it is released, a read-only agent can still read every name in the table, and the promise was never enforced anywhere except in the application code the agent is not going through.

Someone is about to connect a language model to their production database. They are sensibly nervous, and the first control they reach for is read-only access.

It is a good instinct and it covers a real risk. An agent with write access that misinterprets a request can corrupt data in ways that are expensive to unpick. Taking writes off the table removes that whole class.

The problem is that it removes one class and is widely treated as having removed the category. Read-only is a integrity control. It is not a confidentiality control, and those are different properties with different mechanisms.

The two questions

Connecting an agent to data raises two separate questions.

Can it change something it should not change? Read-only answers this, completely.

Can it see something it should not see? Read-only does not address this at all. It is, if anything, the opposite: a read-only role is a role optimised for reading, and the most common way to create one is to grant broad read access across the schema.

For many internal tools the second question does not bite, because every user of the tool is entitled to see everything in it. The moment your product has a confidentiality promise between its own users, it bites hard.

Can it change what it should not
Read-only answers this
completely
the whole class of corrupting writes is gone
Why the instinct is right
it covers a real risk
an agent that misreads a request cannot rewrite anything
Can it see what it should not
Read-only answers this
not at all
if anything it is the opposite, because the role is optimised for reading
Where the promise lived
application query layer
the layer the agent is not going through
What the agent reads instead
the tables
every borrower name, including the deals nobody released
How the disclosure surfaces
quietly
in reasoning, in an explanation, in a summary
integrity is not confidentiality
For an internal tool whose every user may see everything, the second question does not bite. For a marketplace promise it is the product.
FigureConnecting an agent to data raises two separate questions and read-only answers only one of them. It is an integrity control routinely mistaken for a confidentiality control, and the mistake bites the moment your product carries a promise between its own users rather than between you and them.

Where it actually hurts

Consider a lending marketplace. The public promise is that lenders see deal metrics only, and the borrower's identity stays hidden until the borrower chooses to release it. That promise is the product. It is why borrowers are willing to list at all.

Now add an agent that helps match deals to lenders. Give it read-only access, because that is the responsible thing to do.

The agent can read the borrowers table. Names, addresses, everything, for every deal including the ones that have not been released. Nothing has been violated by the database's rules, because you granted read access and it read. The confidentiality promise lived in application code, in the query layer that filtered out unreleased identities, and the agent is not going through that layer. It is going straight to the tables.

The failure mode is the quiet one. The agent does not announce that it has read a name. It may use it in reasoning, surface it in an explanation, or include it in a summary, and any of those is a disclosure of the exact thing the product promised to withhold.

Why row-level security does not automatically save you

The right mechanism is row-level security, which pushes the filter into the database so it applies regardless of who is querying or how. Write the policy, enable it, and the unreleased identities are invisible.

Two defaults undermine this, and both are easy to hit.

Table owners are exempt. A role that owns a table is not subject to that table's policies unless you explicitly force it. If your agent's role was created by copying the application's role, or if it owns anything in the schema, the policy does not apply to it. FORCE ROW LEVEL SECURITY is what closes this, and it is not the default.

pg_read_all_data bypasses policies. This predefined role exists to make read-only access convenient, and it is exactly what somebody reaching for a read-only agent role is likely to grant, because its name describes precisely what they want. It grants read on everything, and it is not subject to row-level security. You have created the most permissive possible reader and labelled it safe.

That second one is worth sitting with. The convenient path to read-only is the path that defeats the control you need. The thing that sounds like a safety measure is the thing that removes the protection.

Role that owns the tablespolicies do not applya table owner is exempt unless forced, and a role copied from the application often owns something
Role granted read on everythingbypasses policiesthe predefined read all data role is exactly what someone reaching for read-only will grant, because its name describes what they want
Force row level securitycloses the owner gapand it is not the default
Grants on the specific tables neededleast privilegenot on the schema, and certainly not the read all data role
Policy expressing the application ruleone boundary instead of twothe agent now reads through the same filter as everyone else
The test is the negative one. Ask for something the agent should not see and confirm it gets nothing, because a test that it can see what it should see passes in both configurations.
FigureRow-level security is the right mechanism and two Postgres defaults walk straight past it. The convenient route to a read-only role is the route that defeats the control you need, which is why the grant that sounds like a safety measure is the one that removes the protection.

What read-only should be

Read-only is still worth having. It is just the wrong layer to put your confidence in.

The role should be least-privilege rather than broadly read-only: grants on the specific tables the agent needs, not on the schema, and certainly not pg_read_all_data. Row-level security on, forced, with the policy expressing the same rule the application's query layer expresses. Then the agent is reading through the same boundary as everyone else, and the boundary is in one place instead of two.

The test is the same one that catches every permissions bug: ask for something the agent should not be able to see and confirm it gets nothing. A test that confirms the agent can see what it should see passes in both the safe and the unsafe configuration.

Score it on what already happened

A second thing worth doing while the boundary is being built, because it is cheap and it answers a different question: score the agent against deals that have already closed.

You have a history of real scenarios with known outcomes. That is an acceptance set nobody has to construct, it reflects the actual distribution of cases rather than the ones somebody thought to write down, and it tells you whether the agent's judgement is worth anything before it touches a live decision.

Thirty to fifty past scenarios is usually enough to find out whether the thing is systematically wrong, which is the question that matters before any of the subtler ones.

The honest limit

Everything here is about the database boundary, which is necessary and is not the whole surface. An agent that can read the right rows can still disclose them to the wrong person through the application, log them somewhere unprotected, or include them in a context window that gets persisted. Those are separate controls and this article does not cover them.

What it covers is narrower: read-only is an integrity control that is routinely mistaken for a confidentiality control, and in Postgres the two most convenient ways to implement it both bypass the mechanism that would have provided confidentiality.

FAQ

Does read-only database access make an AI agent safe? It makes it safe against one risk, which is the agent changing data. It does nothing about what the agent can read, so it is an integrity control rather than a confidentiality one. If your product has a promise about what one user cannot see about another, read-only does not enforce that promise.

Does pg_read_all_data respect row-level security? No. The pg_read_all_data predefined role bypasses row-level security policies. It is the convenient route to a read-only role, which makes it a trap: the grant that sounds like the safety measure is the one that removes the protection you actually need.

Why is my agent's role exempt from my row-level security policy? Most likely it owns the tables, or inherited ownership from the role it was copied from. Table owners are not subject to their tables' policies unless FORCE ROW LEVEL SECURITY is set, which is not the default. Create the agent's role fresh, grant only the tables it needs, and force the policy.

Where should a confidentiality rule live, the application or the database? In the database, because that is the only place every caller has to pass through. A rule enforced in the query layer protects every path that goes through the query layer and no others, and an agent connected to the database directly is precisely a path that does not.

How do I test that an agent cannot see restricted data? Ask it for the restricted data and confirm it gets zero rows. Testing that it can retrieve what it is entitled to passes in both the safe and the unsafe configuration, so it tells you nothing about the boundary. The negative case is the only one that distinguishes them.

What is a good acceptance set for an agent making judgement calls? Scenarios you have already resolved, with known outcomes. Thirty to fifty past cases reflects the real distribution rather than the cases somebody thought to invent, costs nothing to assemble, and answers the blunt question of whether the agent is systematically wrong before any live decision depends on it.

Free worksheet
The Seam Register

The five signs that a failure is in the seams, then one register per boundary covering units, ranges, timing, retry ownership and partial-success behaviour. Filled in separately by both sides, which is the whole method.

One email, the resource, and nothing else unless you reply.
Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program