Skip to main content

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 / ActionisPublic: true orgisPublic: 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 after e so that it only matches paths inside the e/ 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-intl middleware

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. getOrgMembership is still called so that admins receive their real role.
  • isPublic: false → calls getOrgMembership(shortName). If null (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:

RouteActionRisk
GET /api/admin/cache-invalidate?shortName=XBusts Next.js cache for a tenantLow — no data destroyed
GET /api/admin/recreate-demoWipes and recreates the demo orgHigh — 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:

  1. Calls auth() to get userId. Returns null if not authenticated.
  2. Calls getOrganizationByShortNameRO(shortName) (cached) to retrieve clerkOrganizationId.
  3. Calls Clerk's backend API (clerkClient().organizations.getOrganizationMembershipList) to fetch all members of the Clerk org.
  4. Finds the entry for the current userId. Returns null if not found.
  5. Returns { userId, role } where role is '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 roleDescription
org:adminFull 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:memberCan 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:

EventLevelLocation
Non-member access attemptwarngetOrgMembership in requireOrgAccess.js
Clerk membership lookup errorerrorgetOrgMembership in requireOrgAccess.js
Private org access by non-memberinfo(app)/layout.jsx
Demo org recreation triggeredwarnrecreate-demo/route.js

File Map

FileRole
src/proxy.jsNext.js middleware — enforces auth on editor routes
src/lib/auth/requireOrgAccess.jsgetOrgMembership + requireOrgAdmin utilities
src/app/[locale]/(app)/layout.jsxLayout — isPublic-aware org membership check
src/lib/hooks/OrganizationContext.jsxMembershipContext + useMembership() hook
src/services/organizationService.jsrunMutatorOnOrgRW — write guard choke point
src/app/api/admin/cache-invalidate/route.jsAdmin route — shared secret guard
src/app/api/admin/recreate-demo/route.jsAdmin route — shared secret guard + audit log