Skip to main content
Jabal Al Noor Pharmacy Digital Commerce Platform
Case StudyBackend

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:

WithheldReason
prescriptions:read_all / reviewUAE health data, and the endpoint has no store scoping, so a branch login would see every patient prescription
users:create / groups:managePrivilege escalation — users:create can mint an admin, and nothing restricts which permissions a group may contain
settings:manage, content:*, dsr:manage, audit:viewOwner-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:

  1. Diff before invalidating. It reads the group before the write, so a plain rename, or a resubmit of identical permission IDs, invalidates nothing.
  2. 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 DEL calls.
  3. 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.

Six branches, one order pool, no privilege escalation | Tuninbits