DEV Community

Takashi Matsuyama
Takashi Matsuyama

Posted on • Originally published at blog.tak3.jp

Hide It from People, Tell It to the Agent — What May This Role Touch?

Point an AI agent at a database through a least-privilege role and here is what happens. The agent writes a confident query against a table it cannot read. What comes back is permission denied. Or — if all it ever got was a catalog already filtered by privilege — it reads the absence as nonexistence, substitutes a similarly named column, and hands you a number with complete confidence.

The privileges are working. What isn't working is the telling. PostgreSQL is stopping it with GRANT and row-level security (RLS). The only party never told why, or how far, is the one being limited.

So: how should an agent be told what a role may touch? And — does telling it weaken the privileges?

This post is the record of how Kozou — a compiler that turns a PostgreSQL schema into AI context (handed to the agent over MCP, the standard protocol for connecting AI agents to external tools), an Admin UI, and a REST API — answered that. The short version: don't hide it from the agent, tell it. Hide it in the human Admin UI, annotate it in the AI's context: the same information, pointed in opposite directions.

Everything below is as of v1.17.0, using the demo schema bundled with Kozou's quickstart — a small online store (customers / products / orders / order_items plus three reporting views). The support_agent role, its grants, and the RLS are the overlay I added for this post. Version-wise: the privilege annotations require v1.8.0 or later and the RLS signal v1.11.0 or later, so an earlier version omits whichever of the two it predates.

The line already drawn

The Kozou introduction drew a line: Kozou hands meaning over, it doesn't enforce it — the actual access control stays on the PostgreSQL side, in privileges and RLS. The follow-up on writing schema meaning put it from the author's side: if you truly must hide something, stop it with a privilege, not a comment.

This post is about the outside of that line. You stopped it with a privilege. How does the agent find out?

Decision 1 — hide, or annotate?

Kozou's privilege support is opt-in. Turn it on with a role and two surfaces start reflecting what that role can do — and they do it in opposite directions. (The REST API and its OpenAPI document are not among them; they stay schema-wide. The API enforces each request with the caller's role and RLS, so it has no need for an advisory annotation.)

  • The Admin UI (for people): a table the role cannot SELECT disappears from the navigation. A column it cannot write doesn't disappear from the form, but it renders read-only per form mode — no INSERT privilege makes it read-only on create, no UPDATE privilege makes it read-only on edit.
  • The MCP describe_table / describe_view tools and kozou docs (for the agent): nothing is hidden. Every relation stays, and each one is annotated with what this role may and may not do.

It's easier to just look at it. Here is describe_table("public.customers") evaluated for support_agent — a support-desk role that can read orders but was never granted SELECT on the customer table (it holds personal data):

{
  "qualifiedName": "public.customers",
  "privileges": { "role": "support_agent", "select": false, "insert": false, "update": false, "delete": false },
  "columns": [
    { "name": "id",        "insertable": false, "updatable": false },
    { "name": "full_name", "insertable": false, "updatable": false },
    { "name": "email",     "insertable": false, "updatable": false }
    // …
  ]
}
Enter fullscreen mode Exit fullscreen mode

select is false — this role has no privilege to read this table. What you see here is the GRANT situation only, evaluated independently of the RLS we'll get to later (Kozou asks has_table_privilege). And the table still comes back whole. Columns, the @ai notes and the @policy business rules written into the table's COMMENT ON — all of it, still attached. kozou docs, the Markdown schema document, does the same: the section for that table doesn't vanish, its Security row just goes all no.

**Security** — effective privileges for role `support_agent` (advisory; PostgreSQL enforces access):

| SELECT | INSERT | UPDATE | DELETE |
| --- | --- | --- | --- |
| no | no | no | no |
Enter fullscreen mode Exit fullscreen mode

Now open the Admin UI with that same configuration. The header reads 3 tables / 0 views. customers is gone from the list, and so are the three views the role was never granted SELECT on. At the same moment, MCP and kozou docs return all seven objects — four tables and three views — each annotated with what the role can and cannot do.

Same privilege information, same configuration, opposite output.

Why point them in opposite directions

