The industry has settled on how an authorization question gets asked. The OpenID Foundation's Authorization API 1.0 reached Final Specification in January 2026 and standardises the protocol between the point that enforces a decision and the point that makes it, so any compliant enforcement point can call any compliant decision point. What it deliberately leaves open is where the decision point gets its facts, and what an application does when the question is not about one object but about a set.
Per record authorization holds up until someone asks for a list. Checking whether a user can open document 4172 is bounded. The record is in hand, the policy runs against it, and the answer is yes or no. Rendering the first page of a table that same user is allowed to see is a different problem, and most codebases end up solving it twice.
The first attempt is usually to fetch and filter. Read the rows, loop over them, ask the policy engine about each one, keep the survivors. It is correct, it is easy to reason about, and it does not survive contact with a table of any real size. Pagination makes it worse, because you cannot know how many rows to read before you know how many will pass the filter.
The second attempt is to write the WHERE clause by hand. Someone reads the policy, works out that a viewer sees published documents in their own tenant plus anything shared with them, and encodes that in SQL. The query performs. It is also a second copy of the policy, living in a repository the policy author never reviews, and it drifts the first time a rule changes and the query does not. Both approaches rebuild the coupling externalized authorization was meant to remove.
Ask what the user can see, a query plan instead of a per record check
The way out is to change the question. Instead of asking whether this principal may act on this record, ask what has to be true of a record for this principal to act on it. That question has an answer that does not depend on the data, so it can be answered once per request rather than once per row.
Cerbos answers it with PlanResources. The request carries a principal, a resource kind and an action, with no resource instance attached. The engine evaluates the policy as far as it can. Anything resting only on roles or principal attributes resolves immediately. Anything resting on resource attributes cannot, because there is no resource, so it stays behind as an unresolved condition. What comes back is the residue, a tree of the constraints that remain.
The tree is plain JSON. A rule allowing a user to view documents they own reduces to a single comparison, with the principal id already substituted.
{
"kind": "KIND_CONDITIONAL",
"condition": {
"expression": {
"operator": "eq",
"operands": [
{ "variable": "request.resource.attr.ownerId" },
{ "value": "alice" }
]
}
}
}
Nested rules produce nested trees, with and, or and not as interior nodes and comparisons at the leaves. A plan can also come back saying no filter is needed at all, or that nothing is visible and there is no point querying. How partial evaluation gets there is covered in the existing query plan post and in a longer walkthrough of adapter internals.
AuthZEN search endpoints versus a query plan
This is not one engine's idea of a useful extra. Alongside Access Evaluation for a single question and Access Evaluations for a batch, the Authorization API defines Subject, Resource and Action Search at /access/v1/search/, which enumerate the authorized set with opaque token pagination. The batched endpoint carries short circuit options too, execute_all, deny_on_first_deny and permit_on_first_permit, which exist because one call per object stopped being workable some time ago. A standards track document with multi vendor authorship giving the set question its own endpoints is the field agreeing it is a real question rather than an application's workaround.
The difference between a search endpoint and a query plan is worth being precise about, because they are not the same tool. A search endpoint enumerates the authorized set, so the decision point has to know the resource inventory and the caller receives a list to page through. A query plan returns the conditions that describe the set instead, so the caller receives a predicate. That predicate can be pushed into the query the application was already going to run, combined with the caller's own filters, then sorted, counted and paged by the database. When the set is small, or the decision point genuinely owns the inventory, enumeration is simpler. When the data is in your database and the user is on page 400, the predicate is what you want.
NIST's guide to ABAC decomposes an access control mechanism into a decision point, an enforcement point, and a context handler that assembles the attributes a decision needs. The translation described below sits in that third role. It is not deciding and it is not enforcing, it is preparing the inputs and shaping the output so both ends stay simple.
Translating a query plan into UCAST once, not in every service
A tree is not a filter yet. Something has to walk it and emit whatever the data layer speaks, a Prisma predicate, a SQLAlchemy expression, a Mongo query document, raw SQL. That walking code is small, but it is the kind of small that multiplies. Every service that lists anything needs a copy, in whatever language it happens to be written in, kept in step with the operator set the policies use.
Cerbos Synapse moves that step out of the services. A route extension exposes an endpoint of its own, accepts a plan request, calls the PDP, walks the returned tree and hands back the translated form. One such extension converts plans into UCAST, a universal conditions abstract syntax tree that query builders and OPA compatible systems already consume. Comparisons become field nodes carrying a column name, an operator and a value. Logical operators become compound nodes wrapping their children. The unconditional outcomes get distinct encodings, an empty object for allowed and a null for denied, so the three plan kinds survive translation rather than collapsing into a filter that quietly means the wrong thing.
The service then asks for a filter rather than for a decision, and receives something its query builder can consume directly. No conversion code ships inside the service, and a service in a different language does not mean writing the walker again. The extension can be a compiled Go plugin, a WASM module or a Starlark script, because the logic is a pure function over a tree with no state to manage.
One condition tree for Prisma, SQLAlchemy, Trino and vector store filters
UCAST is one target of several. The same tree feeds the reference adapters published for Prisma, Drizzle, Mongoose, SQLAlchemy, Convex and a ChromaDB vector store, and the mapping is mechanical in every case. An operator table, a step that coerces values into the types the target expects, and a rule for turning attribute paths into column or field names.
That last part is where the real decisions sit. request.resource.attr.ownerId has to become owner_id, or whatever the schema calls it, and holding that naming contract in the translation layer means one edit when a column is renamed rather than a search across every endpoint.
The same shape travels past ORMs. A query engine that supports row level predicates takes the conditions directly, which is how row filtering works in the Trino piece in this series. A vector store takes them as a metadata filter alongside the similarity search, and that is where agent traffic lands. A retrieval step asks which documents are relevant to a question, not for a verdict on document 4172, and an agent loop issues that shape of request continuously rather than once per user click. Per object checks do not hold at that rate.
One policy for the detail view and the list view
The policy becomes the only definition of who can see what. A rule saying a reviewer sees documents in review status inside their own tenant is written once, in a resource policy, and both the record check and the listing query derive from it. No second implementation to keep in step, so a policy change cannot land half way.
Listing endpoints get smaller. The endpoint asks for a filter, combines it with the business filters the caller asked for, and runs one query. The row count it reads is the row count it returns, so pagination is correct again and total counts are cheap. Adding a data layer becomes additive too, because a new service on a different database needs a translation rather than a reimplementation of rules that were never in the service to begin with. The same instinct at a different granularity resolves a whole screen of feature permissions in one call.
The plan request also travels the same path as every other decision, so it picks up the same enrichment. A department the caller's token does not carry gets resolved by a data source first, the mechanism described in the piece on treating a PostgreSQL context source as a context source, once at plan time rather than once per row.
Fail closed defaults, CEL that will not translate and index behavior
Closed has to be the default, and the shape of a plan makes that easy to get wrong. The Authorization API is explicit that decisions default to false and that a transport error is never a permit. The same discipline applies here. Always denied and always allowed both arrive with no condition attached, so code that reads an absent filter as no restriction turns the most restrictive outcome into the most permissive one. Handle the three kinds explicitly, and fail closed when the plan cannot be fetched at all.
The information point becomes the constraint. Once attributes are resolved from outside the request, the plan is only as good as the source that supplied them, and caching in that layer is a correctness decision rather than a performance one. A stale department attribute produces a filter that is confidently wrong for every row the query returns, not for one record.
Conditions can only reference attributes the data layer can filter on. A policy that tests something with no column behind it produces a plan the database cannot answer, so policy and schema have to agree, and changing a rule can mean changing a table.
Not every expression translates. Cerbos conditions are CEL, and CEL includes lambdas, arithmetic and set operations with no equivalent in a filter language. A conversion layer has to fail loudly on those rather than approximate them, and the practical answer is to restructure the condition or precompute the value as an attribute before the check runs. Being told a plan cannot be converted beats receiving a filter that is subtly wider than the policy.
Index behaviour is not guaranteed either. A plan is a correct filter, not a fast one. Deeply nested or clauses, negations and pattern matches all translate cleanly and can then defeat the index the table was built around. Reading the database's own execution plan against production data shapes is part of adopting this, not an optimization for later.
Closing
The useful framing is not that filtering moved into the database. It is that the application stopped holding a private opinion about who can see what. It asks a question, receives a condition, applies it to a query. The condition came from the same policy that governs the single record check, so the list view and the detail view cannot disagree.
The standards work has converged on how that question is carried and has made the set question first class. Turning the answer into something a specific data layer understands is still an implementation choice, and a better one when it is made once. Put the translation next to the decision and a listing endpoint becomes what it should have been, a query with one more predicate on it. The extension model that hosts that translation is covered in the Synapse documentation.
Try Cerbos to see how this works in practice, or book a call to talk through your architecture with the team.
Go deeper:
- Building a scalable authorization system (eBook) for the architecture decisions behind a listing path that stays correct as the data grows
- How to adopt externalized authorization (eBook) for a structured route to moving access rules out of application code
FAQ
Tagged in




