Development

Giving an Agent a Read Tool: Column Allowlists and Row Filters

Antonio Evans8 min read

A flat editorial scene of one table seen through a vertical column pane and a horizontal tenant band, with a query arrow crossing both before reaching the data and a small ticked test checklist below.
Key points

Key points

  1. 1A read-only database role controls which tables and columns an agent can touch, but it says nothing about which rows it can see.
  2. 2A policy table maps each agent principal to an explicit column allowlist and a tenant predicate that the tool enforces before running any query.
  3. 3Inject the row filter on every table reference, including joins and subqueries, by rewriting the parsed syntax tree rather than trusting the prompt.
  4. 4Test with a second tenant's data and a fixed list of attack queries, asserting zero rows and no error message that reveals whether other rows exist.

Why the read-only role stops short

Granting SELECT is the first and easiest control. You create a login, give it CONNECT, USAGE on the schema, and SELECT on the specific tables you want the agent to reach. Simon Willison documents exactly this: after granting schema usage, every select * from table returned permission denied until he listed the tables explicitly, and tables outside the list stayed blocked (read-only user walkthrough). The PostgreSQL privilege documentation confirms the model: privileges attach to objects (tables, columns, schemas), and REVOKE removes them.

That protects you from the agent reading the billing table or the salary column. It does nothing when the orders table holds rows for five hundred customers and your agent is supposed to answer questions for customer 41. The moment the connection succeeds, SELECT * FROM orders returns every row in the table. A model that was instructed in its system prompt to filter on tenant_id = 41 can also be talked out of it, can forget, or can write a join that pulls in the other side.

Column-level grants also get awkward at scale. PostgreSQL supports GRANT SELECT (column, ...) ON table TO role, but as soon as the agent needs SELECT * or you add tables, you end up maintaining a grants matrix that drifts from what the tool actually intends to allow. The step after the read-only role is to move the scope decision into a policy object your tool reads and enforces on every call.

Design the schema: principals, allowlists, row predicates

You need three things: an identity for each agent, a list of what it may read, and a predicate that limits rows. Keep them in tables your application owns, separate from the data warehouse schema.

CREATE TABLE agent_principal (
    principal    text PRIMARY KEY,      -- e.g. 'support-agent-acme'
    tenant_id    text NOT NULL,
    db_role      text NOT NULL,         -- the Postgres role the tool connects as
    enabled      boolean NOT NULL DEFAULT true
);

CREATE TABLE column_allowlist (
    principal    text NOT NULL REFERENCES agent_principal(principal),
    table_name   text NOT NULL,
    column_name  text NOT NULL,
    PRIMARY KEY (principal, table_name, column_name)
);

CREATE TABLE row_policy (
    principal    text NOT NULL REFERENCES agent_principal(principal),
    table_name   text NOT NULL,
    tenant_column text NOT NULL,        -- e.g. 'tenant_id'
    PRIMARY KEY (principal, table_name)
);

A few decisions matter here. Use a per-agent principal rather than one shared service account, because the whole model rests on knowing which policy to apply at query time. Keep the tenant value as data, not as part of the role name, so adding a tenant is an INSERT rather than a CREATE ROLE and GRANT. The measurements on role-per-tenant versus a session variable show the first SET ROLE in a session cost roughly 19 ms with 1,000 roles and about 211 ms with 10,000, so a role per tenant is a poor fit for a system that adds tenants often.

The policy table becomes the single source of truth for the tool, the audit log and the test suite. When someone asks "what can the support agent see", the answer is a two-table join, not a reading of the code.

Enforce the scope where the model cannot reach it

The prompt is not a security boundary. Anything you write in the system prompt ("only query tenant_id = 41") is a suggestion the model can override, ignore or reformat. The filter has to be applied by code between the model and the database.

The pattern that works: the model submits SQL, the tool parses it, checks it against the policy, rewrites it, and only then sends it to the database. The General SQL Parser walkthrough describes this exactly: parse into an abstract syntax tree, reject anything that is not a single SELECT, remove columns outside the allowlist, add the tenant predicate as a node with correct parenthesization, regenerate the SQL, and re-parse before execution. It also names the trap: naive string appending can produce status = 'OPEN' OR status = 'PENDING' AND tenant_id = ?, which SQL evaluates as OPEN OR (PENDING AND tenant), leaking the other tenant's open rows.

Here is the shape of the check in Python, using a parser that returns an AST:

ALLOWED_STATEMENTS = {"select"}

