Education PlatformsField engagement

The brief said retrieval. Three of the four example questions were live-record queries, which makes it a permissions engagement with a language model attached.

Architecture and hardening lead.
The approach, in four words
the lock sits below the modelforce the policyidentity per transactiontest the negative case
checks that held5
checks that leaked another record3
Eight permission checks against a correctly written policy. Three returned another child's complete record, and all three failures were below the policy rather than in it.
What was at stake

A school platform wanted a natural-language assistant over its records, and the brief called it retrieval. The example questions supplied with the brief asked things like how a particular child's attendance looked this week. There is no document behind a question like that and nothing to embed. It is a live database query, run on behalf of a parent or a teacher, against records about children. The only thing that decides whether the answer is about the right child is the permission boundary.

How this works

A row-level policy is only as good as the context it is evaluated in.

A database with row-level security lets you attach a policy to a table. The policy is a condition evaluated against each row, using facts about who is asking: this row is visible if its pupil belongs to the requesting parent, for example. When a query runs, the database quietly adds the policy to it as an extra condition, so rows that fail the test are simply absent from the result. Nothing errors. The query returns fewer rows, and the code that issued it cannot tell that anything was filtered.

To evaluate that condition the database needs to know who is asking, and this is the weak point. The database itself only knows which database role opened the connection. A web application usually serves every user through a small pool of shared connections under the same role, so it has to tell the database who the end user is, typically by setting a variable that the policy reads. That variable can be set for the whole session or for a single transaction. The policy is only as correct as that variable is at the moment the query runs, and on a shared connection the session can belong to whoever used it last.

Policies also apply only in certain contexts. In common implementations, the role that owns a table and any role with a bypass privilege are exempt unless the policy is explicitly forced on them. A view normally runs with the privileges of the role that defined it, so a query through a view may be judged as the view's owner. Functions can be declared to run with their definer's rights. Each of these defaults makes sense for some other purpose. Together they mean the same policy text can be fully enforced, partly enforced or ignored depending on how the query arrived, and every one of those outcomes looks like a normal result. The exact defaults vary by database product and version and have to be checked on the one in use.

  1. Parent asks a question
    in natural language, through the assistant
  2. Identity set for this transaction
    who is asking, scoped to this request only
  3. Model proposes a query
    it chooses what to ask for, nothing more
  4. Database checks role and path
    owner, bypass and view rules decide if the policy applies
  5. Policy filters the rows
    rows for other children are absent, not refused
  6. Only permitted rows return
    the model can only describe what it received
A correct policy cannot rescue a wrong identity set earlier in the chain.
FigureWhat has to go right, in order, for a parent's question to return only their own child's record. The policy itself is only one step. Every step before it decides whether the policy is evaluated for the right person, or evaluated at all.
The constraint

A rule written into the model's instructions is a request made to the very component it is meant to constrain, and the space of inputs that might talk the model out of it is all of natural language. So the control has to live in the database. That move is necessary and it is not sufficient. Row-level security is evaluated in a context: which role connected, which identity was set, whether the query arrived through a view. The context at runtime is often different from the one the developer pictured when writing the policy. The engineering is proving that the lock holds below the model, in the contexts that actually occur.

Where it usually breaks

Permission leaks rarely look like errors. They look like answers.

When access control fails inside a database, the usual symptom is not a refusal or a crash. It is a result with extra rows in it. The query succeeds, the application formats the result, and the user sees a plausible answer. An assistant makes this worse, because a model will turn whatever rows it receives into fluent, confident prose about a child the user has no right to see.

The other pattern is testing in the wrong direction. Teams write tests confirming that a user can see their own data, because that is what the feature is for. Every leak described here leaves that test passing. A boundary is defined by what it keeps out, and a test suite that never asks for something forbidden says nothing about it.

01

The prompt rule

Access rules are written into the model's instructions. They work in every example tried, and they are a request to the component they are meant to constrain.