People don't look for what isn't there. A button you can't see doesn't get pressed; a menu item that's missing doesn't get demanded. For a human UI, not showing what can't be done is the kinder choice: it keeps controls that cannot succeed out of the way.

An agent, I assumed, is the opposite: it fills in absences by guessing. If the table isn't visible, it concludes no such table exists and reaches for a similarly named column instead. That is the same failure shape as the one in the previous post — an AI that sees only raw DDL, doesn't know which column is a trap, and is plausibly wrong — except now it happens on the privilege side. To be clear, this is not a measured result; it's the direction the design bet on.

So the agent is better told: it's here, and you cannot read it. "select": false isn't a refusal, it's information. The agent learns its limits before it tries.

The split is in the code's own vocabulary. In the comment beside Kozou's config schema, the Admin UI "hides tables the role cannot SELECT", while MCP and docs "do NOT hide — they keep every relation and annotate it". And the docs generator is handed a privilegeDisplay: 'annotate' when privilege mode is on.

The option not taken: hide on the AI side too and ship a narrowed schema. It looks like giving away less, but the agent learns the object doesn't exist and starts guessing to fill the hole where the overall picture used to be. Less information out, more mistakes back.

Decision 2 — read only the booleans

Beyond table-level and column-level GRANTs there's row-level security. What the agent gets about RLS is three booleans and one line of advice.

orders — RLS on, one policy:

"rowSecurity": {
  "enabled": true, "forced": false, "hasPolicies": true,
  "note": "Row-level security is enabled: the rows you can read and the rows you can write are filtered by policy for the connecting role, so do not assume a result is complete or that a write will be accepted."
}
Enter fullscreen mode Exit fullscreen mode

customers — RLS on, and not a single policy defined:

"rowSecurity": {
  "enabled": true, "forced": true, "hasPolicies": false,
  "note": "Row-level security is enabled but no policy is defined, so non-owner roles can read and write no rows (default-deny). RLS also applies to the table owner (roles with BYPASSRLS still bypass it)."
}
Enter fullscreen mode Exit fullscreen mode

That quirk of PostgreSQL — where writing no policy is the strictest setting you can pick, because RLS with no policy is default-deny — travels intact to the agent. forced means RLS applies to the owner too, and that gets a line as well.

What's absent here matters. The USING and WITH CHECK expressions — the policy bodies — are never handed over. Only the booleans are read; the expressions aren't even fetched.

Three reasons.

  1. It would put authorization logic in two places. A copy of the rules living in the context will go stale. That's the same problem as written meaning having a shelf life, from the previous post, now applied to authorization — and a stale explanation of who may see what is worse than a stale column comment.
  2. Knowing doesn't help you get around it. Reading the expression gives the agent no way past RLS; the database enforces it regardless. There's little to gain.
  3. The expressions themselves can be sensitive. How you distinguish between users is often exactly what you don't want disclosed.

What that costs is clear too. The agent can't explain why it was refused. A rejected write at least surfaces as an error; a SELECT is quieter — RLS drops the non-matching rows silently and returns a perfectly normal result. So what gets handed over instead is the warning that a result may not be complete. Explaining the reason is not a job this takes on.

The option not taken: summarize the policy expressions and pass the summary. The moment that summary goes stale, the agent starts lying with confidence — "you should be able to see this row."

Decision 3 — what gets to be opt-in

Two kinds of information have shown up: the role's privileges, and the RLS signal. Their defaults are opposites.

Run describe_table twice against the same database, changing only the configuration:

Field Default respectPrivileges: true
privileges absent present
per-column insertable / updatable absent present
rowSecurity present present

The rule fits in one line: role-dependent facts are opt-in; structural facts are included by default.

privileges is a lie unless you've settled whose privileges these are — which is why the output says whose: "role": "support_agent". Hand out a privilege picture without deciding the role and you've published misinformation, not information. Whether RLS is enabled, forced, or policy-less is a structural property of the table and doesn't depend on any role, so it can go out unasked.

The options not taken: enable both by default (with no role configured, you'd be handing out a privilege picture belonging to nobody), or make both opt-in (an agent walks into a default-deny table, gets an empty result it can't account for, and reasons from it).