def enforce(sql: str, principal: str, conn) -> str:
    tree = parse(sql)                          # one statement only
    if len(tree.statements) != 1:
        raise PolicyError("one statement")
    stmt = tree.statements[0]
    if stmt.kind not in ALLOWED_STATEMENTS:
        raise PolicyError("select only")
    if stmt.has_forbidden_nodes():             # DDL, functions, INTO
        raise PolicyError("forbidden construct")

    columns = load_allowlist(conn, principal)  # {(table, column), ...}
    for col in stmt.output_columns():
        if (col.table, col.name) not in columns:
            raise PolicyError("column not allowed")
    for f in stmt.from_items():                # joins, subqueries included
        if f.table not in {t for t, _ in columns}:
            raise PolicyError("table not allowed")

    for f in stmt.from_items():
        pred = load_row_policy(conn, principal, f.table)
        if pred:
            stmt.where = and_expr(stmt.where, eq(lit(pred.tenant_column), bind(pred.value)))

    return tree.to_sql()

Two details are worth calling out. First, the filter is applied to every FROM item, not just the outer query. A subquery that selects from orders without the predicate is the same leak as a top-level one. Second, the tenant value goes in as a bind parameter, not inline text, so the value is never part of the string the model produced.

If your database supports row-level security, you can push the predicate into the database instead of rewriting. PostgreSQL's row security policies evaluate the policy expression for every row before the user's own conditions, and enable them with ALTER TABLE ... ENABLE ROW LEVEL SECURITY. The catch, as the stack overflow question on RLS with session variables shows, is that the table owner and BYPASSRLS roles skip policies, so the agent must connect as a role that is subject to them. Session variables set with set_config work but, per the role comparison, SET leaks through transaction-mode poolers while SET LOCAL does not, so set the tenant inside the transaction and nowhere else. The pgedge writeup on session variables shows the set_config('myapp.tenant', '41', true) call and the current_setting('myapp.tenant') read a policy can use.

The choice between rewriting in the tool and RLS in the database comes down to where the allowlist lives.

CriterionTool-side rewriteDatabase row-level security
Where the policy livesYour policy tablesCREATE POLICY in the database
Column allowlistEnforced during rewriteGrant column privileges separately
Survives if the tool is bypassedNo, only applies to tool trafficYes, for any connection as that role
Handles joins and subqueriesYou walk the ASTThe database applies it everywhere
Operates cleanly behind PgBouncerYesOnly with SET LOCAL per transaction
AuditabilityYou log every decisionCheck pg_policies and logs
EffortParser and test suitePolicy SQL and role discipline

Most teams that start here use both: the database policy is the backstop, and the tool rewrite lets the agent produce readable errors and keeps the column allowlist in one place.

Make the policy fast: indexes that match the filter

Once the tenant predicate is injected, every query an agent runs is a WHERE tenant_id = ? query on a large table. That changes the indexing advice you have probably read. A composite index on (tenant_id, created_at DESC) gets the planner straight to the tenant's rows and returns them in order, which is exactly the shape most agent queries take ("the last twenty orders for this customer").

The reasoning is standard. A PostgreSQL composite index sorts by its first column, then the second within equal values, and only serves queries that filter on a prefix of its columns; a query on product alone ignores an index on (region, product). The worked example from a SaaS query takes an issue query from 16.6 ms to 0.04 ms with (issue_id, user_id, created_at DESC) because all three conditions moved into Index Cond and the sort disappeared. The tenant_id column is the issue_id of your agent queries.

You can confirm the fix with EXPLAIN ANALYZE before and after, and check whether the plan shows a sequential scan. The PostgreSQL documentation on index-only scans notes that the scan avoids the heap only when the index contains all the columns the query needs and the visibility map is current, so a covering index on the tenant plus the columns the agent may read is worth adding when the queries are frequent and small. The MySQL manual's page on avoiding full table scans lists low-cardinality keys as a common cause of a table scan; a tenant column with a few large customers has exactly that shape, so pair it with a timestamp rather than using it alone. The sqlc verify warning on sequence scans is a reminder that a generated plan can quietly turn into a full scan, and a check in CI is the cheapest way to catch it.

