Engineering research / agents

Letting an Agent Query an Existing Database: Read-Only Roles, Row-Level Security, Settings an Agent Can Override and a Curated View Layer

A capability boundary for agent-generated SQL against a database that already serves an application: privileges instead of prompts, row-level security that the agent cannot switch, statement limits it cannot SET away, curated views, and a runnable SQLite gate.

By Sandeep Belgavi · Published 2026-10-25 · Prepared 2026-10-21

Agent-era architecture · Practical technical guide · 21 October 2026

A capability boundary for agent-generated SQL against a database that already serves an application: privileges instead of prompts, row-level security that the agent cannot switch, statement limits it cannot SET away, curated views, and a runnable SQLite gate.

The request

An analytics or support agent is far more useful when it can answer questions from the database the application already uses: how many refunds did this customer get this quarter, which orders are stuck in a state. The quick version gives the agent a connection string and a prompt that says "only run SELECT statements". That prompt is not a boundary. The model may comply on every test and still emit an UPDATE when a user asks it to fix something, and prompt injection in retrieved text can ask for exactly that.

The boundary has to be the one the database already enforces for people: a role with privileges, policies on rows, limits on resources, and a schema designed to be read. The question this article answers is which of those controls hold against SQL that the client itself writes, and which ones only look like they do.

What stays deterministic: privileges, not defaults

PostgreSQL distinguishes between privileges, which a role cannot grant itself, and session settings, which the session can change. That distinction decides what an agent can undo with one more statement.

ControlCan generated SQL undo it?Notes
GRANT SELECT on specific views onlyNoThe boundary that matters; nothing else is granted
Row-level security keyed on the login roleNoPolicies apply unless the role owns the table or has BYPASSRLS
Row-level security keyed on a session variableYes, with SETWhoever can run SET app.tenant_id can pick the tenant
default_transaction_read_only = onYesA default; SET TRANSACTION READ WRITE or SET overrides it
statement_timeout set for the roleYes, with SETUse it, but also enforce time outside the session
A prompt saying SELECT onlyYesNot a control

The row-level security documentation is explicit about the edges: superusers and roles with BYPASSRLS always bypass policies, table owners normally do too, and a table with RLS enabled but no policy denies everything. The predefined role pg_read_all_data grants SELECT on everything and, per the documentation, does not bypass RLS, which makes it convenient and far too broad for an agent.

Views are a capability layer, with one trap

Grant the agent a schema of views written for questions rather than the application's tables. Views hide columns the agent should never see, rename codes into words the model understands, pre-join the obvious relations and fix the grain of each answer. They are also where the trap lives. By default a view accesses its base tables with the privileges of the view owner, and the CREATE VIEW documentation states that row-level security policies of the view owner are applied unless the view has security_invoker set to true. A view owned by the application's schema owner can therefore quietly return every tenant's rows to an agent whose own role is restricted. Create agent views with security_invoker = true, or own them with a role that is itself subject to the policies. A security_invoker view also needs the agent to hold SELECT on the base table, which reopens the table to direct queries; grant it on the view's columns only, so that querying the table directly shows no more than the view.

Illustrative PostgreSQL setup; test it by connecting as the agent role, including a direct query on app.refunds.

CREATE ROLE agent_tenant_42 LOGIN NOINHERIT;
CREATE SCHEMA agent_api;
CREATE VIEW agent_api.refunds WITH (security_invoker = true) AS
  SELECT r.id, r.order_id, r.amount_cents, r.reason, r.created_at
  FROM app.refunds r;
ALTER TABLE app.refunds ENABLE ROW LEVEL SECURITY;
-- Every non-owner role now sees no rows unless a policy names it, so the
-- application role needs its own policy (or must own the table).
CREATE POLICY agent_tenant_42_rows ON app.refunds FOR SELECT
  TO agent_tenant_42 USING (tenant_id = 42);
GRANT USAGE ON SCHEMA agent_api TO agent_tenant_42;
GRANT SELECT ON agent_api.refunds TO agent_tenant_42;
GRANT USAGE ON SCHEMA app TO agent_tenant_42;
-- security_invoker needs the agent to read the base table: grant only the view's
-- columns, so a direct query on app.refunds cannot see more than the view does
GRANT SELECT (id, order_id, amount_cents, reason, created_at) ON app.refunds TO agent_tenant_42;
ALTER ROLE agent_tenant_42 SET statement_timeout = '5s';

Resource limits outside the session

A read-only query can still hurt an application database: a cross join, a scan of a billion-row table, an ORDER BY without a LIMIT. Set statement_timeout on the role, because it catches honest mistakes, and accept that a determined session can SET it away. The limits that cannot be negotiated live outside the session: a connection pool with a small size for the agent role, a query gateway that rejects anything except a single SELECT before it reaches the database, a read replica so the primary is never at risk, and a gateway-side deadline that cancels the backend if the statement runs too long.

An estimated cost gate is a reasonable addition. Running EXPLAIN (FORMAT JSON) first returns the planner's total cost, and a gateway can refuse plans above a threshold. Treat it as a heuristic: estimates come from statistics and can be wrong by orders of magnitude, so the deadline still has to exist.