One detail worth noting: evaluating the privileges doesn't require connecting as that role. It's has_table_privilege / has_column_privilege, so nothing borrows the role's authority just to describe it.

Decision 4 — don't let describe and act disagree

So far, everything has been about description. Kozou can also execute exposed functions over MCP (opt-in as well), and once execution is in play, a description is only accurate if it matches the role that acts.

Turn execution on and the annotated role is bound to the executing role. The agent cannot pick a role — self-elevation isn't forbidden so much as structurally unavailable.

Running the remote MCP endpoint as an OAuth resource server changes the shape: execution happens as each verified token's PostgreSQL role, and every assumable role must appear in an explicit allowlist. But the privilege annotation can only be combined with that when the allowlist contains exactly one role — any other combination refuses to start. If the acting role varies per caller while the annotation claims a single role, the annotation is a lie. Per-caller annotation doesn't exist yet.

The reason is simple: if the role whose privileges were described isn't the role that acts, the agent is working from an accurate description of the wrong role. A description is only as true as its agreement with execution.

The option not taken: let the annotated role and the execution role be configured separately. More flexible — and it would let you run in a state where the two disagree. So it isn't configurable; the disagreeing combinations fail at startup.

The boundary — this is not permission

Finally, what this deliberately doesn't do.

An annotation is not a permission. "select": true is advice that reading should work, not a grant. Granting is what GRANT and RLS do, and there is nothing Kozou can add to that (on the execution side it can narrow things — the functions it exposes go through an allowlist). The same idea shows up in how functions are published: whether an agent may run a function exposed with @expose: rpc is decided by the EXECUTE privilege. Exposure is not permission.

Executing as a single role is not multi-tenant per-user authorization. There's no per-caller identity in it. That's the job of the REST surface, or of the OAuth path where the role comes from the token.

There is a cost, though. Not hiding means the context ends up carrying the names of tables the role cannot read, their columns, and the business notes written on them. Not one bit of data access changes, but the disclosure surface of the schema as metadata grows. If the audience for that surface is wider than the database role — say you expose the MCP endpoint beyond your machine — that needs designing separately.

With that said, back to the opening question. Telling doesn't weaken the privileges. Access to data stays exactly where PostgreSQL put it. The riskier party, I'd argue, is the agent that doesn't know its limits: it writes speculative workarounds, reads an empty result as "there is no data," and carries that into its conclusion.

Try it

To see this on your own schema, there are three steps.

  1. Create one least-privilege role. The trick is to deliberately leave one table without SELECT — that's where the interesting part of this design becomes visible.
   CREATE ROLE support_agent NOLOGIN;
   GRANT USAGE ON SCHEMA public TO support_agent;
   GRANT SELECT ON orders, order_items, products TO support_agent;  -- customers withheld
   GRANT INSERT ON orders TO support_agent;                          -- the INSERT grant only
Enter fullscreen mode Exit fullscreen mode
  1. Add two lines of configuration.
   introspection:
     respectPrivileges: true
     role: support_agent
Enter fullscreen mode Exit fullscreen mode
  1. Call describe_table. kozou docs grows a Security section too — though the per-column insertable / updatable only exist in the MCP payload (docs stops at the four verbs per table). Views carry relation-level privileges only: PostgreSQL itself can grant on a view's columns, but Kozou only collects column-level privileges from tables.

With the grants above, orders comes back like this:

"privileges": { "role": "support_agent", "select": true, "insert": true, "update": false, "delete": false },
"columns": [
  { "name": "status", "insertable": true, "updatable": false },
  { "name": "channel", "insertable": true, "updatable": false }
  // …
]
Enter fullscreen mode Exit fullscreen mode

And then, writing this example, my own post tripped me up. This demo's orders has RLS enabled, and the only policy I wrote is for SELECT. In PostgreSQL, inserting into a table with RLS enabled requires an INSERT policy. So actually trying it gives you:

ERROR:  new row violates row-level security policy for table "orders"
Enter fullscreen mode Exit fullscreen mode

