Authentication, Authorization & Audit (AAA) Architecture
Overview
ROPA 2.0 uses a multi-layer authorization model built on top of Clerk authentication. Authorization is enforced at four distinct layers to provide defence in depth: the proxy (middleware), the layout, the service layer, and the admin API.
Multi-tenancy is subdomain-based: each tenant is identified by a shortName derived from the request hostname. Clerk organizations map 1-to-1 to tenants via Organization.clerkOrganizationId in MongoDB.
Authentication
Authentication is delegated entirely to Clerk. See clerk.md for the full integration details.
Entry point: src/proxy.js — Next.js middleware that wraps every request with clerkMiddleware. This makes the Clerk session available to all server components and API routes via auth() from @clerk/nextjs/server.
Authorization
Access Matrix
| Route / Action | isPublic: true org | isPublic: false org |
|---|---|---|
View routes (/ropa, /about, /docs, …) | ✅ Anyone | ❌ Org members only |
Editor routes (/e/**, /orgchange) | ❌ Org members only | ❌ Org members only |
| Server actions (all writes) | ❌ Org members only | ❌ Org members only |
Admin API (/api/admin/**) | ❌ Shared secret | ❌ Shared secret |
isPublic only controls view access. Write access always requires org membership regardless of visibility.
Layer 1 — Proxy (Route-level, src/proxy.js)
The Next.js middleware file. Uses Clerk's createRouteMatcher to enforce authentication on routes that are always private regardless of isPublic:
/:locale/e/(.*) → all editor sub-routes (activity, OU, org settings, tmp)
/:locale/orgchange(.*) → organization change flow
Pattern note: The
e/pattern requires a literal slash aftereso that it only matches paths inside thee/directory. Without the slash,/:locale/e(.*)would also match/:locale/event/…(public incident pages), causing those to be incorrectly protected.
Unauthenticated users hitting these routes are redirected to Clerk's hosted sign-in page. All other routes pass through to the layout for isPublic-aware access control.
The middleware also handles:
- Unsupported locale redirects (→
/en/event/language) - i18n routing via
next-intlmiddleware
Layer 2 — Layout (Org membership, src/app/[locale]/(app)/layout.jsx)
The server-side layout runs on every (app) route render. After fetching the tenant org from MongoDB it enforces:
isPublic: true→ no membership check; anyone can view.getOrgMembershipis still called so that admins receive their real role.isPublic: false→ callsgetOrgMembership(shortName). Ifnull(unauthenticated or not a member), server-redirects to/${locale}/event/org-not-available.
The resolved membership ({ userId, role } or null) is passed into OrganizationProvider and is available to all child components via the useMembership() hook.
import { useMembership } from "@/lib/hooks"
const membership = useMembership()
// membership.role === 'org:admin' | 'org:member'
// membership.userId === '<clerkUserId>' | 'unauthenticated'
useMembership() never returns null for public orgs. When no explicit membership is found (unauthenticated visitor or non-member), it falls back to { userId: 'unauthenticated', role: 'org:member' }. This makes useMembership() the single source of truth for client-side authorization: all UI components can check role without null-guarding.
Layer 3 — Service layer (Write guard, src/services/organizationService.js)
All mutations — 20+ operations across entities, activities, OUs, contracts, locales, and org settings — flow through a single helper: runMutatorOnOrgRW. The membership check is applied there once, covering every write operation without per-function changes:
const membership = await getOrgMembership(shortName);
if (!membership) return fail("Unauthorized");
This guard fires even for public orgs — isPublic: true has no effect on write access. An unauthorized write always returns { isError: true, message: "Unauthorized" }.
Layer 4 — Admin API (Shared secret, /api/admin/**)
Two internal operational routes exist that are not user-facing:
| Route | Action | Risk |
|---|---|---|
GET /api/admin/cache-invalidate?shortName=X | Busts Next.js cache for a tenant | Low — no data destroyed |
GET /api/admin/recreate-demo | Wipes and recreates the demo org | High — destructive |
Both require the x-admin-secret header to match the ADMIN_SECRET environment variable. Clerk auth is not used here because these routes are called by automated scripts and CI pipelines — not by user sessions.
recreate-demo additionally emits a logger.warn on every invocation to maintain an audit trail (see Audit below).
Core Utility — getOrgMembership (src/lib/auth/requireOrgAccess.js)
Used by both Layer 2 and Layer 3:
import { getOrgMembership, requireOrgAdmin } from "@/lib/auth/requireOrgAccess"
// Returns { userId, role } | null
const membership = await getOrgMembership(shortName)
// Returns { userId, role } only if role === 'org:admin', null otherwise
const admin = await requireOrgAdmin(shortName)
How it works:
- Calls
auth()to getuserId. Returnsnullif not authenticated. - Calls
getOrganizationByShortNameRO(shortName)(cached) to retrieveclerkOrganizationId. - Calls Clerk's backend API (
clerkClient().organizations.getOrganizationMembershipList) to fetch all members of the Clerk org. - Finds the entry for the current
userId. Returnsnullif not found. - Returns
{ userId, role }whereroleis'org:admin'or'org:member'.
auth().orgId from Clerk's session is not used for authorization. It reflects the client-side "active org" set by TenantRedirector, which may not have run yet on first server render. Membership is always verified via the Clerk backend API.
Roles
| Clerk role | Description |
|---|---|
org:admin | Full access: can view, edit, and manage org settings. Clerk is the sole source of truth for roles — there is no parallel role store in MongoDB. |
org:member | Can view and edit all ROPA data. Cannot manage users or org settings. |
Role enforcement beyond the binary member/non-member check (e.g. restricting specific actions to org:admin) uses requireOrgAdmin(shortName) server-side or useMembership().role === 'org:admin' in UI components.
Audit
Structured logging uses Pino via src/lib/utils/logger. Authorization events are logged at appropriate levels:
| Event | Level | Location |
|---|---|---|
| Non-member access attempt | warn | getOrgMembership in requireOrgAccess.js |
| Clerk membership lookup error | error | getOrgMembership in requireOrgAccess.js |
| Private org access by non-member | info | (app)/layout.jsx |
| Demo org recreation triggered | warn | recreate-demo/route.js |
File Map
| File | Role |
|---|---|
src/proxy.js | Next.js middleware — enforces auth on editor routes |
src/lib/auth/requireOrgAccess.js | getOrgMembership + requireOrgAdmin utilities |
src/app/[locale]/(app)/layout.jsx | Layout — isPublic-aware org membership check |
src/lib/hooks/OrganizationContext.jsx | MembershipContext + useMembership() hook |
src/services/organizationService.js | runMutatorOnOrgRW — write guard choke point |
src/app/api/admin/cache-invalidate/route.js | Admin route — shared secret guard |
src/app/api/admin/recreate-demo/route.js | Admin route — shared secret guard + audit log |