← Back to posts

14 min read

Your AI Agent's Permissions Belong in Postgres, Not the Prompt

Filed under API Auth

Somewhere in your company, a developer has a database connection string sitting in an .env file. They wrapped it in a Python function and registered it as a tool with an agent framework. That string authenticates as a service account with read access to the full schema, which means every control you write in the prompt, the planner, or the middleware sits downstream of one bad fact.

The fix is not a better system prompt. It is moving identity and authorization into the data layer, the one choke point every access path has to cross. This post is the how: scoped roles, row policies, identity propagation through a connection pooler, an audit schema, and the failure modes I would hunt first.

The app layer is where agent permissions go to die

App layer vs. data layer: who actually enforces

Composio's governance guide calls the pattern above Shadow AI: long-lived API keys in environment variables, wrapped in Python, handed to LangChain or LlamaIndex. It produces three distinct vulnerabilities:

  • Identity flattening. The agent operates with a single "System Admin" key instead of the end user's specific permissions. Every human it acts for collapses into one overprivileged principal.
  • Intent blindness. Your infrastructure sees an HTTP call, not the reasoning that produced it.
  • Governance vacuum. There is no central place to revoke anything. Composio's point is blunt: in these setups, killing access means a code deployment, not a policy toggle.

Standard API gateways do not close this gap. Composio names Kong and MuleSoft: they manage requests, not intent. A gateway cannot act on "the agent is trying to delete a user because it hallucinated a policy violation." I agree with the diagnosis, and I would push it further. Even a perfect gateway only covers the traffic you remembered to route through it. The second agent someone adds next quarter, the analytics connector, the migration script run at 2 a.m., none of those go through your middleware. They all go through the database.

Atlan frames the same problem as two layers of governance: agent-layer controls (identity, permissions, action limits, output policies, audit trails) and data-layer controls, and notes that most enterprise frameworks deploy only the first. That matches what I see in practice. Teams build elaborate guardrails around the model and leave the underlying role with SELECT on everything. It is the same gap that separates a demo from a production system, which we have written about in Your Agent Demo Is Lying to You: A Map of the Production Gap.

One agent, one role, no shared accounts

Atlan's requirement is the right baseline: every agent gets a non-human identity with scoped credentials, explicitly not shared service accounts that make attribution impossible. The Digital Thought Disruption architecture adds the second half: bind that workload identity to an owner and record the human principal on whose behalf the agent acts, so permissions stay revocable and actions stay attributable.

In Postgres, that is a role per agent with column-level grants:

sql
CREATE ROLE agent_support_bot LOGIN
  PASSWORD 'issued-by-your-secrets-broker';

GRANT CONNECT ON DATABASE appdb TO agent_support_bot;
GRANT USAGE ON SCHEMA support TO agent_support_bot;
GRANT SELECT ON support.tickets TO agent_support_bot;
GRANT UPDATE (status, internal_note) ON support.tickets TO agent_support_bot;

-- claw back what PUBLIC hands out by default
REVOKE ALL ON SCHEMA support FROM PUBLIC;

Two things to notice. The agent can update exactly two columns, not the row. And nothing here comes from the model. The Digital Thought Disruption reference architecture is explicit on this: the model proposes structured tool calls, it does not execute, and execution uses scoped credentials with minimum permissions and a short validity window. In practice that means a secrets broker issuing credentials that expire in minutes or hours, not a password rotated when someone remembers. Atlan's authentication patterns line up the same way: OAuth 2.0 client credentials for third-party APIs, service accounts with IAM roles for cloud-native deployments, and API keys only when they are narrowly scoped and rotated often. NIST SP 800-207's zero trust principle sits underneath all of it: every request from every principal, authenticated explicitly.

Row policies that fail closed

Role-level grants scope the agent to a table. Row-level security scopes it to the rows it should see for this request, on behalf of this user:

sql
ALTER TABLE support.tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE support.tickets FORCE ROW LEVEL SECURITY;

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'
  );

