← Back to list

AI Data Agents Need Access Control, Not Better Prompts

In a regulated firm, access isn’t a setting. It’s the business — and most AI data assistants don’t understand that yet.

Dimitar Stoyanov in Towards Deep Learning · 2026-07-06 16:38 · 0 claps · 13.8 min read
#data-security #ai-agent #text-to-sql #data-governance #llm
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents

AI Data Agents Need Access Control, Not Better Prompts

In a regulated firm, access isn’t a setting. It’s the business — and most AI data assistants don’t understand that yet.

A prompt is NOT a lock — why AI data agents need real access control. A cartoon: a “prompt wizard” and a friendly AI robot politely ask a bank vault to “only show allowed data” with sticky notes and scrolls, while a heavily-armored guard labeled ACCESS POLICY stands firm in front of signs for Revenue, Cost data, Client data, and Restricted rows. A chalkboard reads: prompts are suggestions, policies are enforced.

A prompt is NOT a lock — why AI data agents need real access control. A cartoon: a “prompt wizard” and a friendly AI robot politely ask a bank vault to “only show allowed data” with sticky notes and scrolls, while a heavily-armored guard labeled ACCESS POLICY stands firm in front of signs for Revenue, Cost data, Client data, and Restricted rows. A chalkboard reads: prompts are suggestions, policies are enforced.

Three people at the same investment bank open its new AI assistant and type the same question: “Show me profitability by business unit for Q2.”

They get three different answers. Every one of them is correct.

One question, three correct answers. Frank (Group Finance Controller) may see everything and gets a FULL answer - profitability across the whole firm. Bianca (Corporate Banking, EMEA) may see revenue and cost for her unit and region and gets a SCOPED answer - profitability for her book only. Dana (Revenue Analyst, EMEA) may see revenue only, no costs or client names, and gets an INCOMPLETE answer - revenue, explicitly not profitability.

One question, three correct answers. Frank (Group Finance Controller) may see everything and gets a FULL answer - profitability across the whole firm. Bianca (Corporate Banking, EMEA) may see revenue and cost for her unit and region and gets a SCOPED answer - profitability for her book only. Dana (Revenue Analyst, EMEA) may see revenue only, no costs or client names, and gets an INCOMPLETE answer - revenue, explicitly not profitability.

In most software, three different answers to one question is a bug. In a bank, it’s the entire point. Who may see what isn’t a preference or a UX setting — it’s the operating model. Access runs by group, by database, by table, by column, and by row, layered with information barriers between desks and GDPR duties on client data; it’s the thing a decade of entitlement reviews exists to enforce. The day you put an AI in front of the warehouse, you’re betting it honors that model as faithfully as the controls you built around your people.

Most don’t. And the way they fail is quiet.

This article uses a small open-source example: an access-aware assistant over a miniature bank warehouse, with a deterministic guard and a comprehensive test suite:

This article uses a small open-source example: an access-aware assistant over a miniature bank warehouse, with a deterministic guard and a 121-case test suite. Code on GitHub:

[embed]GitHub - sparklingneuronics/access-aware-text-to-sql: A deterministic SQL access guard for LLM… A deterministic SQL access guard for LLM text-to-SQL agents: enforce each user's real data-access rules on generated…github.com

The two fixes everyone tries first — and why both fail

So you reach for the obvious fix. There are two, and most enterprise teams have already tried at least one. Both fail — and why they fail is the whole design.

Fix 1 — give the agent access and tell it the rules. Connect it to the warehouse, add “only query data the user is allowed to see” to its system prompt, and trust it to comply. Ask it Dana’s question and it does the sensible thing: it joins revenue to the restricted cost table, pulls the US desks in alongside her EMEA book, and hands back a finished-looking answer:

The AI assistant’s plausible-looking answer: a Profitability by business unit table for Q2 2025 — US Investment Banking $30,000,000 revenue / $18,000,000 cost / $12,000,000 profitability; EMEA Corporate Banking $20,000,000 / $12,000,000 / $8,000,000; EMEA Retail Banking $10,000,000 / $6,000,000 / $4,000,000. Every US row is outside Dana’s region and every cost figure comes from a table she cannot open.

