Three questions wearing one word
PRAMAAN keeps many litigation firms' privileged files in one system. Those firms are sometimes each other's adversaries, their records sitting in the same tables. So "access control" has to be exact here, because the phrase hides three questions that barely overlap: who are you, which records may you touch, and what may you do. The label is the trap. It tempts you to build one mechanism and believe it answered all three.
It doesn't. In most software a slip here is a bug: a button shown by mistake, a ticket filed. For us a single wrong row is another firm's privileged material in front of a party that may be its courtroom adversary, a confidentiality breach, not a severity-4. And the three questions fail in three different ways:
- Authentication fails loudly, at one door. A stranger gets in, and someone notices fast.
- Record authorization fails silently. A legitimate user is handed rows that were never theirs, and nothing looks wrong.
- Capability fails when the right person takes an action they should never have been offered.
Three failure shapes, no shared blast radius, no shared fix. So we answer each in a different place.
How most systems answer them
Identity is settled: a signed token, verified at the edge, and nobody serious reinvents it. The live argument is the other two, and the modern default is to centralize: lift authorization into one service that owns policy, whether a relationship graph like Zanzibar, a policy engine like OPA or Cedar, or an in-house permissions DSL like Figma's. The reasons are good, and worth stating plainly:
- one place to read the rules, change them, and test them;
- one consistent answer whether the request comes from the web app, a job, or an internal tool;
- a decision you can log and audit as a first-class event.
We didn't dismiss any of that. We diverged on exactly two of these questions, and only for reason.
Authentication stays at the perimeter
Who are you is answered once, at the edge, before a request reaches any business logic. Verify the token, reject what you can't, and let everything downstream trust a known identity. It carries no permissions. It's just a verified claim about a person and the firm they belong to:
{
"sub": "user_9f2c4a",
"firm_id": "firm_A",
"email": "asha@firm-a.law",
"auth_method": "oidc",
"verified_at": "2026-06-08T11:42:09Z",
"exp": 1781000000
}
We keep this half boring on purpose: one well-guarded door beats identity checks scattered through a hundred handlers.
Authorization at the data layer: push it down, not up
Which records is the dangerous question, so we answer it as close to the records as possible: inside the database. This cuts against the centralize-up default deliberately. A central authorization service still has to be called, and the failure we actually fear is a forgotten call.
Picture three firms on PRAMAAN: Counsel A and Counsel B, opposing each other in the same arbitration, and Counsel C, unrelated. A developer ships a new "export matters" endpoint and forgets the tenant filter, the kind of one-line miss that sails through review on a busy Friday. If the filter lives only in application code, Counsel B's export quietly returns one of Counsel A's privileged drafts. Nothing errors. Nobody knows. That is the breach.
Now move the boundary into the database, with Postgres row-level security. The same forgotten filter returns nothing across the firm wall, because the wall was never the developer's job to remember. The mechanism is ordinary: a junior can write an RLS policy in an afternoon. The decision is where authority lives: in the layer that owns the data, not in the code that happens to be asking. Centralizing up gives you a tidy policy you can forget to consult; pushing down gives you a boundary you can't.
Capability at the action: explicit grants, not inferred roles
Seeing a matter isn't permission to act on it, to export it, delete its chronology, or sign it off. That's the product surface, and it gets its own answer: capabilities, explicit grants attached to a membership, checked at the moment of the action. What a user may do is a separate object from who they are:
{
"user": "user_9f2c4a",
"firm_id": "firm_A",
"tenant_scope": "firm_A",
"capabilities": [
"matter.read",
"matter.export",
"chronology.edit",
"billing.view"
]
}
It's the oldest idea in the book, an access control list. We use roles to assign grants, but we check the explicit grant, because an auditor asking "could this user have done that?" should get a fact read off a list, not an argument computed through a role graph. And we keep this separate from row authorization on purpose: row visibility is the tenant wall, capability is what the product lets you do. Fuse them and you get the oldest bug there is: if you can see it, you can wreck it.
You might ask why not go fully attribute-based (ABAC), with rules like allow export if user.clearance >= matter.sensitivity and the request is in-region. We use exactly that shape where context is the whole point: data-residency and clearance tiers. But we don't make it the entire model, for the same reason we prefer explicit grants: an ABAC decision is computed, not stored. The auditor's question, could this user have done that?, then becomes a rule you have to re-evaluate against a snapshot of every attribute, instead of a fact you can read off a list. We reach for attributes where the rule genuinely is the policy, and for explicit grants everywhere a fact is cheaper to defend than an argument.
| The question | Enforced where | Mechanism | How it fails |
|---|---|---|---|
| Authentication | The perimeter / edge | A verified token, checked once | Loudly: a stranger gets in, at one door |
| Authorization | The data layer | Enforced by the database (RLS) | Silently: a legit user gets the wrong rows |
| Capability | The action | An explicit grant on the membership (ACL) | The right person takes an action they shouldn't |
What it costs
None of this is free:
- migration discipline and real multi-tenant tests;
- a hard "no bypass roles in normal paths" rule you actually enforce;
- every new action declaring the grant it needs, with no leaning on "the admin role probably covers it."
It's more work up front than a stack of if statements. For many products that trade isn't worth it. For one holding privileged legal records, it's the floor.
Why we decided this on day one
Not from a postmortem, but before the first record existed. We sell into regulated environments, and the other way to learn this lesson is in production, with a real client's confidential matter. That's not a story we ever wanted to tell. It's also, conveniently, the simplest design: each question answered once, in the one place it can't quietly fail. Authentication at the perimeter. Authorization at the data. Capability at the action.
