Six branches, one order pool, no privilege escalation
Group-based permissions for branch staff, and the 14-minute cache window that made a revocation dangerous.
- Client
- Jabal Al Noor Pharmacy
- Industry
- Pharmacy retail
- Outcome
- 43 permissions, 12 for branch staff
The problem
Branch staff need admin access scoped to their job. But the schema has no store scoping on orders, prescriptions or customers — all branches share one global pool.
So per-branch logins are for attribution, not isolation. Pretending otherwise would be security theatre.
Permissions from groups, not roles
Permissions moved off roles and onto groups for the one role that needed it. The staff role resolves to a deliberately empty permission array; staff permissions come entirely from group membership, resolved per user. A staff account in no groups has zero permissions. The seed carries an explicit do-not-repopulate warning on that array.
A pre-built Store Staff group ships with 12 of the 43 permissions. The seed documents what is withheld and why:
| Withheld | Reason |
|---|---|
prescriptions:read_all / review | UAE health data, and the endpoint has no store scoping, so a branch login would see every patient prescription |
users:create / groups:manage | Privilege escalation — users:create can mint an admin, and nothing restricts which permissions a group may contain |
settings:manage, content:*, dsr:manage, audit:view | Owner-level surfaces |
The seed also warns that branch accounts must be role staff and never admin, because the permissions guard returns true unconditionally for admins. An admin bypasses every check by design, which makes the role assignment itself the security boundary.
The cache-coherence problem this created
Resolved permissions are cached in Redis at auth:user:{userId} for 14 minutes, to avoid two database queries per authenticated request. So a permission revocation could stay live for 14 minutes — unacceptable when the revocation *is* the security response.
The group update path handles it with three refinements:
- Diff before invalidating. It reads the group before the write, so a plain rename, or a resubmit of identical permission IDs, invalidates nothing.
- One round trip, not one per member. When the set genuinely changed, it invalidates every member cache in a single Redis pipeline rather than a loop of awaited
DELcalls. - Skip the empty case. At zero members it does not build a pipeline at all, since an empty pipeline is a wasted round trip.
Making the dangerous state unreachable
Group deletion needs no invalidation at all, because it returns a 409 if the group has any members — checked first, before any delete is attempted. Better to make the dangerous state unreachable than to ask the caller to remember.