Northstar: When Authorization Logic Split Across Two Layers
A postmortem-style writeup from building Northstar — mixed RBAC surfaces, workflow rules in services, and moving notifications off the request path.
Northstar: When Authorization Logic Split Across Two Layers
This is not a generic RBAC tutorial. It is what happened while building Northstar — a service marketplace backend I shipped as public, inspectable code.
Incident
Not a production outage. A design smell I caught during review: authorization was enforced in two different styles.
admin,users, andprovider-responsescontrollers used@UseGuards(JwtAuthGuard, RolesGuard)with@Roles(...).service-requestsrelied onJwtAuthGuardglobally, then checked roles insideServiceRequestsService.updateStatusfor workflow transitions likeIN_REVIEWandACCEPTED.
Both were correct for the rules I wanted. Together they were hard to audit.
Timeline
- Early endpoints — I added
@Roles(ADMIN)on admin routes first. Fast, declarative, easy to grep. - Workflow growth — Service requests needed state-dependent rules: only the customer submits
DRAFT → SUBMITTED, only staff movesSUBMITTED → IN_REVIEW. That logic lived naturally in the service layer next toallowedTransitions. - Review pass — A new endpoint almost copied an inline role check from a controller instead of using the guard pattern. That was the signal: two mental models for the same concern.
Root cause
I treated route capability ("who may call this endpoint at all") and workflow authorization ("who may cause this state transition") as the same problem. They overlap but are not identical.
- Guards excel at coarse role gates.
- State machines excel at transition rules tied to domain state.
Mixing them without documenting the split created duplication risk.
Decision
Keep both layers — but make the boundary explicit:
| Layer | Responsibility | Example |
|---|---|---|
RolesGuard | Route-level role allow lists | @Roles(UserRole.ADMIN) on /admin/* |
| Service layer | State + ownership rules | Customer-only submit; staff-only review transitions |
Document the split in the case study and add negative E2E tests (customer cannot hit staff transitions).
Second fix: async notifications
Related mistake: first notification path ran inline on status change. Submitting a request blocked on email work.
Fix: emit service-request.submitted / completed events → BullMQ email processor with idempotencyKey. The HTTP path returns when the state change is durable; delivery is async.
Known limitation I left documented: EmailProcessor dedupes with an in-memory Set. Fine for a single worker demo; Redis-backed dedupe before horizontal workers.
What I'd do differently
- Write the authorization matrix (role × transition) before the tenth endpoint.
- Add one architecture test that fails if a controller handles
/adminwithoutRolesGuard.
What this demonstrates
- I ship, then tighten — not pretend the first draft was perfect.
- I separate declarative route auth from workflow auth instead of forcing one pattern everywhere.
Full artifacts, rejected alternatives, and a 10× scale path: Northstar case study → Engineering depth.