02

The owner exemption

The application connects as the role that owns the tables, which is exempt from their policies by default. The policy is correct and never consulted.

03

The definer's view

A view added for convenience runs with its creator's privileges, so queries through it are judged as the creator and return rows the caller should not see.

04

The inherited identity

Identity is set per session on a pooled connection. A request that fails to set it runs as whoever used the connection last, and receives that person's answer.

05

The happy-path suite

Every test asks for something the user is allowed. All of them pass whether the boundary works or not.

Connected as the table ownerowner is exempt by default, so the policy is skipped
Queried through a definer viewjudged as the view's creator, not the parent asking
Identity left on the connectionthe previous user's identity answers the next request
The policy itselfcorrectly written in all three cases
A correct policy is necessary. Where and as whom it is evaluated decides whether it does anything.
FigureThree leak paths the engagement closed. In each one the policy text is unchanged and correct, and another child's record still comes back. What differs is the context the policy is evaluated in, which is why reviewing the policy alone would find nothing.
The fork

The reflex, and the fix.

Road not taken

Write the rule into the system prompt

Pull

It reads like a rule, it takes one line, and it behaves correctly in every example anyone thinks to try.

Why not

The model is the component being constrained, so the instruction is a polite request. Its input space is unbounded, which means it cannot be tested exhaustively, and nothing about it can be proven to hold.

Road taken

Enforce below the model, then prove each leak path closed

Accepted

A least-privileged database role, policies forced on the table owner, identity scoped to each transaction, and a test suite written around the records that must not come back.

Bought

A boundary that holds whatever the model asks for, with each known leak path first demonstrated and then shown to be closed.

Decision

The model decides what to ask for. The database decides what it is allowed to receive.

How it was built
01Question
›
02Query
›
03Policy in context
›
04Scoped identity
›
05Answer
01

A demonstrator before a design

During scoping, eight permission checks were run against a correctly written policy, as a reproduction on a test setup. Three of them returned another child's complete record. That made the case for building the boundary deliberately, because it showed that a correct policy is not enough on its own.

02

Forcing the policy on the owner

By default, the role that owns a table is exempt from that table's policies. Applications very often connect as the role that ran the migrations, which is usually the owner. Forcing the policy to apply to the owner as well turns that common configuration mistake into a non-event.

03

Views that run as their caller

A view runs with the privileges of whoever defined it unless it is told otherwise. A convenience view added to make queries simpler can therefore route around the policy, with no error anywhere. Views are made to evaluate as the user who is asking.

04

Identity that ends with the transaction

Identity set for a whole database session survives on a pooled connection into the next request. Any code path that forgets to set it then answers for the previous user, and the answer is well formed and plausible. Scoped to the transaction, a forgotten setting produces nothing at all.

05

Tests written around the negative case

A test that checks the right parent sees their own child passes in all three leaking configurations, because a leak adds rows and takes none away. The test that tells a working boundary from a broken one asks for another child's record and expects an empty result.

Inside the key decisions

What each choice cost, and what it bought.

ATreating the brief as a permissions engagement

The brief described retrieval, and building retrieval would have matched it. Reading the example questions showed that most of them were about live records, which made the retrieval layer small and the access boundary the main body of work. Re-scoping cost an awkward conversation early on. It avoided building a polished search layer on top of a boundary nobody had tested, over records about children.

BRunning a demonstrator before designing anything

The argument that a correctly written policy might leak is easy to wave away. Showing it settles the matter. The demonstrator cost scoping time, and what it produced was a reproduction on a test setup, never a finding about the client's live system. What it bought was agreement that each leak path had to be closed and proven individually, and a concrete set of cases to prove them against.

CScoping identity to the transaction

Session-scoped identity is simpler to wire up and is how many examples do it. On a pooled connection, it means a request can inherit the previous request's user. Transaction scoping requires every query path to set identity explicitly, every time, which costs discipline throughout the application code. In return the failure mode changes from someone else's child to an empty result, and an empty result is a bug report, not a disclosure.