CREATE POLICY tickets_agent_update ON support.tickets
  FOR UPDATE
  TO agent_support_bot
  USING (
    tenant_id = current_setting('app.tenant_id', true)::uuid
    AND status IN ('open', 'pending')
  )
  WITH CHECK (
    tenant_id = current_setting('app.tenant_id', true)::uuid
  );

The detail that matters is current_setting('app.tenant_id', true). The second argument makes Postgres return NULL instead of throwing when the setting is absent, and a NULL comparison in a policy filters the row. If the calling code forgets to set tenant context, the agent sees nothing. That is the fail-closed behavior the governance architectures ask for: when policy is undefined or unavailable, the answer is no. FORCE ROW LEVEL SECURITY closes the other hole, the table-owner exemption, in case anything ever connects as the owning role.

Note where the tenant value must come from: your execution layer, derived from the authenticated session. If the model can influence app.tenant_id through a tool argument, you have built a prompt-injection-to-data-theft pipeline with excellent row-level security.

Getting identity through the connection pool

How identity reaches the database

This is the part that breaks in production, and nobody writes about it. You are almost certainly running PgBouncer or a similar pooler in transaction mode, and a session-level SET does not survive transaction boundaries. Worse, it can survive in the wrong direction.

The pattern that works is transaction-scoped settings:

sql
BEGIN;
SET LOCAL app.tenant_id = '8b7c2f10-...';
SET LOCAL app.end_user  = 'user_4821';
SET LOCAL app.agent_id  = 'agent_support_bot';
SET LOCAL app.trace_id  = '3f9ad221-...';
-- the agent's query runs here, policies apply
COMMIT;

Now the failure mode. If any code path uses plain session-scope SET on a pooled connection and the pooler returns that connection to the pool, the next borrower inherits the previous tenant's context. Your policy is correct. Your rows still leak across tenants. I have seen this exact bug survive code review because every individual piece looked right.

Mitigations, in order of preference. Ban session-scope SET in the shared data-access library, and make SET LOCAL the only API. Configure the pooler to reset state on release; in PgBouncer's transaction mode that means server_reset_query_always = 1, because the default reset query only fires in session mode. For your most sensitive schemas, consider a dedicated pool per tenant so a bleed is architecturally impossible.

Audit the action, not the conversation

Framework guidance on agent audit trails tends to stop at "log invocations." Atlan's version is more useful: invocation logs need timestamp, calling principal, agent class, and outcome for every call. Digital Thought Disruption adds the correlation requirement: request, plan, policy, approval, tool call, result, and verification should share one trace ID. Here is a schema that does both:

sql
CREATE TABLE agent_action_audit (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  trace_id      uuid        NOT NULL,
  occurred_at   timestamptz NOT NULL DEFAULT now(),
  agent_id      text        NOT NULL,
  on_behalf_of  text,
  action        text        NOT NULL,
  target_table  text        NOT NULL,
  rows_affected integer,
  decision      text        NOT NULL,  -- allow, deny, pending_approval
  policy_name   text,
  detail        jsonb
);

CREATE INDEX ON agent_action_audit (trace_id);
CREATE INDEX ON agent_action_audit (agent_id, occurred_at);

And the trigger that populates it on tables agents can write:

sql
CREATE OR REPLACE FUNCTION audit_agent_write() RETURNS trigger AS $$
BEGIN
  INSERT INTO agent_action_audit
    (trace_id, agent_id, on_behalf_of, action, target_table, rows_affected, decision)
  VALUES (
    nullif(current_setting('app.trace_id', true), '')::uuid,
    current_setting('app.agent_id', true),
    current_setting('app.end_user', true),
    TG_OP,
    TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
    1,
    'allow'
  );
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tickets_agent_audit
  AFTER INSERT OR UPDATE OR DELETE ON support.tickets
  FOR EACH ROW EXECUTE FUNCTION audit_agent_write();