There is a tension to name here. If you use RLS, the policy expression is evaluated per row and can change plans, and the role comparison measurements show the query cost with a variable versus a role was close (about 3.4 ms to 3.6 ms for the largest tenant's count), so the concern is less about the predicate and more about how you set it. Set the tenant with SET LOCAL inside the transaction and the cost is in the noise.

Test that the agent cannot see other tenants' rows

A policy is only real when a test fails if it breaks. The test suite has three parts: another tenant's data, a fixed list of attack queries, and assertions on both rows and errors.

-- fixture: two tenants, distinct values, same table
INSERT INTO orders (id, tenant_id, total, note)
VALUES (1, 'acme', 100, 'acme order'),
       (2, 'globex', 200, 'globex order');

CREATE ROLE agent_acme LOGIN;
GRANT SELECT ON orders TO agent_acme;

The attack list is short and stable:

QueryExpected resultWhy it is in the list
SELECT * FROM ordersOnly acme rows, or a column errorTests the filter and the allowlist
SELECT note FROM ordersError, if note is not allowlistedTests column enforcement
SELECT * FROM orders o JOIN orders o2 ON o2.id = o.idOnly acme rows from both aliasesTests joins
SELECT (SELECT total FROM orders WHERE id = 2)Error or null, never 200Tests subqueries
SELECT * FROM orders WHERE tenant_id = 'globex'Zero rowsTests that the injected filter wins
SELECT 1; DELETE FROM ordersError, one statement onlyTests the parser gate

The last two columns are the insight search results usually skip: the error must not distinguish. If querying globex returns an empty result but querying a nonexistent tenant returns an error, the agent learns that globex exists. Both should look the same: a zero-row result or a generic policy error with a request id, never a message that reveals a row existed. Run the same query for both tenants and assert the byte-for-byte error text matches.

A second test worth having runs the whole flow under the pooler. The PgBouncer measurements show a leaked SET ROLE t1 from one client gave the next client 60,000 invoices, while SET LOCAL ROLE inside a transaction returned zero rows. Start the tool, end a session mid-query, then start a second principal and assert it sees its own tenant and not the first. That test catches the class of bug that hides in staging for months.

Operate the policy like code

The policy tables will drift unless you treat them like source code. Export them to a migration file, review changes, and run the same attack suite against the staged version before it reaches the agent. A useful invariant: no principal should be able to read a table that is not in column_allowlist, and no query should reach the database without passing through the tool. If a second path exists (a notebook, a debug endpoint, a backup job), the policy has a hole, and the test suite should fail on the hole once someone finds it.

When a new agent or tenant is added, the loop is: insert the principal, insert the allowlist rows, insert the row policy, run the attack suite with the second tenant in place, and confirm the error text matches. Then watch the plans on the biggest tenant's queries with EXPLAIN ANALYZE, because an agent's default "give me the latest twenty" query is exactly the kind that a missing composite index turns into a full scan.

If your agents run across several model providers and the read tool is one of many, centralizing the model calls behind one OpenAI-compatible endpoint with Quanta Inference leaves the policy layer as the only thing between the model and the rows, with a single API key to rotate and prepaid billing in dollars. The policy work itself is small enough to lift from this article, and the test suite is the part that pays for itself in the first incident.

FAQ

FAQ

Does a read-only role already prevent an agent from seeing other tenants' rows?

No. A read-only role controls which tables and columns a connection can touch, and the PostgreSQL privilege documentation describes privileges as attaching to objects rather than rows. Once the connection is allowed on a table, every row in it is reachable, so the tenant filter has to come from a policy the tool or the database enforces.

Should I use a role per tenant or one role with a tenant variable?

Use one role and pass the tenant as a session value inside the transaction, unless you have very few tenants and want the login itself to be the boundary. A role per tenant applies a lasting additional cost (around 19 ms with 1,000 roles and about 211 ms with 10,000 on the first switch in a session, per the measured comparison) and every new tenant forces every pooled session to pay it again.

Can I just tell the model to filter by tenant in the prompt?

No, because the prompt is not a control plane. The model can ignore, forget or be argued out of an instruction, and the SQL it produces is what reaches the database. Parse the query, check it against the allowlist, and inject the predicate in code; the AST rewrite walkthrough shows the steps and the parenthesis trap that string appending creates.

How do I make sure the tenant filter applies to joins and subqueries?

Walk every FROM item in the parsed statement, including aliases, joins and derived tables, and add the predicate to each. Then regenerate the SQL and re-parse it before running, so a malformed rewrite fails closed rather than opening up. Test with a self-join and a scalar subquery, and assert both return only the caller's rows.

How do I stop a timing side channel between an empty result and a policy error?

Return the same generic outcome for both. If one tenant returns zero rows and another returns an error saying "row not found", the agent has learned which tenants exist. Run each attack query for a seeded tenant and a nonexistent one, and assert the response body and status code match, keeping the detail in a server-side log keyed by request id.

Sources

Sources

  1. Granting a PostgreSQL user read-only access to some tables
  2. Documentation: 18: 5.8. Privileges
  3. A role per tenant vs a session variable for PostgreSQL RLS
  4. Modify and Regenerate SQL AST
  5. Documentation: 18: 5.9. Row Security Policies
  6. postgresql row level security with session variable
  7. It Depends: Using Session Variables in Postgres
  8. PostgreSQL Multi-Column Indexes Column Orders
  9. From 17ms to 0.04ms: How to Design the Right SQL Index
  10. 18: 11.9. Index-Only Scans and Covering Indexes
  11. 10.2.1.23 Avoiding Full Table Scans
  12. Prevent full table scans in production
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles