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 (5)

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
 
takashimatsuyama profile image
Takashi Matsuyama • Edited

Thanks, Cenk KURTOĞLU (@cekuu35 ) — you've put your finger on the exact gap the post ends on, and your extension survives the "no expressions leave the database" rule, which is what makes it worth taking seriously.

I ran your shape against PostgreSQL 16 before answering, on four tables that differ only in how the policy is written. Same GRANT INSERT on all four, RLS enabled on all four:

the table's only policy your cmd count says INSERT actually
FOR ALL ALL: 1, so insert 0 succeeds
AS RESTRICTIVE FOR INSERT INSERT: 1 fails
FOR INSERT TO another_role INSERT: 1 fails
FOR INSERT TO this_role INSERT: 1 succeeds

Three of the four read the wrong way. pg_policy.polcmd stores FOR ALL as *, so a table whose only policy is FOR ALL reports zero INSERT policies while inserting fine — and that false negative is the dangerous direction, because predicting a refusal was the whole point. polpermissive and polroles account for the other two.

What survives is the negative, and it survives cleanly: if no permissive policy can apply to a command — counting FOR ALL — that command is denied for every non-owner role, whatever the expressions say. Role-independent and monotone, so it sits on the default-on side of Decision 3 exactly as you argued, and it needs polcmd + polpermissive and still no qual / with_check.

So I'd shape it as booleans rather than counts:

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

false = provably refused; true = undetermined here. Next to "insert": true in privileges, that false is the readable contradiction you're after, and unlike a count it cannot overstate.

Your note about two permissive policies OR-ing together is real, but I think it belongs to a different surface: that's a policy being wider than its author intended, which is the author's problem to see, not the agent's. Taking the boolean form — it is filed as kozou-dev/kozou#259, with the measurements above and the four cases as required coverage. Thanks for reading closely enough to find this.

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.

Collapse
 
takashimatsuyama profile image
Takashi Matsuyama

Thanks, Mads — This is the edge the post admits to and doesn't solve — "the disclosure surface of the schema as metadata grows", and if the audience is wider than the database role it needs designing separately. So, agreed on the axis.

Where I'd push back is on per-role disclosure levels as configuration. The reason RLS travels as booleans is that a copy of the authorization model in a second place goes stale and then lies with confidence. A per-role table of disclosure classes is that same second copy: the GRANTs move, the table doesn't, and the catalog is now wrong in the direction that matters.

What keeps your three levels without the copy, I think, is deriving the class from the privileges already evaluated — one switch rather than a table. For an object the role cannot SELECT: emit the name and the capability annotation only, and no columns, comments, or @ai / @policy notes. Same intent — known-but-denied stays known, its contents don't travel — with a single source of truth and nothing to keep in sync.

"Fully hidden" exists today but only at schema granularity: introspection.schemas is the list, and an object in an unlisted schema never enters the catalog. Per-object hiding does not exist. And one constraint worth naming: privilege annotation is bound to a single role — on the OAuth path it refuses to start unless the allowlist holds exactly one role, because an annotation naming one role while another acts is a lie. Per-role anything inherits that limit until per-caller annotation exists.

The canary test is the part I'd take unchanged. Seeding marked strings into restricted comments and failing CI when one surfaces outside its disclosure class tests the property rather than the code path — and instruction-like text in a comment is a real hazard for a surface whose whole job is handing comments to an agent.

Collapse
 
takashimatsuyama profile image
Takashi Matsuyama • Edited

@mads_hansen_27b33ebfee4c9
Follow-up: the canary idea is filed as kozou-dev/kozou#260, with credit to you — but I scoped it differently than you proposed, so it's only fair to say where and why.

Your version fails CI when a canary appears outside its allowed disclosure class. Those classes don't exist yet, and on the AI side the current design is the opposite: describe_table annotates rather than hides, so a comment on a denied table is emitted deliberately. A canary there would be asserting against the design rather than guarding it. It becomes the right test the moment a minimal-disclosure mode exists — the harness is written so those tests can reuse it.

What the technique fits today is a claim the README makes without hedging: policy expressions never leave the database. Nothing currently fails if that stops being true; it's held by one hand-written query and a comment above it. And it's about to be edited — the other issue from this thread rewrites exactly that query, and the convenient way to write the new version is pg_policies, a view that computes pg_get_expr for qual and with_check. So the guard is worth having before the change, not after.

One thing your framing made me notice: a canary sees emission, not fetching. The README says reads, which is the stronger claim, and a value fetched and then dropped is invisible to any payload scan. So #260 carries two assertions rather than one — the canary for what comes out, and a query-shape check for what goes in. Without the second, passing the first would have let me believe a stronger claim than the tests support. That distinction came out of taking your suggestion seriously, so thanks.