DMaking the negative case the core of the suite

Tests about what must not come back are less natural to write. Each one needs a second identity and a target record that deliberately belongs to someone else. They are also the only tests that distinguish a working boundary from a broken one, so they became the acceptance criteria, and the tests that a user sees their own data became the supporting cast.

Language modelchooses what to ask for, never what it receives
Least-privileged roleconnects with no ownership and no bypass
Forced row policiesapply even to the owner
Views evaluated as callerno route around the policy
Identity per transactiona forgotten setting returns nothing
Negative-case testsask for another child's record, expect nothing
Remove any one layer and the policy can still be correct while the boundary fails.
FigureWhere each control sits. The model is at the top and controls nothing about access. Everything that decides what a parent can receive lives underneath it, and the test suite at the bottom is what shows the layers above it actually hold.
How it was measured

A single headline number hides where a system fails. This work was scored on the dimensions that actually decide whether it holds in production, measured on real, held-out cases rather than the demo path.

Cross-record access under adversarial identifiersPolicy behaviour under pooled connectionsBehaviour through viewsCoverage of the negative case in tests
The gate
Ask for another child's record under this parent's identity. Is the result empty?
The boundary holds on this path
nothing returned, which is the only passing answer
A leak path has been found
what three of the eight scoping checks returned
A test that asks for the parent's own child would have passed in every one of those cases.
FigureThe check that actually tests a permission boundary. In the scoping demonstrator, a reproduction run on a test setup and not a measurement of any client system, three of eight checks against a correctly written policy failed this question. No client outcome figures are published for this engagement.
figures

The eight-check demonstrator is a reproduction run during scoping, with its method stated. It is not a client outcome.

What it produces
Without this discipline

An assistant that answers the question correctly, about the wrong child, with the policy written correctly and the connection pool still holding the previous parent's identity.

This system

A permission boundary that sits below the model: the owner exemption forced off, views evaluated as their caller, identity scoped to each transaction, a least-privileged role, and a test suite whose assertions are about what must never be returned.

the lock sits below the modelforce the policyidentity per transactiontest the negative case
The operating envelope

What it owns, and what it hands to a person.

Handled with confidence
Record access through the assistant
Pooled connections and views
Adversarial identifier substitution
Flagged for review
A query path no test covers
Out of scope by design
Disclosure through the application after a correct retrieval
Elevated-privilege functions and extensions
The honest limit

Eight checks is a demonstrator, not an audit. It showed, three times over, that a correctly written policy is not sufficient on its own, and it does not enumerate every leak path. Functions, triggers and extensions that run with elevated privileges are further surfaces this work does not claim to have covered, and so is disclosure by the application after a correct retrieval. The defaults described here differ between database products and versions, so each one has to be re-verified against the database actually deployed.

What it generalizes to

When an assistant answers from live records, the retrieval work is small and the permission boundary is the engagement. Enforce access below the model. Assume the policy will be evaluated in a context you have not checked, and then check it. Write the tests about what must never come back, because tests about what should come back keep passing when the boundary is broken.

What to take from this

A rule in a model's instructions is a request to the component it is meant to constrain. Put access control in the data layer, below the model.

Row-level security is evaluated in a context of role, identity and query path, and each of those can quietly differ from what the policy's author assumed.

On a pooled connection, identity set for the session can outlive the request. Scope it to the transaction so that a missed setting returns nothing.

Table owners and definer-rights views are commonly exempt from row policies by default. Check which role your application connects as and how your views run.

Test a boundary by asking for what must not come back. A test that a user can see their own data passes when the boundary is broken.

How we engage

You have a system like this one.
Tell us where it stands.

Whether it is failing, not yet built, or about to meet a scale it has never seen, we can tell you what we see.

Start a conversation
mostafa@opulion.dev · Response within 24 hours · By inquiry