The GRANT INSERT is there. The payload says "insert": true. PostgreSQL refuses anyway. The annotation is saying "the privilege exists," not "this will go through." That is the most concrete possible form of what this whole post has been about — and it's why the same payload carries rowSecurity right beside it, warning that a write may be rejected. Neither half alone is enough to hand to an agent.

Privilege mode announces itself in the log, too:

[kozou mcp] privilege-aware context ON: describe tools annotate what role "support_agent" may touch (advisory; enforcement stays in PostgreSQL)
Enter fullscreen mode Exit fullscreen mode

advisory; enforcement stays in PostgreSQL — this post is, in the end, about what that one line means as a design.

Kozou lives at kozou.org and on GitHub (Apache-2.0). The demo schema above ships in the quickstart.

Summary

  • The same privilege information is hidden from people and annotated for the AI — on the bet that an agent kept in the dark fills the absence by guessing.
  • RLS travels as booleans only. The policy expressions stay unread, so authorization logic never leaves the database.
  • Role-dependent facts are opt-in; structural facts are included by default. And the role you describe must be the role that acts.
  • An annotation never promises the operation will go through. insert: true and an RLS refusal coexist happily.

Enforcement was PostgreSQL's all along. What changes by telling an agent its limits is whether it can do useful work inside them.

The Japanese version of this post — its "paired" article — is already live.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU

The "booleans only, expressions never leave the database" rule is the right call, and the reasons you give for it are the ones that actually bite — a stale copy of authorization logic is worse than no copy.

One extension that stays inside that rule, and which your own closing example argues for better than I can: hasPolicies is table-scoped, but RLS is enforced per command.

Your orders case is exactly the gap. RLS enabled, hasPolicies: true, GRANT INSERT present, "insert": true in the payload — and the insert still fails, because the only policy written was for SELECT. Everything the agent received was accurate, and none of it was sufficient to predict the refusal. The agent has no way to distinguish "policies exist and one covers INSERT" from "policies exist, none cover INSERT", which is the difference between a write that might be filtered and a write that cannot succeed at all.

That distinction is derivable without touching a single expression:

select cmd, count(*)
from pg_policies
where schemaname = 'public' and tablename = 'orders'
group by cmd;
Enter fullscreen mode Exit fullscreen mode

cmd alone, no qual, no with_check. Shape it the same way as the rest:

"rowSecurity": {
  "enabled": true, "forced": false, "hasPolicies": true,
  "policiesByCommand": { "select": 1, "insert": 0, "update": 0, "delete": 0 }
}
Enter fullscreen mode Exit fullscreen mode

Now "insert": true in privileges next to "insert": 0 in policies is a readable contradiction, and it is the one your post ends on. It carries no authorization logic, so it cannot go stale in the way you are guarding against — the counts change only when policies are added or dropped, which is the same event that changes hasPolicies.

It fits your framing too: this is a structural fact, not a role-dependent one, so it would sit on the default-on side of Decision 3 alongside the rest of rowSecurity.

One smaller note in the same spirit: counts also surface the case where two permissive policies exist on the same command. Permissive policies combine with OR, so a broad leftover silently widens a careful one — and hasPolicies: true reads identically whether there is one policy or three.

The design bet in Decision 1 matches what I see in practice, for what it is worth. An agent handed a narrowed catalog does not conclude "I lack privilege", it concludes the object does not exist and substitutes something plausible. Separately, on why single-identity testing hides authorization bugs from humans too: dev.to/cekuu35/your-supabase-rls-p...

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

I like the explicit “known but denied” state. The part I would make configurable is how much metadata crosses that boundary.

A denied table’s name may be useful; its column names, comments, and business rules may expose PII categories, internal workflows, or even instruction-like text the agent never needed. So I’d model at least three disclosure levels per role: full schema for usable objects, minimal capability metadata for known-but-denied objects, and fully hidden for sensitive objects.

That can be tested as a contract too: snapshot the discovery payload for each role, seed canary strings in restricted comments, and fail CI if a canary appears outside its allowed disclosure class. PostgreSQL still owns enforcement, while the MCP catalog gets its own least-information policy instead of treating metadata exposure as all-or-nothing.