Series -- From Ad-Hoc SQL to a Semantic Layer: 0: Overview · 1: Ten Answers · 2: Why Not Indexes · 3: Metrics Model · 4: View Pipeline · 5: Schema Design · 6: Authorization · 7: Migration Sequence · 8: Stakeholder Alignment
Part 1 of this series described a call where two reports claimed to show
billable hours for the same firm over the same period and disagreed, because
each report had quietly decided for itself what "billable" meant. There was a
second version of that same problem that nobody put on a call, because it
never surfaced as a visible contradiction. It surfaced as a compliance
question instead.
Each of the 30-plus legacy reports was also hand-rolling its own permission
checks against raw tables. One report decided for itself whether the person
running it was allowed to see a firm's trust balance. Another report, pulling
from the same underlying data, made its own separate decision. Thirty reports
meant 30 different answers to "who can see this data," scattered across 30
different SQL queries, with no single place that said what the rule actually
was. For a legal SaaS handling trust accounting and payment data, that
fragmentation was not just a maintenance burden. It was a compliance surface.
Every one of those 30 decisions was a place where the rule could be wrong, and
no way to audit whether it was.
When I designed the unified analytics platform, authorization was not a
feature I added to the GraphQL API after the fact. It was the constraint that
decided where the API layer had to sit at all.
Three places the check could live
There were three honest candidates for where authorization could be enforced,
and picking the wrong one was easy to do because two of the three look
reasonable until you push on them.
The first was the controller layer: Rails, before the GraphQL resolver ever
runs. This is the instinct most Rails engineers reach for first, because
that is where authorization usually lives. The problem is that it does not
actually centralize anything. Each report route still has to know which
fields it is about to expose and gate each one. You have moved the check
upstream of the query, but you still need a check per route per field. Thirty
reports still means thirty places that each have to remember the rule. I would
have rebuilt the exact fragmentation I was trying to eliminate, just one layer
higher up the stack.
The second was the view layer: the shared React report framework deciding
what to render. A report component could check whether the current user holds
view_trust_accounting and simply not draw the trust balance column if they
do not. This looks clean, and for a lot of applications it would be fine. It
was not fine here, and the reason is the distinction that ends up being the
whole point of this post: by the time the React framework decides not to
render a field, the data has already been fetched. The GraphQL query ran, the
resolver returned the trust balance, it traveled over the wire, and it is
sitting in the browser's memory. The UI is choosing not to paint it. For
IOLTA-governed trust data and PCI-relevant payment fields, withholding the
field in the UI is not the same thing as withholding the data. The data left
the server. That is the event that matters, and the view layer happens after
it.
That leaves the API layer: the GraphQL field itself. This is the only boundary
in the entire stack where declining to include a field in the response is the
same act as declining to send the data. If the resolver does not return the
trust balance, the trust balance does not enter the query result, does not
cross the network, and never reaches a browser that could be inspected. There
is no gap between "the user cannot see this" and "the data did not move."
That property is what settled it. Not convenience, not consolidation for its
own sake. The GraphQL field is the only place where "who can see this field"
and "does this data leave the server" are the same decision.
Two layers that compose without merging
The implementation splits authorization into two boundaries that stack rather
than blend into each other.
The first boundary is firm isolation, and it lives in the query scope layer. A
shared BaseResolver sits under every resolver in the schema, and before any
field logic runs, it scopes the query to current_firm. MyCase is
multi-tenant with law firms as the tenant, so this is the foundational rule:
no query, from any report, ever returns data belonging to a firm other than
the one making the request. Because it lives in the base resolver rather than
in individual resolvers, there is no report and no field that can opt out of
it, forget it, or get it subtly wrong. Firm isolation is not something a
resolver remembers to do. It is the ground every resolver stands on.
The second boundary is field-level access, and it lives in per-type Pundit
policy objects. A trust analytics type has a policy that checks the
view_trust_accounting permission. A financial analytics type has its own
policy, with stricter rules on the PCI-relevant fields, because payment data
carries obligations that a case-count metric does not. The BaseResolver
invokes the appropriate Pundit policy for each type. The authorization logic
lives in the policy object, not inline in the resolver, which means the rule
for a given type is written in exactly one file and read from exactly one
place.
These two boundaries compose without merging. Firm isolation runs first, at
the query scope, and establishes the tenant. Then, inside that already-scoped
query, the field-level Pundit policies run. A user has to clear firm context
before any field-level check is even asked, and clearing firm context tells
you nothing about whether they can see trust balances. The BaseResolver
guarantees the first; the policies guarantee the second. Keeping them separate
is deliberate: firm isolation is a tenancy invariant that must never have an
exception, while field-level access is a permission model that legitimately
varies by field and by domain. Collapsing them into one check would have
meant re-deriving the tenancy invariant every time I touched a field rule.
Why IOLTA and PCI drove the design
It would be easy to present field-level authorization as a nice-to-have that
happened to also satisfy compliance. That reverses the actual causality.
Compliance is why the model looks the way it does.
IOLTA governs how law firms hold client trust funds, and access to trust
account data has to be controlled in a way that can be audited. PCI compliance
for payment-adjacent fields carries a parallel requirement: you have to be
able to demonstrate that cardholder-adjacent data is gated at the data layer,
not merely hidden in the interface. Both of these are questions an auditor can
ask, and both of them demand a specific kind of answer. Not "we filter it in
the UI." Not "each report handles it." The answer that satisfies the question
is "this field is protected by this Pundit policy, and here is the policy
definition."
That answer is only possible because the checks are consolidated. Thirty
reports each making their own access decision against raw tables cannot
produce an audit trail, because there is no single artifact to point at. The
field-level model gives both IOLTA and PCI a concrete, inspectable boundary: a
named policy object, attached to a named type, checking a named permission.
The compliance story is a byproduct of putting the rule in one place, and
putting the rule in one place was only worth doing at the layer where the rule
also controls the data.
The payoff for every report that came after
The operational return showed up in reports I never touched. As described in
Part 5, new reports get composed from an L7 report-definition config rather
than written as fresh SQL. Because authorization lives in the schema and the
policy layer, a new report defined through that config inherits the entire
authorization model automatically. Firm isolation, trust-accounting gating,
payment-field rules: all of it is already there, underneath the field the new
report is consuming.
The important part is what a report author cannot do. They cannot forget to
add a permission check, because the check was never theirs to add. It is not
in the report config at all. It lives one layer down, in the schema, attached
to the type. A new report is a new arrangement of fields that are already
governed. There is no path through the config where a report author writes an
access rule, which means there is no path where they write it wrong.
This is the difference between "authorization-aware" as an adjective bolted
onto a finished GraphQL API and authorization-aware as a property the schema
was designed to have from the start. I did not build the analytics API and
then add access control. I put field-level access in the schema as a
first-class constraint, and that constraint is what made it safe to open the
same API to four separate report domains without each one auditing its own
access logic. The reports did not have to be trusted to enforce the rules.
The layer they query does it for them, once, in the only place where getting
it right and keeping the data on the server are the same thing.


Top comments (0)