Odds are good that somewhere in your company, a database connection string lives in an .env file, wrapped in a Python function, registered as a tool with an agent framework. It authenticates as a service account with read access to the full schema. Every guardrail you wrote in the prompt or the middleware sits downstream of that one fact.
You can build perfect app-layer controls and they still only cover the traffic you remembered to route through them. The second agent someone ships next quarter, the migration script run at 2 a.m. None of them touch your middleware. Every one of them touches the database. That's why agent authorization belongs in Postgres.
One agent, one role
Baseline: every agent gets its own non-human identity. Shared service accounts kill attribution, so when the support bot does something weird you can't tell which human it acted for. In Postgres this is a role per agent with column-level grants:
GRANT SELECT ON support.tickets TO agent_support_bot;
GRANT UPDATE (status, internal_note) ON support.tickets TO agent_support_bot;
REVOKE ALL ON SCHEMA support FROM PUBLIC;
The agent can update two columns, not the row. None of this comes from the model. The model proposes structured tool calls, and execution happens with scoped credentials from a secrets broker, expiring in minutes or hours rather than whenever someone remembers to rotate a password.
Row security that fails closed
Grants scope the agent to a table. Row-level security scopes it to the rows this request should see, on behalf of this user:
CREATE POLICY tickets_agent_read ON support.tickets
FOR SELECT TO agent_support_bot
USING (
tenant_id = current_setting('app.tenant_id', true)::uuid
AND classification <> 'restricted'
);
The detail that matters is the second argument to current_setting. With true, Postgres returns NULL instead of throwing when the setting is missing, and a NULL comparison filters every row. If your calling code forgets to set tenant context, the agent sees nothing. Policy undefined means no. Add FORCE ROW LEVEL SECURITY to close the table-owner exemption.
One warning: the tenant value has to come from your execution layer, derived from the authenticated session. If the model can influence app.tenant_id through a tool argument, you've built a prompt injection path straight to your data, with row-level security that technically works.
The pooler will leak your tenant context
This is the part that breaks in production. You're running PgBouncer in transaction mode, and a session-level SET does not survive transaction boundaries. Worse, it can survive in the wrong direction. If any code path uses plain SET on a pooled connection and that connection goes back to the pool, the next borrower inherits the previous tenant's context. Your policy is correct and your rows still leak across tenants. This one survives code review easily, because each line looks right on its own.
The pattern that works is transaction-scoped settings:
BEGIN;
SET LOCAL app.tenant_id = '8b7c2f10-...';
SET LOCAL app.end_user = 'user_4821';
-- query runs here, policies apply
COMMIT;
Ban session-scope SET in your shared data-access library and make SET LOCAL the only API. Then set server_reset_query_always = 1 in PgBouncer, because the default reset query only fires in session mode. For your most sensitive schemas, a dedicated pool per tenant makes a bleed architecturally impossible.
If you do four things this week:
- Create one LOGIN role per agent with column-level grants, and revoke what PUBLIC hands out by default.
- Turn on RLS with policies that read
current_setting('app.tenant_id', true)so missing context returns zero rows, thenFORCEit. - Grep your data layer for session-scope
SETon pooled connections and replace it with transaction-scopedSET LOCAL. - Audit with a trigger writing
trace_id,agent_id, andon_behalf_ofon every write. App logs record what the agent intended, the trigger records what the database did. When they disagree, believe the trigger.
Is anyone actually running a role per agent in production, or is everything still one fat service account behind an ORM? Curious what broke first for the people who made the switch.
Longer writeup with the full audit schema and trigger code: https://axeploit.com/blog/your-ai-agent-s-permissions-belong-in-postgres-not-the-prompt



