AI agent data access permissions come down to one sentence: an agent that queries business data on someone’s behalf must never return rows that person is not allowed to see. That sounds like the same access-control problem every web app has — and mechanically it is — but the agent changes who the caller is, and that changes where the check has to live.
A scope check can live in the interface: an if/else branch that filters a dropdown, a query builder that quietly appends a filter. The moment an agent holds a database connection and composes its own queries, every one of those checks stops applying — not because the agent is malicious, but because the code path that contained the check never runs. And the failure is quieter than a breach headline: an over-broad agent query doesn’t throw an error, it returns a plausible, well-formatted answer built from rows the person asking should never have seen.
This guide covers where a scope check can live and what each layer actually guarantees, the mechanics of Postgres row-level security and its documented bypass paths, a worked example of the trap where the authorization key doesn’t match the storage key, the multi-tenancy taxonomy that tells you which isolation model you’re in, and how to test policies by asserting on what a role must not see.
- 01The agent is a caller, not a feature.Once an agent holds a database connection, scope checks that live only in the UI or a single service path never run on the agent’s path. The UI was never the only caller — the agent just makes that impossible to ignore.
- 02Enforce scope where every caller must pass.Per the PostgreSQL docs, enabling row-level security flips the table to default-deny: no rows are visible until a policy grants them back.
- 03Know the bypass list before you trust it.Superusers, BYPASSRLS roles, and table owners (unless FORCE ROW LEVEL SECURITY is set) skip policies entirely, and Supabase’s service_role key authorizes through a BYPASSRLS role. Migrations, cron jobs, and admin tools can run as exactly these.
- 04Model scope as a set of rows, not a branch ID.Permissive policies combine with OR and restrictive policies with AND, and predicates are arbitrary boolean expressions — so a user’s scope can resolve a hierarchy into a set of qualifying values instead of a single hard-coded identifier.
- 05Test by asserting absence, not just errors.Supabase’s RLS guidance calls for pairing every denied write with a check that the target row is intact; OWASP’s Authorization Cheat Sheet frames deny-by-default as the state under test. Two sources, two distinct points — both belong in your suite.
01 — The New CallerThe UI was never the only caller.
Most business databases already have more callers than the web app: reporting scripts, cron jobs, a second internal tool, the odd direct connection from an analyst. An AI agent is simply the newest member of that list — but it’s the member that finally breaks the comfortable fiction, because it composes queries dynamically, on demand, on behalf of whoever asked it a question. Who — or what — is making the request is its own discipline (we covered non-human credentials in our agent identity and credentials playbook); this post is about the other half of the problem: once the caller is identified, which rows may it touch?
The reason this matters more with an agent than with a UI is the shape of the failure. A broken page renders an error a human can see and report. An agent handed an over-broad connection answers the question it was asked — accurately, fluently, and with data drawn from every row its credentials can reach. Nothing crashes. The answer simply includes another unit’s revenue, another team’s customers, another person’s records. The same dynamic applies to which copy of the data the agent reads — a separate correctness problem we cover in our guide to agent read paths and stale data — but visibility is the sharper edge: a stale answer is wrong, an over-scoped answer is a disclosure.
The human in the app
Sees only what the interface renders. A scope filter in the page code looks sufficient because this caller cannot phrase a query the UI didn’t anticipate.
Scripts, crons, integrations
Skip the interface entirely. Their queries are at least written once, by a person, and reviewable — but they already prove the UI’s checks are not the boundary.
The AI agent
Queries on behalf of a person, phrased however the question demands. If its connection can see every row, some answer eventually will too — plausibly formatted, with no error raised.
The conclusion falls out of the table of callers: the permission check has to live at a layer every one of them passes through. For row visibility, that layer is the database itself.
02 — Enforcement LayerWhere the scope check actually lives is the security decision.
Most access-control writing explains how to write a rule. The prior question is where the rule is enforced — because the layer you choose determines what happens when a caller you didn’t plan for shows up. OWASP’s Top 10:2021 ranks Broken Access Control as the #1 web-application risk category, up from 5th in the 2017 edition, with 34 CWEs mapped to the class and 318,487 occurrences in the contributed 2021-edition testing data. Among its named example weaknesses are violations of deny-by-default and insecure direct object references — the exact mechanical shape of a scope check that exists only in application code and can be walked around by any caller that skips the app.
| Where the check lives | Must every caller pass through it? | When a new caller arrives (script, agent, second API) | Failure mode if misconfigured |
|---|---|---|---|
| UI only | No. Only the human clicking through the interface ever meets it. | The new caller never sees the check. Whatever its credentials can reach in the database is what it gets. | Wrong rows render — or an agent narrates them fluently. Maps to OWASP A01’s broken-access-control class. |
| Application / service layer | Only callers routed through that service. Direct connections and sibling endpoints skip it. | Protected only if someone remembers to wire the new caller through the same path — a per-caller obligation that decays. | IDOR-shaped: swap an identifier, read someone else’s rows. A second path into the same data recreates the bypass. |
| Database row-level policy | Yes — every query against the table, minus the documented bypass roles in Section 04. | The new caller inherits the policy automatically. Nothing to remember; the predicate applies to the query, not the caller. | Silent by design: no policy means zero rows; an over-broad policy means every row. Both demand tests that assert on absence. |
The middle row is where real systems get burned, and there’s a well-documented public example. In 2024, security researchers Sam Curry, Neiko Rivera, Justin Rhinehart, and Ian Carroll disclosed that Kia’s dealer-portal registration endpoint accepted the same HTTP request used for consumer owner registration — letting an attacker mint a dealer-level account and reach dealer APIs that exposed any vehicle owner’s name, phone, email, and address, and issue remote commands, starting from nothing but a license plate. Kia was notified and the flaws were fixed by August 14, 2024. To be precise about what this illustrates: it was an API-layer authorization boundary, not a Postgres row-level-security failure. The lesson is the class of bug, not the technology — a permission decision that exists in only one place in the call graph gets walked around by a second path into the same data.
"It seemed that we could register on the Kia Dealer website using the same HTTP request to register on the Kia Owners website."— Sam Curry, security researcher, ‘Hacking Kia’ disclosure (2024)
An AI agent with a database connection is exactly such a second path — one you deployed on purpose. The difference between the Kia case and yours is that you know the second caller is coming, which means you can put the boundary underneath it before it arrives.
03 — RLS MechanicsRow-level security, mechanically.
PostgreSQL’s row security system is documented in §5.9 of the manual, and three mechanics carry the whole pattern. First, the default flips on enablement: once ALTER TABLE … ENABLE ROW LEVEL SECURITY is set, all normal SELECT, INSERT, UPDATE, and DELETE access must be allowed by a row security policy — with none defined, no rows are visible. That reverses the pre-RLS default, where any granted user saw every row. Second, policies are boolean predicates over the row, and they compose by kind: multiple applicable PERMISSIVE policies (the default) combine with OR, while RESTRICTIVE policies combine with AND — a row must pass at least one permissive policy and every restrictive one. Third, policy management is a table-owner privilege; it isn’t delegable to a lesser role without object ownership.
Just as important is what runs before the policy. Postgres evaluates grants and policies as two separate, sequential checks: table and column GRANTs decide whether a role may perform an operation on the table at all; RLS policies then decide which rows that already-permitted operation can touch. A grant with no policy is not safe by omission — on a table without RLS enabled, it means every row is visible. And the two-step cuts the other way on cleanup: adding policies does not revoke pre-existing grants, so a table “protected only by policies” can still hand a broad role an access path if the underlying grant was never revoked. Revoke broad grants, then add narrow policies — doing only the second step leaves a gap.
Deny until a policy grants
Enable row security with no policies defined and no rows are visible or writable. The safe default is also the silent one — an empty result, not an error.
At least one must pass
Multiple permissive policies widen access — a row visible under any one of them is visible. This is what lets scope be modelled as a union of entitlements.
Every one must pass
Restrictive policies narrow access — a row must satisfy all of them on top of a permissive pass. Use them for hard boundaries that no union may cross.
Because predicates are arbitrary boolean expressions, a policy is not limited to tenant_id = current_setting(…) equality. It can call a function, resolve membership, or check a value against a computed set — for example branch_id = ANY(current_user_branch_ids()). That expressive room is what makes the pattern in Section 05 possible, and it’s why we’d frame the design question as “what set of rows is this person entitled to?” rather than “which single ID do they carry?”
04 — Bypass PathsWhat row-level security does not cover.
RLS is a floor, not a force field, and the PostgreSQL documentation is candid about the exceptions. The most consequential: “Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table.” Table owners bypass it too, unless the table is explicitly put under ALTER TABLE … FORCE ROW LEVEL SECURITY. OWASP’s Multi-Tenant Security Cheat Sheet reinforces the same point from the defensive side — “Force RLS for table owners too (important!)” — alongside a code example. Think about which of your processes run as owner or superuser: migration scripts, cron jobs, admin tools. Without FORCE, every one of them silently sees everything.
On a Supabase stack this exception has a name you already know: the service_role key authorizes through a Postgres role carrying the BYPASSRLS attribute, so it skips any and all row-security policies you attach — which is exactly why Supabase’s guidance is to never expose that key client-side. There’s a subtlety worth knowing: a secret key bypasses RLS only when the request carries no user access token. If the request does carry one, it runs under that signed-in user’s own policies even though the client library was initialized with the secret key.
Two quieter leak paths round out the list. Referential-integrity checks — unique and primary-key constraints, foreign-key references — always bypass row security. The Postgres docs warn: “Care must be taken when developing schemas and row level policies to avoid ‘covert channel’ leaks of information through such referential integrity checks.” In practice, a constraint-violation error can confirm the existence of a row the querying role cannot see. And a security-definer function placed in an exposed schema is callable through the Data API with its creator’s privileges; Supabase’s guidance is to never create one there and to pin search_path = '' on every security-definer function, because an unpinned search path lets a caller point an unqualified name at their own object and run it with the function owner’s privileges.
Finally, the claims problem. Supabase warns against reading scope from JWT user_metadata inside policies — that claim is user-editable, so a signed-in user could forge their own scope by editing their profile. And even trustworthy claims go stale: “Even if you remove a user from a team and update the app_metadata field, that will not be reflected using auth.jwt() until the user’s JWT is refreshed.” Reassign someone off a unit and, if scope rides in the token, the old scope survives until reissue. A fresh row-level lookup trades a little latency for scope changes that take effect when you make them. Relatedly, auth.uid() returns null for an unauthenticated request — write policies to check authentication explicitly rather than relying on a null comparison to fail closed.
05 — Worked ExampleScope is a set, not a branch ID.
Here’s the trap that motivated this post, worked through on invented numbers. All figures in this example are illustrative — no real company’s structure or counts appear here. Picture a multi-site operations company with twelve branches. An area manager covers three of them. And one location is a satellite depot: it takes bookings and serves customers, but every record it generates is written under its parent metro branch — the depot has zero rows keyed to its own identifier.
Model scope as a single hard-coded ID and both edge cases break. The area manager, stamped with one branch ID, loses two-thirds of their remit. Worse is the depot manager: grant them scope over the depot’s own identifier and every query filters on a value no row carries. The result set is empty. No error is raised, nothing crashes, and the dashboard — or the agent’s answer — reads like a perfectly plausible quiet day. This kind of scoping bug is hard to spot precisely because it produces a believable answer: zero rows looks like “nothing happened,” not “the authorization key doesn’t match the storage key.” We haven’t seen this failure mode named in the RLS guidance we’ve read — most of it stops at “scope by tenant_id” — so treat this as a described pattern from build experience, not a sourced statistic.
The fix is to model a user’s scope as a set of row-qualifying values, resolved through the org hierarchy before the policy predicate is evaluated. The area manager’s set is their three branches. The depot manager’s set resolves up the hierarchy to the parent metro branch — because that’s where the depot’s rows actually live. A policy like branch_id = ANY(current_user_branch_ids()) doesn’t care whether the set has one member or five, or whether a member was resolved through a parent-child relationship; permissive-OR composition (Section 03) means additional entitlements widen the set cleanly. The general rule: when the natural query key and the natural authorization key differ, resolve the difference in one place — the function that computes the set — not in every caller.
One boundary note: row visibility is only one of the guardrail layers an ops system needs. Which state transitions a record may make is a different layer with different failure modes — we’ve written that up separately in the CRM state-machine guardrails pattern. And if the system in question is a CRM with an agent in front of it, this whole design conversation is the core of our CRM automation work.
06 — Tenancy ModelsSilo, pool, or bridge — where row scoping sits.
Branch scoping is multi-tenancy wearing a different name badge, and the taxonomy is well mapped. AWS’s SaaS tenant-isolation whitepaper names three canonical models: silo (dedicated resources per tenant, commonly a dedicated database), pool (tenants share the underlying infrastructure — this is where row-level scoping lives), and bridge (a mix per layer, such as a shared web tier over siloed storage). Citus — the Postgres horizontal-scaling extension — is explicit in its multi-tenant guidance that it “works best with shared-schema multi-tenancy,” with all tenants’ data in the same tables keyed by a tenant identifier, and it documents combining that shared-schema approach with native Postgres row-level security to enforce the per-tenant boundary.
Mapping that onto the familiar database-per-tenant / schema-per-tenant / shared-schema framing is our synthesis across those two sources rather than any single source’s wording — but the correspondence is direct, and it’s the decision matrix that matters:
Database per tenant
AWS’s silo model: dedicated resources per tenant. Highest isolation, highest operational cost — separate connections, migrations, and backups for every tenant. Physical separation does the enforcement work.
Schema per tenant
Isolation by namespace inside one database. Less infrastructure than silo, but still N migration runs and N sets of objects to keep in sync — the operational tax scales with tenant count.
Shared schema + row policies
AWS’s pool model, and the shape Citus documents pairing with Postgres RLS. One schema, one migration, lowest per-tenant overhead — isolation rides entirely on policy correctness rather than physical separation.
Mixed per layer
AWS’s bridge model: silo where a tier demands it, pool where it doesn’t — for instance a shared application tier over per-tenant storage. Useful when one workload’s isolation requirement outranks the rest.
A branch-structured operations app sits naturally in the pool model. Its “tenants” — the branches — are few and fixed compared with a true multi-customer SaaS, which removes most of the operational argument for silo isolation. But the isolation requirement itself is mechanically identical: a given user’s queries must never surface another branch’s rows. The pattern doesn’t change between a multi-customer SaaS and a multi-branch company; only the vocabulary does. That’s worth internalizing because it means the pool model’s one obligation — policy correctness is the entire boundary — applies to the small ops app with exactly the force it applies to the SaaS, which is what makes the testing discipline in the next section non-optional.
07 — TestingProve the denial, not just the error.
Because both failure directions are silent — no policy returns zero rows, an over-broad policy returns every row — scope has to be verified by a suite, not a spot-check. Two sources anchor that discipline, and they make two different points, so the attribution matters. Supabase’s RLS guidance frames verification as a pass/fail suite — until the suite passes, you don’t know whether the policies do what you intended — and specifically calls for pairing every denied write with a check that the target row is still intact: assert the row was not changed, not merely that the write call returned an error. OWASP’s Authorization Cheat Sheet supplies the state under test rather than the assertion style: “The application must always make a decision, whether implicitly or explicitly, to either deny or permit the requested access,” and its testing guidance asks “is access being denied by default? Does the application terminate safely when an access control check fails?”
Put together — and they are complementary, not one unified vendor recommendation — the suite for a branch-scoped system looks like this. For every role: a positive case (the rows the role must see are present), a negative case (a query shaped to reach another unit’s rows returns nothing), a denied-write case (the write fails and the target row is unchanged), and a hierarchy case (a user scoped to a child unit receives the parent-keyed rows they’re entitled to — the direct counter to the zero-rows trap, which only presence assertions can catch). Add one case per bypass path from Section 04: a test that fails if a table is missing FORCE ROW LEVEL SECURITY, and a test that fails if the agent’s role carries BYPASSRLS.
This testing posture pairs naturally with a cautious rollout posture: give the agent a read-only, policy-scoped role first and widen from evidence — the same sequencing argument we make for whole systems in the read-only-first rollout pattern. And if you’re standing up agentic access to business data and want the scope model, policies, and test suite designed together, that’s the ground floor of our AI transformation engagements.
08 — ConclusionPut the boundary where every caller must pass.
The database is the layer every caller has to pass through.
The direction of travel is easy to read: databases tend to gain callers over time, and agents accelerate that because each agent is effectively a query author. Access control that lives in one caller’s code path was already the #1 risk class in OWASP’s 2021 edition — before agents were composing queries at all. The durable response is not more per-caller checks; it’s moving the decision down to the layer all callers share, where default-deny is the starting state and a policy predicate grants rows back.
The craft, once you’re there, is in the details this guide walked through: scope modelled as a set resolved through the hierarchy, so the sub-unit whose records live under a parent doesn’t produce a convincing empty dashboard; the bypass list — owners without FORCE, BYPASSRLS roles, service keys, definer functions — audited so the agent holds none of it; and a test suite that asserts on absence and presence both, because both failure directions are silent.
Looking forward, we’d expect the enforcement-layer question to keep moving down-stack as agent adoption widens — visibility rules expressed as data the database evaluates, rather than branches the application remembers to take. Teams that already treat row policies as the boundary and application checks as UX can hand an agent a connection with reasonable confidence.