A gate you can run

The runnable example in agent-decision-lab demonstrates the layering with SQLite and the Python standard library, so it runs anywhere. SQLite has no roles, but it has two hooks that act inside the engine: an authorizer callback consulted for every table and column a statement touches, and a progress handler that can abort a statement after a budget of virtual machine instructions. The gate opens the database read-only, allows reads of five named columns and a short list of functions, and aborts long statements.

Abridged from examples/python/agent_sql_gate in agent-decision-lab; the tests cover every row of the table below.

ALLOWED_COLUMNS = {("refunds_view", c) for c in ("id", "order_id", "amount_cents", "reason", "created_at")}

def strict_authorizer(action, arg1, arg2, db_name, source):
    if action in (sqlite3.SQLITE_SELECT, sqlite3.SQLITE_RECURSIVE):
        return sqlite3.SQLITE_OK
    if action == sqlite3.SQLITE_READ:
        # db_name is None for the columns of a CTE, which exist only inside this statement
        if (arg1, arg2) in ALLOWED_COLUMNS or db_name is None:
            return sqlite3.SQLITE_OK
        return sqlite3.SQLITE_DENY
    if action == sqlite3.SQLITE_FUNCTION:
        return sqlite3.SQLITE_OK if arg2.lower() in ALLOWED_FUNCTIONS else sqlite3.SQLITE_DENY
    return sqlite3.SQLITE_DENY

conn = sqlite3.connect(f"file:{replica}?mode=ro", uri=True)
conn.set_authorizer(strict_authorizer)
conn.set_progress_handler(budget.tick, 1000)

Worked example: a bypass found while building the gate

The first version of the gate ran against the application database and used a tenant view, refunds_view, whose body filters tenant 42. To let the view read its base table, the authorizer allowed any read whose fifth argument, the inner-most view responsible for the access, was refunds_view. It passed every planned test. Then one more statement went in: WITH refunds_view AS (SELECT * FROM refunds) SELECT sum(amount_cents) FROM refunds_view. SQLite reports the name of a common table expression in that same argument, so the reads of the base table arrived labelled as coming from refunds_view, the gate allowed them, and the query returned 14,400 cents: both tenants, where 4,500 belonged to tenant 42.

The fix was not a smarter callback. Column-level checks cannot tell a filtered view from an unfiltered CTE reading the same columns. The strict gate instead connects to a replica that holds only tenant 42, which is the role row-level security plays in PostgreSQL: the engine the agent talks to contains nothing it must not return, whatever shape the query takes.

StatementWeak gate, application databaseStrict gate, tenant replica
SELECT count(*), sum(amount_cents) FROM refunds_viewAllowed: 3 rows, 4,500Allowed: 3 rows, 4,500
SELECT * FROM refundsRefused by the authorizerRefused: no such table
SELECT card_last4 FROM paymentsRefused by the authorizerRefused: no such table
UPDATE refunds SET amount_cents = 0Refused by the authorizerRefused
ATTACH DATABASE ... AS otherRefused by the authorizerRefused by the authorizer
CTE named refunds_view over the base tableAllowed: 14,400, both tenantsRefused: no such table
Recursive CTE counting to 10 millionInterrupted by the budgetInterrupted by the budget

The general lesson transfers directly: a gate that reasons about query text or about names inside the query can be steered by the query. Put tenant isolation where the query cannot reach it.

Migration steps

  1. Inventory the questions the agent must answer and write one view per question family, with security_invoker and plain column names.
  2. Create login roles for the agent, one per tenant where tenants share tables, with SELECT on those views only and a pool of its own.
  3. Enable RLS on the base tables the views read, keyed on the role rather than on a settable variable.
  4. Route agent SQL through a gateway that accepts one SELECT statement, applies a deadline and logs the text, plan cost and row count.
  5. Point the agent at a replica. Measure rejected statements and timeouts per day before widening the view layer.

When an agent is unnecessary

If the questions are known in advance, build parameterised tools instead of free SQL: refunds_for_customer(customer_id, quarter) is easier to test, cheaper to run and impossible to turn into a full scan. Free SQL earns its risk only for genuinely open-ended analysis, and even then a semantic layer of views gives the model most of its accuracy.

Failure modes

  • A prompt as the only restriction, defeated by one injected instruction.
  • Tenant isolation through a session variable that the agent can SET.
  • Views that run as their owner and bypass the policies of the role that queries them.
  • statement_timeout as the only time limit, undone by SET.
  • An agent role in the application pool, so a slow agent query starves user traffic.
  • Granting pg_read_all_data for convenience, which exposes every table.
  • A table-level grant under a security_invoker view, which lets a direct query read every column the view was meant to hide.

Sources and scope

Checked against these sources on 21 October 2026. Examples and figures are illustrative, not measurements.

Grant the agent SELECT on security_invoker views only, enforce tenants with row-level security keyed on the login role, treat session settings as defaults it can change, and keep isolation where the query text cannot reach it.