The AI assistant’s plausible-looking answer: a Profitability by business unit table for Q2 2025 — US Investment Banking $30,000,000 revenue / $18,000,000 cost / $12,000,000 profitability; EMEA Corporate Banking $20,000,000 / $12,000,000 / $8,000,000; EMEA Retail Banking $10,000,000 / $6,000,000 / $4,000,000. Every US row is outside Dana’s region and every cost figure comes from a table she cannot open.

This is the answer Dana should never receive. Every US row is outside her region; every cost figure comes from a table she can’t open. Nothing broke. The SQL was correct. Run the agent again and it reaches for the cost table every time, because that is the correct way to compute profitability when nothing stops it. The lesson isn’t that the model is careless. It’s that you cannot put authorization in the model’s hands. A prompt is a request, not a control. A creatively-worded question, or a document with hidden instructions can still get through. Access control has to be deterministic and live outside the model.

Fix 2 — so restrict what the agent can see. Provision it only the tables the user is entitled to — safer, and the instinct every data team reaches for next. But now the agent is blind: it has no idea the cost table exists. Ask for profitability and it doesn’t refuse — it sums revenue, labels the column “profitability,” and hands Dana a clean, wrong number: correctly computed, catastrophically mislabeled, and dangerously incomplete, but with a chart attached. It can’t tell “you’re not allowed to see cost data” from “there is no cost data,” so it can’t even warn you the answer is incomplete. The lesson: the agent has to stay aware of the data it can’t read. Without that awareness, it can’t be honest about what is missing.

A bank has to prevent both failures. An analyst who quietly pulls another desk’s P&L, or MNPI they weren’t cleared for, is a serious incident. An analyst who presents revenue as margin in a board pack is a different kind of incident. An AI assistant is the first tool that can create both risks — at machine speed, with a confident smile.

Neither fix works because each solves only half the problem — and the same issue applies beyond banking, anywhere AI agent sits on governed data. A real solution needs both at once: enforce access deterministically, outside the model — and still let the agent see the whole catalog, so it knows what it’s missing. With both in place, the agent can return an honest, clearly-labeled incomplete answer instead of a confident wrong one.

We built exactly that into a small working showcase: a sqlglot-based guard that rewrites or refuses each query against the asker’s role, with identity injected server-side, deny-by-default table and column checks, and 121 regression tests — most of them adversarial. The rest of this piece is what it does and where it fits; it’s a runnable notebook, linked at the end.

Two ways it goes wrong, and both are silent. Left - leakage (authorized overfetch): the agent joins revenue with restricted cost data and includes the US Investment Banking row ($30M revenue, $18M cost, $12M profitability) that is outside Dana’s region, returning data she should never receive. Right - a confident partial answer: the agent can’t reach cost data, so it returns revenue ($20M Corporate Banking, $10M Retail Banking) mislabeled as profitability. One failure leaks data; the other quietly misstates the truth.

Two ways it goes wrong, and both are silent. Left - leakage (authorized overfetch): the agent joins revenue with restricted cost data and includes the US Investment Banking row ($30M revenue, $18M cost, $12M profitability) that is outside Dana’s region, returning data she should never receive. Right - a confident partial answer: the agent can’t reach cost data, so it returns revenue ($20M Corporate Banking, $10M Retail Banking) mislabeled as profitability. One failure leaks data; the other quietly misstates the truth.

This is not SQL injection, and it is not a hallucination

The instinct is to reach for a familiar playbook — parameterized queries, prepared statements, input sanitizing. None of it applies. Those defenses stop an attacker from changing the structure of a query. Here the structure is fine: the agent generated a legitimate, well-formed SELECT that happens to read more than the person asking is entitled to.