Why log at the database when the agent framework already has traces? Because the two record different things. Application logs capture what the agent intended. The trigger captures what the database actually did. When they disagree, believe the trigger. Digital Thought Disruption makes the same point as a design requirement: a separate verification step reads the target system back and compares intended state with actual state. If you want to know how this holds up when an auditor starts asking who authorized a specific agent action, we covered the evidence side in Your AI Agents Are One Audit Question Away From a Finding.

"We already enforce this in the API"

The skeptical pushback I hear most: authorization already lives in the API layer, so duplicating it in the database is redundant work and query overhead.

Duplication is the point. Authorization that exists in exactly one layer is one refactor away from being bypassed, and gateways see requests, not rows. On performance, measure before you worry: a policy predicate on an indexed tenant column folds into the query plan, and EXPLAIN will tell you in seconds whether your case is the exception.

Now the honest limits, because there are two. First, RLS does nothing about output channels. Atlan flags this as a distinct risk: an agent that retrieves PII through a fully authorized read can still route it to an API response, a log file, or another agent. That is an output-policy problem, and the data layer cannot solve it alone. Second, volume. Agents make thousands of calls per hour with no judgment about appropriateness, so add cheap guardrails at the role level:

sql
ALTER ROLE agent_support_bot CONNECTION LIMIT 20;
ALTER ROLE agent_support_bot SET statement_timeout = '5s';

It is worth naming the stakes properly. The OWASP Top 10 for LLM Applications lists Excessive Agency and Sensitive Information Disclosure among the highest risks for production AI systems, and Atlan characterizes both as access control failures. If you want to test those categories against your own deployment, we maintain a pentester's checklist for each OWASP LLM category.

A kill switch that does not need a deploy

Remember the governance vacuum: revocation via code deployment. With identity in the database, the kill switch is one statement:

sql
-- full stop, effective immediately
ALTER ROLE agent_support_bot NOLOGIN;

-- narrower: stop writes, keep reads running
DROP POLICY tickets_agent_update ON support.tickets;

Test both before you need them. A recovery procedure that has never been executed is a hypothesis.

Ship list

If you do nothing else this quarter:

  1. Inventory every agent in a single registry with owner, purpose, platform, and access scope, as Microsoft's Cloud Adoption Framework recommends. If you cannot enumerate your agents, the rest is decoration.
  2. One database role per agent. No shared service accounts. Short-lived credentials from a broker.
  3. Enable and FORCE row-level security on every table an agent can touch. Default posture: zero rows.
  4. Propagate principal context with `SET LOCAL` only, derive the values from the authenticated session, and configure the pooler to reset state on release.
  5. Deploy the audit table and triggers on agent-writable tables, correlated by trace_id end to end.
  6. Set connection limits and statement timeouts per agent role.
  7. Prove isolation, do not assume it. Mint a session as tenant A, capture the agent's calls, replay them as tenant B, and expect zero rows. The full method is in Prove Tenant Isolation Without a Pentest: Mint as Tenant A, Replay as Tenant B.

Config review gets you most of the way, but an auditor or a buyer will eventually want evidence that these controls hold against a motivated caller, not just a clean schema dump. For evidence between point-in-time audits, Axeploit pentesting for audits is the page to send a buyer or auditor. It pairs naturally with the replay test above.

If you want this checked automatically

If you want the auth and object-level checks in this article run against a live app, start with Axeploit's API security checker.

Key takeaways

  • App-layer guardrails only cover the paths you remembered to route through them. The database is the one choke point every agent, connector, and script must cross.
  • Identity flattening is the default failure mode: one agent role per agent, no shared service accounts, short-lived credentials.
  • Postgres RLS with current_setting()-driven policies fails closed when context is missing, and SET LOCAL is the only safe way to carry that context through a transaction pooler.
  • Audit what the database actually did with triggers, and correlate it to what the agent intended with a shared trace ID.
  • Test your kill switch (NOLOGIN, DROP POLICY) before the incident, not during it.
Get started

Integrate Axeploit into your workflow today