NestJSRBACArchitecturePostmortemNorthstar

Northstar: When Authorization Logic Split Across Two Layers

3 min read
By Arman Hazrati

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, and provider-responses controllers used @UseGuards(JwtAuthGuard, RolesGuard) with @Roles(...).
  • service-requests relied on JwtAuthGuard globally, then checked roles inside ServiceRequestsService.updateStatus for workflow transitions like IN_REVIEW and ACCEPTED.

Both were correct for the rules I wanted. Together they were hard to audit.

Timeline

  1. Early endpoints — I added @Roles(ADMIN) on admin routes first. Fast, declarative, easy to grep.
  2. Workflow growth — Service requests needed state-dependent rules: only the customer submits DRAFT → SUBMITTED, only staff moves SUBMITTED → IN_REVIEW. That logic lived naturally in the service layer next to allowedTransitions.
  3. 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:

LayerResponsibilityExample
RolesGuardRoute-level role allow lists@Roles(UserRole.ADMIN) on /admin/*
Service layerState + ownership rulesCustomer-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 /admin without RolesGuard.

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.

ShareLinkedInX