Call the symptom authorized overfetch: the query is authorized — the database will happily run it — but it overfetches, returning data the asker had no right to. The mechanism is a security classic, the **confused deputy* — a program with more authority than its caller, induced to use that authority on the caller’s behalf — a database-flavored instance of what OWASP’s LLM Top 10 calls [excessive agency](https://genai.owasp.org/llmrisk/llm062025-excessive-agency/)*. Prepared statements have nothing to say about it.

This is not just a theoretical failure. A prompt is a request, not a control; and a 2025 study — Role-Conditioned Refusals, which layered real role-based policies onto the standard text-to-SQL benchmarks — found every model-layer enforcement approach became less reliable as the policies grew longer and more complex, exactly the wrong direction for an enterprise, whose policies are enormous and only ever grow.

The underlying problem is simple: the agent queries the database as itself, not as the person asking. Its identity is the service account it uses to connect, so the entitlements, walls, and row-level rules attached to people never apply. The database sees one privileged robot, not Frank, Bianca, and Dana.

This already happened — to a company far larger than yours

If that sounds hypothetical, it isn’t. In September 2025 an engineer described, on Microsoft’s own Q&A forum, an internal analytics agent built on Microsoft Fabric’s Data Agent. Row-level security was configured. The same users, viewing the same data through an embedded Power BI report, correctly saw only their entitled rows. But asked through the agent, it returned every row in the model — row-level security silently bypassed.

Two things had gone wrong, and they’re worth separating. A Microsoft community moderator diagnosed the first: the agent’s queries “may run under the agent’s service identity if user context is not propagated” — so the identity-dependent rules never fired. The engineer found the second himself: the security had been defined only in the Power BI semantic model, not at the warehouse table level, so it wasn’t present on the path the agent actually took. In the thread, a moderator acknowledged it as a known limitation still being worked on. (These were community moderators, not an official product-team statement — but the failure, and the fix, are real.)

The important point is that the security was configured. It worked for humans. It failed for the agent, and silently is the operative word: no error, no refusal — just a helpful answer assembled from data the asker should never have seen. This is not unique to Microsoft. It is an example of a structural gap in how AI agents interact with governed data. Engine-native security is necessary, but it is not sufficient on its own.

What a real fix has to satisfy

Put the two lessons together and the requirements for any AI agent over governed data become concrete. You can use them as a rubric for evaluating every approach:

  • See metadata, not rows. The agent sees table-level metadata for everything — every table’s name and description, including the ones it can’t query — and detailed schema only where policy allows. It always knows what exists; metadata is far less sensitive than data, and that visibility is what keeps it from going blind.
  • Enforce on execution, against the asker — not in the model. Every query is checked against the user’s real entitlements by something deterministic and outside the LLM, before it runs.
  • Rewrite or refuse before the SQL reaches the database. Allowed queries are rendered as safe SQL, with the user’s row filters injected where policy requires. Disallowed queries are blocked before execution — not post-filtered, not left to the model’s good intentions.
  • Answer honestly. Label every result FULL, SCOPED, or INCOMPLETE. When relevant data is out of reach, the agent should say so and explain why.

The first two points are really two identities doing two jobs. The catalog is built by a privileged, metadata-only identity — in production, your data catalog (dbt / DataHub / Collibra), or a privileged read of INFORMATION_SCHEMA. It enumerates names and descriptions, never rows. Execution runs under the end user's entitlements.

That separation is what lets the agent say “cost data exists, but it’s outside your access” instead of acting as if the table does not exist. Seeing that a table exists isn’t the same as reading its rows, and an access-aware agent needs to preserve that distinction.

Where you can actually enforce access

Every serious approach to this lives at one of four points. No single one is the whole answer:

The four places access enforcement can live, and each one’s blind spot on the agent path. The database engine (native RLS/masking — Snowflake, Databricks, Immuta) protects the real user’s identity at the source of truth, but enforces on whoever connects, missing the gap when the agent’s identity isn’t the user’s. A proxy on the wire (Formal, Teleport, Cyral, Palo Alto) governs connection identity and audit, not the meaning of a specific SELECT. A semantic layer by construction (Cube) gives strong control but limits questions to pre-modeled metrics. A guard between agent and database (this work, Thales, HeimdaLLM) preserves full SQL expressiveness enforced on the asker’s role, but is only as strong as its parser.

The four places access enforcement can live, and each one’s blind spot on the agent path. The database engine (native RLS/masking — Snowflake, Databricks, Immuta) protects the real user’s identity at the source of truth, but enforces on whoever connects, missing the gap when the agent’s identity isn’t the user’s. A proxy on the wire (Formal, Teleport, Cyral, Palo Alto) governs connection identity and audit, not the meaning of a specific SELECT. A semantic layer by construction (Cube) gives strong control but limits questions to pre-modeled metrics. A guard between agent and database (this work, Thales, HeimdaLLM) preserves full SQL expressiveness enforced on the asker’s role, but is only as strong as its parser.

Engine vendors are right that native policy is the source-of-truth backstop — but none of these points competes with the others the way the marketing implies. Engine-native security is the hard backstop; a guard in the agent path closes the identity gap it can’t see. The two cover each other’s failure modes — the real lesson of the Fabric story.

What “right” looks like

To make this concrete, we built a working showcase: an access-aware assistant over a miniature investment-bank warehouse — Revenue, Direct costs, a Client registry, a Product catalog — and the three people from the top of this article, each getting the answer they’re entitled to and no more.

The shape of it is simple. The agent proposes SQL; a deterministic guard, keyed to the asker, decides what is allowed to run:

The model proposes, the guard decides. The question and a server-injected identity go to the AI agent, which proposes SQL. A deterministic guard checks the asker’s policy and splits into two paths: the allowed path rewrites the query with row filters, runs it against the warehouse, and returns a scoped answer; the denied path blocks the query, refuses, and raises an access-gap flag. The trusted behavior uses the asker’s role, returns only what is allowed, and says clearly when it cannot fully answer - labeled FULL, SCOPED, or INCOMPLETE. Access control is enforced on the executable query, not just the model output.

The model proposes, the guard decides. The question and a server-injected identity go to the AI agent, which proposes SQL. A deterministic guard checks the asker’s policy and splits into two paths: the allowed path rewrites the query with row filters, runs it against the warehouse, and returns a scoped answer; the denied path blocks the query, refuses, and raises an access-gap flag. The trusted behavior uses the asker’s role, returns only what is allowed, and says clearly when it cannot fully answer - labeled FULL, SCOPED, or INCOMPLETE. Access control is enforced on the executable query, not just the model output.

The design turns on one decision: the agent is never told its role. Frank, Bianca, and Dana’s identity is injected server-side the moment they ask, so it never appears in the model’s prompt or context. The agent has no role label to select, spoof, or interpret.

(The next few paragraphs are the implementation proof; the business takeaway is one line — the model proposes, the guard decides. Non-technical readers can hold onto that and read on.)

The guard is a deterministic, LLM-free module — ~500 lines of Python on the sqlglot parser. It resolves every column to its base table, checks tables and columns against the role’s allow-list (deny-by-default), injects row filters, and renders safe SQL — or refuses. Anything it can’t parse is rejected. No model sits in that enforcement path, so the behavior doesn’t depend on the model’s goodwill or on it correctly following policy text.

“Rewrites” is literal. Dana asks for revenue by business unit; she never says “only EMEA” — the guard welds her scope into the query before it runs:

-- What the agent proposes:
SELECT business_unit, SUM(revenue_amount) FROM fact_revenue
WHERE period = '2025-Q2' GROUP BY business_unit

-- What actually executes - one line injected, from Dana's role:
SELECT business_unit, SUM(revenue_amount) FROM fact_revenue
WHERE period = '2025-Q2'
  AND region IN ('EMEA')          -- ← added by the guard
GROUP BY business_unit

The model writes SQL; the guard rewrites it. On the left, the agent proposes a query selecting business_unit and SUM(revenue_amount) from fact_revenue for 2025-Q2 - with no region filter, so it could return data outside the user’s scope. On the right, before the query reaches the warehouse, the deterministic guard injects one line - AND region IN (‘EMEA’) - based on Dana’s identity (Role: Analyst, Region access: EMEA). Access is enforced before execution, not after the answer is written; only data within the user’s scope is returned.

The model writes SQL; the guard rewrites it. On the left, the agent proposes a query selecting business_unit and SUM(revenue_amount) from fact_revenue for 2025-Q2 - with no region filter, so it could return data outside the user’s scope. On the right, before the query reaches the warehouse, the deterministic guard injects one line - AND region IN (‘EMEA’) - based on Dana’s identity (Role: Analyst, Region access: EMEA). Access is enforced before execution, not after the answer is written; only data within the user’s scope is returned.

The same question is handled differently depending on who is asking. Send the harder question — profitability, which needs the restricted cost table — through the guard of each user:

Frank   (finance_controller) → ALLOWED, no filters      → the whole firm
Bianca  (bu_manager)         → ALLOWED, rewritten with   → her book only
                                 region IN ('EMEA') AND
                                 business_unit IN ('Corporate Banking')
Dana    (revenue_analyst)    → DENIED
                                 role 'revenue_analyst' is not permitted
                                 to access table 'fact_direct_cost'

That’s the whole thesis in one block: the guard isn’t advice to the model or a filter on the output — it changes or blocks the executable query, per asker, before it runs. And access depends on the question, not just the person: ask Dana’s agent for “revenue by product family” — which needs only the public product catalog — and the agent that just refused profitability returns a full, scoped answer.

The behavior that actually earns trust

The most important behavior is also one of the easiest to describe: an assistant should be clear about what it cannot answer. What makes a good analyst trustworthy isn’t that they know everything — it’s that when you ask for something outside their access, they say so, and that honesty is what lets you trust everything they do tell you. We wanted the AI assistant to behave like that analyst, not like an eager junior who’d rather guess than admit a limit.

So Dana’s answer isn’t a refusal and isn’t a wrong number. It’s this:

Dana’s honest answer, an INCOMPLETE report card for Q2 2025. Missing: fact_direct_cost is restricted, so profitability cannot be computed because it requires direct-cost data. Scope: EMEA region only; applied filters fact_revenue.region IN (‘EMEA’). Revenue by business unit: Corporate Banking $20,000,000, Retail Banking $10,000,000, Total $30,000,000. Footer: this is revenue only — profitability could not be computed because fact_direct_cost is outside your access.

Dana’s honest answer, an INCOMPLETE report card for Q2 2025. Missing: fact_direct_cost is restricted, so profitability cannot be computed because it requires direct-cost data. Scope: EMEA region only; applied filters fact_revenue.region IN (‘EMEA’). Revenue by business unit: Corporate Banking $20,000,000, Retail Banking $10,000,000, Total $30,000,000. Footer: this is revenue only — profitability could not be computed because fact_direct_cost is outside your access.

Alongside that human-readable answer, the system emits a machine-readable flag:

{
  "type": "access_gap",
  "requester": "revenue_analyst",
  "metric": "profitability",
  "needs": ["fact_revenue", "fact_direct_cost"],
  "missing": ["fact_direct_cost"],
  "returned": "revenue by business unit"
}

That flag can flow to a SIEM or an access-request workflow. A denied query doesn’t have to become a dead end; it can become the first logged step in a reviewable entitlement decision.

This is still underdeveloped in many systems, and it has academic backing: the TrustSQL benchmark scores a silent wrong answer as actively harmful — worse than saying nothing — but even its taxonomy of “can’t answer this” does not fully capture permission-based incompleteness. The tools reflect the gap: Databricks’ docs describe Genie as returning an empty response when a user asks about data they can’t see; the benchmarks model access as a simple permit/deny decision. Few systems say why they can’t answer, and fewer offer the partial answer they legitimately can. Dana’s does — because her AI agent kept the catalog visibility, even though it cannot read it.

What we didn’t invent — and the part that separates a demo from a system

It’s important to be precise about the contribution. A deterministic parse-and-rewrite guard is not novel. Thales Group ships an open-source one, [sql-data-guard](https://github.com/ThalesGroup/sql-data-guard) - allow-lists, row restrictions, rewriting - on the same parser we use. HeimdaLLM takes the same position: no AI in the enforcement path, just grammars and parsers. And the pattern is peer-reviewed: LangShield (ICSE 2025) describes exactly this rewriting hook, measured its overhead at under two milliseconds, and recommends it as part of defense-in-depth alongside database least privilege.

What we found missing wasn’t the mechanism. It was the combination: a deterministic guard, paired with letting the agent see the full governed catalog (so it knows what it’s missing and can say so), paired with honest disclosure of what it can’t answer. The contribution is not the guard alone, but the guard integrated into the agent’s behavior: enforcing access while also enabling the assistant to explain incompleteness.

A guard also has to be tested adversarially. SQL has many edge cases, and a naive check is easy to bypass. In an early version of ours, an independent red-team agent found one — an outer-join construction that let an EMEA-only analyst read US rows. We fixed it and added a regression test so the issue cannot silently return. Today the guard ships with 121 tests, 74 of them adversarial — and they hold:

A sample from the adversarial suite (74 of the 121 tests). Reading a restricted column (client_id) inside an allowed table: BLOCKED. Smuggling US rows past an EMEA filter via an outer-join trick: closed, scoped to EMEA. Hiding an unpoliceable join key behind a NATURAL JOIN: BLOCKED.

A sample from the adversarial suite (74 of the 121 tests). Reading a restricted column (client_id) inside an allowed table: BLOCKED. Smuggling US rows past an EMEA filter via an outer-join trick: closed, scoped to EMEA. Hiding an unpoliceable join key behind a NATURAL JOIN: BLOCKED.

That test suite is what turns the guard from a demo component into something closer to a system boundary. If you build one of these, the red-team work is not optional; it is part of the control.

One caveat is important: the guard makes generated SQL access-safe, not analytically correct. Whether the agent computed the right number — the right grain, no double-counting, correct metric definition — is a separate problem. That belongs in careful prompting, semantic modeling, and metric design. It’s more honest to solve one problem well than to claim both.

What would have to be true in production

A showcase is not a deployment. In a real bank, this would be one layer in a broader control stack: engine-native RLS as the source-of-truth backstop; IAM/SSO so identity comes from the same source of truth, never chosen by the agent; a SIEM so every decision is logged and reviewable; a semantic layer for the analytical-correctness problem the guard does not try to solve.

And for a bank, the same query-time enforcement also has to support regulatory controls. The demo scopes by region and desk; the real policy scopes by information barrier and deal team, walling the MNPI-holding private-side data from public-side users. Row-level scoping isn’t the whole of an information-barrier regime — that also depends on approvals, surveillance, and wall-crossing records — but the query-time part of it.

That is why the guard should be a readable, owned component. A regulator, second-line reviewer, or internal control team should be able to inspect what it enforces, see how it applies per person, and verify what the assistant will and will not return.

The takeaway

If you are evaluating an AI assistant over regulated data, three points matter most.

First, the main risk is not the model inventing a table. It is authorized overfetch: legitimate, well-formed queries that return more data than the asker is allowed to see. The related risk is a confident partial answer: an answer that is technically computed, but incomplete because the assistant could not access all the data needed. Prepared statements and system-prompt rules do not solve either problem.

Second, choose the enforcement point deliberately. Engine-native security is necessary, but it may not cover the agent path when the agent does not query as the end user. Proxies govern connections, not the meaning of a specific query. Semantic layers offer strong control, but limit the assistant to pre-modeled questions. A deterministic guard can preserve SQL expressiveness while enforcing access against the asker’s role — but only if it is well-tested, red-teamed, and built to fail closed.

Third, let the assistant be honest. The most trustworthy behavior is simple: the assistant should know what it cannot answer, say so clearly, return the portion the user is allowed to see, label the result properly, and leave a record of the access gap. It should know whose question it is answering, what that person may access, and when the right answer is: “I cannot fully answer that. Here is what I can answer.”

The Fabric example matters because it shows how easily this can fail in practice. The agent did not need to behave maliciously. It followed the request, used data the asker should not have seen, and returned an answer without warning. Access-aware text-to-SQL is about preventing that path — and, when a complete answer is not allowed, making the limitation explicit.

The full working showcase is on GitHub: the annotated notebook, the guard (~500 lines on sqlglot), the catalog and system prompt, and the 121 adversarial tests. Clone it, point it at your own schema, and watch it refuse — the test suite is the part worth reading.

Full implementation: **github.com/sparklingneuronics/access-aware-text-to-sql**


메타데이터
post_id
bdd58b548a70
slug
ai-data-agents-need-access-control-not-better-prompts-bdd58b548a70
url
https://www.towardsdeeplearning.com/ai-data-agents-need-access-control-not-better-prompts-bdd58b548a70
canonical_url
https://www.towardsdeeplearning.com/ai-data-agents-need-access-control-not-better-prompts-bdd58b548a70
author_url
https://medium.com/@dimitar.h.stoyanov
status
ok
fetched_at
2026-07-14 16:41:29