← Back to blog
Oct 29, 2026·8 min read

How to Give an AI Agent Limited Access to Your Database

How to Give an AI Agent Limited Access to Your Database

The short answer: give every agent its own database role, grant it read and write but not destructive permissions, scope its rows with row level security, and log what it does. Limits enforced by the database hold no matter what the agent is asked to do.

Most agents today get a database connection string and nothing else. That string is the entire permission model. Whatever the application can do, the agent can do, including deleting every row in a table because a prompt said something unexpected.

That was tolerable while agents only drafted text. It stopped being tolerable the moment agents started acting without a person watching each step.

Why this became urgent

In October 2026, Microsoft extended Project Perception with Guardian Agents, tooling that watches other agents at work, scores how risky each action looks in real time, and lets an administrator cut permissions, force a human approval step, or stop a run mid task. The framing Microsoft used is the part worth noting: an agent inside a company should be treated as an insider threat, because frontier models cannot be trusted with standing permissions.

In the same week, Reuters reported on research by NorForge into Cairn, an open source agent platform. One operator had pointed autonomous agents at roughly 1,200 websites. Over 100 companies were compromised. The cost worked out at about $5 per task, with roughly a 10% success rate, so somewhere near $50 per successful intrusion.

Neither story is about a model behaving badly on its own. Both are about what an agent can reach once it is running.

The permission model that actually works

Think in three tiers rather than two. Read only versus full access is too blunt, because most useful agent work involves writing something.

Read. Queries that return data and change nothing.

Write. Inserts and updates that create or modify records.

Destructive. Deletes, schema changes, bulk operations, anything that cannot be undone by writing another row.

That split is not theoretical. The Butterbase connector published on Scalekit exposes 210 tools, and they divide into 92 read, 96 write and 22 destructive. The shape is typical: reads and writes are roughly balanced, and the genuinely dangerous operations are a small minority. Which means blocking the destructive tier costs an agent about a tenth of its capability and removes almost all of the downside.

Most agents need read and write. Very few need destructive. Treating those as one category is where the risk comes from.

Four layers, in order of how much they help

1. Give every agent its own credential

Not the application's credential, and not one shared credential for all agents. One per agent, or per agent role.

This matters for a reason that has nothing to do with blocking anything: when something goes wrong, the audit log needs to say which caller did it. A shared credential turns every incident into guesswork.

CREATE ROLE support_agent LOGIN PASSWORD 'rotate-this';
GRANT CONNECT ON DATABASE app TO support_agent;
GRANT USAGE ON SCHEMA public TO support_agent;

Note what has not been granted yet. Nothing. That is the right starting point.

2. Grant by tier, not by table

-- read tier
GRANT SELECT ON tickets, customers TO support_agent;

-- write tier
GRANT INSERT, UPDATE ON tickets TO support_agent;

-- destructive tier: deliberately absent
-- no DELETE, no TRUNCATE, no ALTER

An agent that cannot execute DELETE cannot be argued into deleting anything. No amount of prompt injection changes what a database role is permitted to do.

For the operations that genuinely need to be destructive, route them through a function that the agent may call but cannot modify, so the destructive action has a fixed shape:

CREATE FUNCTION close_ticket(ticket_id bigint)
RETURNS void
LANGUAGE sql
SECURITY DEFINER
AS $
  UPDATE tickets SET status = 'closed', closed_at = now()
  WHERE id = ticket_id;
$;

REVOKE ALL ON FUNCTION close_ticket(bigint) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION close_ticket(bigint) TO support_agent;

The agent closes tickets. It cannot close accounts, drop tables or update arbitrary columns, because the only destructive thing it can reach is one function with one job.

Be careful with SECURITY DEFINER. It runs with the privileges of the function owner, which is the point, and also the risk. Set an explicit search_path on any such function and keep its body small enough to read in one go.

3. Make the agent inherit a user's view, not the whole table

Table level grants answer "which tables". They do not answer "which rows". For anything multi tenant, that second question is the one that matters, and row level security is how Postgres answers it.

ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;

CREATE POLICY agent_sees_one_tenant ON tickets
  FOR SELECT
  TO support_agent
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

The agent now sees exactly the rows its current context allows, and the restriction lives in the database rather than in the code that calls it. An agent that constructs its own SQL still cannot read another tenant's rows, because the policy applies to every query regardless of who wrote it.

The companion guide, What Is Row Level Security?, covers the mechanics properly, including the mistake almost everyone makes with WITH CHECK.

4. Log what the agent did, separately from what the app did

An agent that acts on its own needs a record that a person can read afterwards. Not application logs. A table.

CREATE TABLE agent_actions (
  id          bigserial PRIMARY KEY,
  agent       text        NOT NULL,
  tier        text        NOT NULL CHECK (tier IN ('read','write','destructive')),
  action      text        NOT NULL,
  target      text,
  occurred_at timestamptz NOT NULL DEFAULT now()
);

Recording the tier alongside the action is what makes the log useful later. "Show me every destructive action by any agent last week" is a question somebody will eventually ask, usually in a hurry.

Three things that look like controls and are not

Telling the agent what it may not do. Instructions in a system prompt are a request, not a boundary. The whole category of prompt injection exists because that request can be overridden by text the agent encounters while working.

Read only as a complete answer. It blocks the destructive tier and the write tier together, which usually makes the agent useless, so somebody widens it a week later and widens it all the way. Three tiers survive contact with reality in a way that two do not.

Approving the first action. A human approving step one of a twenty step run has approved the run, not the step. If approval is the control, it belongs on the destructive tier specifically, which is small enough that asking is reasonable.

Checking that it works

The test is adversarial and takes two minutes. Connect as the agent's role directly, outside the application, and attempt the things it should not be able to do:

SET ROLE support_agent;

DELETE FROM tickets WHERE id = 1;          -- expect: permission denied
SELECT * FROM customers;                    -- expect: only the current tenant
UPDATE tickets SET tenant_id = 'other';     -- expect: blocked

Every one of those should fail, and fail at the database rather than in application code. If any of them succeeds, the control was in the layer above and the agent has already found its way around that layer at least once in testing.

For the wider production picture, including why scoped API layers and MCP permissions alone fall short, see Giving an AI Agent Access to Your Production Database Without Losing Control.

Frequently asked questions

Do not grant DELETE to the role the agent connects as. Permissions enforced by the database cannot be talked around by a prompt, which is not true of instructions, middleware checks or approval flows.

Yes, and preferably one per agent role rather than one shared across all of them. The main benefit is traceability: when something unexpected happens, the log needs to name a caller.

Read returns data and changes nothing. Write creates or modifies records and can be corrected by writing again. Destructive covers deletes, schema changes and bulk operations that cannot be undone. Most agent work needs the first two. Very little needs the third.

Only if the credential it is using is permitted to. Prompt injection changes what an agent tries to do, never what it is allowed to do. That distinction is the entire argument for enforcing limits at the database rather than in the prompt.

Row level security. Enable it on the table, write a policy that compares a tenant or owner column against the current session context, and set that context when the agent connects. The restriction then applies to every query the agent issues, including ones it writes itself.

It is safe and usually not enough, because most useful agent tasks involve writing something. The common failure is granting read only, discovering the agent cannot do its job, and then granting everything. Separating write from destructive avoids that jump.


Butterbase enforces all of this at the backend rather than in application code: per user row level security, scoped credentials, and an MCP server that exposes tools split by read, write and destructive, so an agent connected to it has limits that hold regardless of what it is asked to do. Full details are in the documentation.