Six swimlane diagrams covering every way a decision gets made in DWPlatform: a person clicking a button, an administrator handing out access, a colleague covering a delegation, an engineer breaking glass in an incident, and one service calling another. They all run through the same evaluation, because the moment one of them gets a shortcut, that shortcut is the security model.
These are not aspirations written on a wiki. Each one is enforced by code, and each one exists because its absence has a known, specific failure mode.
Roles are bundles of permissions, nothing more. Nothing branches on a role name. Grepping for one outside the role-definition module returns zero.
Every protected operation calls the same function. Ad-hoc checks scattered through controllers are how two endpoints that should agree quietly stop agreeing.
The screen receives a permission list purely to decide what to draw. Assume the client is hostile: it can be edited, and eventually it will be.
Absence of a grant is a refusal. There is no implicit inheritance — a scope that should cover both a manager and their reports lists both, explicitly.
There is no such thing as salary.read. There is only salary.read over a defined set
of people. A permission with no scope is a permission over everybody.
Why rule 5 is the one that bites. A permission list is easy to reason about and easy to get wrong. In an
earlier build, an administrator correctly held payslip.read at self scope — and the payroll
list still returned every row in the tenant, because the gate could only ask “do you hold this at all?”
An attribute has no resource, so it cannot ask “over whom?”. That question has to be answered inside the
query, which is why every list, search, report and export composes a scope filter into its SQL rather than
filtering the rows after fetching them. Filtering afterwards still leaks the row count and the pagination total,
which is often the sensitive part.
Authorization lives in one service. Every other module asks it, briefly caches the answer, and re-asks when a version stamp moves. Nothing about authorization is baked into the login token any more.
This is the whole model. Everything below is a variation on it.
Read the refusal branches, not the happy path. Denials beat grants, so an explicit “this manager must not see compensation during the review cycle” cannot be undone by someone helpfully adding a role. Validity dates are evaluated on every check rather than at login, so a grant that expires at noon stops working at noon, not at next sign-in. And the refusal is recorded: repeated denials are the clearest signal that either a permission is misconfigured or somebody is trying doors.
You cannot grant yourself something you do
not already hold. This is refused before the conflict check even runs, which is why self-escalation returns
403 rather than 409 — both are refusals, and the order is deliberate.
The pairs that must never meet in one person — preparing a payroll run and releasing it, changing a bank account and releasing payment, proposing and approving the same raise — live in a table, checked when access is granted and again when it is used. A rule enforced only at grant time is a rule that lapses the first time data changes underneath it.
Three properties make delegation safe rather than a second identity: it can never exceed what the delegator holds (re-checked at use, because they may have lost it since), it must expire, and it is never transitive — a delegate cannot re-delegate. Every action reads “X on behalf of Y” in both the audit log and the visible approval trail. Recording it as Y would be forgery, however convenient the screen looks.
This is the diagram that came from a real, silent failure. HR posted org-chart updates to Identity using
a token whose subject named nobody. The permission check did the correct thing and refused — and the client
logged a warning and carried on. Nothing broke visibly. But the org chart is what self,
direct_reports, reporting_chain and department all resolve through, so an
org chart drifting behind the employment record means access quietly computed from last month's reporting lines.
The fix is not an exemption for “trusted callers”. That is ambient authority, and an exemption is never scoped and never audited. A service is now a principal in its own right: it holds grants, in a named tenant, evaluated by the same code as a human, and its actions are attributed to it in the audit log — never to a person who was not there.
Manager access is derived from the org chart at the moment of asking, never stored as a grant. A transfer therefore needs no cleanup task and no offboarding checklist item: the old manager loses visibility because they stopped being the manager, not because somebody remembered to revoke something. The permissions that get forgotten are the ones that had to be remembered.
Scopes do not nest. If a bundle should cover the holder and their reports, it lists both — explicit beats clever, and every implicit inheritance rule is a rule somebody will misread.
| Scope | Covers | Resolved from |
|---|---|---|
| self | Only the actor's own record | The person bound to the account |
| direct_reports | People reporting straight to them | Org chart, live |
| reporting_chain | Everyone beneath them, to any depth | Org chart, live |
| department | A department and its sub-departments | Tree-path prefix |
| legal_entity | One company inside the tenant | Company on the employment record |
| location | One branch or site | Branch on the employment record |
| all | The whole tenant — never wider | Row-level security is still the outer wall |
An honest scorecard is more useful than a confident one. The comparison below is against the access models of Workday and SAP SuccessFactors (HCM), Microsoft 365 and Google Workspace (collaboration), and ServiceNow (multi-tenant operations). Where we fall short, it says so.
| Practice | What the best do | DWPlatform |
|---|---|---|
| Role/permission split | Roles are bundles; nothing evaluates a role name | Meets. 90 permissions, 17 bundles, zero role-name branches in code |
| Target population | SuccessFactors pairs a permission role with a target population | Meets. Seven scopes, org-derived, composed into the query rather than applied after |
| Relationship access | Derived from the org chart at evaluation time | Meets. Never stored as a grant, so a transfer needs no cleanup |
| Field-level security | Record access does not imply field access | Meets, and slightly beyond. Pay can be coarsened to a band rather than hidden, so a manager can plan without reading salaries. Masked values are absent or explicitly redacted — never a plausible fake |
| Separation of duties | Conflict pairs refused at grant and at call time | Meets, with one residual. Seeded as data, refused at grant time, and re-checked on every authorization call — which is what catches a conflict that appeared after the grant: a role edit, a delegation handing over the other half, a future-dated grant coming into force, or a new pair added to the table retroactively. Refusal names the pair rather than returning a generic 403, and sits above break-glass: an emergency is for an unreachable approver, not for completing both halves of a separated duty. Self-approval stays hard-coded impossible. The residual: a pair marked same-record-only can only be judged when the request names the record, so a write endpoint that gates on the permission alone, without saying which record it is about to change, gets no per-record enforcement |
| Delegation | Time-boxed, non-transitive, recorded as on-behalf-of | Meets. Subset re-validated at use, not only at creation |
| Service identity | Google Cloud service accounts, Microsoft Graph app-only permissions | Meets. Services are principals holding tenant-scoped grants, evaluated by the same engine, audited as themselves |
| Mid-session revocation | Short-lived answers, refreshed on change | Meets. The token carries no authorization; a version stamp forces a refetch within seconds |
| Audit of denials | Denials logged, not just successes | Meets. Append-only, enforced by database permission rather than by convention |
| Break-glass | Justified, time-boxed, alerted, flagged | Meets. 60 minutes, substantial justification required |
| Access certification | Periodic recertification campaigns — managers re-attest who holds what | Gap. Grants are visible and revocable, but nothing schedules a review. Entitlement creep is the normal failure mode of a system this expressive |
| Policy simulation | “Explain this decision” and “what would change if…” tooling | Partial. A single decision can be checked and a scope resolved, but there is no what-if against a proposed grant |
| Customer-managed keys / residency | Per-tenant encryption keys, regional pinning | Gap. Not applicable yet at this scale, but it is what enterprise procurement asks for on day one |
| ABAC on record attributes | Conditions on record state, not just relationship | Partial. Grants carry a constraint, and an unparseable one narrows to nothing rather than widening — but it is not a general condition language |
It would have closed the service-to-service gap in an afternoon. It also would have created an unscoped, unaudited path that every future integration would reach for. A service holding real grants costs more to set up and cannot be quietly widened.
When the engine is unreachable, callers hold nothing rather than their last known permissions. An outage therefore refuses work instead of opening it. Serving a cached set on failure would reopen exactly the revocation hole this design exists to close.
The catalogue may be added to, never renamed. A rename is invisible at compile time and silently turns a grant into a no-op — which reads as “that person lost access for no reason” months later.
Where a role name is still used — a chief executive's queue reaching their secretary, a post addressed to a desk — it names who to route to, never what they may do. Membership and authority are looked up separately and deliberately.