CNBS

RBAC & permissions

RBAC & permissions

Last updated 5/24/2026

CNBS — RBAC & Permissions Documentation

Document ID: TECH-SEC-001
Version: v0.8 (PRD lifecycle: Beta)
System: Cannabis Business System (CNBS) — midwestco/cnbs
Auth Provider: Clerk
Database: Supabase (PostgreSQL)
Framework: Next.js 15 (App Router)


Table of Contents

  1. Overview & Security Model
  2. Role Hierarchy
  3. Permission Matrix
  4. Protected Route Inventory
  5. Clerk Integration Architecture
  6. Middleware Configuration
  7. Server-Side Auth Patterns
  8. Client-Side Auth Guards
  9. Row-Level Security (RLS)
  10. Organization & Multi-Tenancy Model
  11. Audit Logging
  12. Business Rules Register
  13. Security Test Coverage

1. Overview & Security Model

CNBS implements a multi-tenant, organization-scoped RBAC model enforced at three layers:

LayerMechanismLocation
Edge / NetworkClerk middleware route matchingsrc/middleware.ts
APIauth() server-side checks + role validationsrc/app/api/**/route.ts
DatabaseSupabase Row-Level Security (RLS) policiesPostgreSQL database/ migrations

Every authenticated request carries a Clerk JWT. That JWT contains the user's organization membership, role within that organization, and custom metadata fields injected at sign-in. API routes validate these claims before executing any business logic. Supabase RLS policies provide a final backstop, ensuring data cannot be accessed even if an API layer check is bypassed.

This three-layer defense is mandatory given CNBS's cannabis regulatory context: state-mandated audit trails, age-verification requirements, and METRC integration all require that access decisions be attributable to a specific authenticated identity.


2. Role Hierarchy

2.1 Clerk Organization Roles

CNBS uses Clerk Organizations as the primary multi-tenancy boundary. Each dispensary (or dispensary group) maps to one Clerk Organization. Users receive a role within that organization.

CNBS Role Hierarchy
════════════════════════════════════════════════════════

  super_admin  (Platform-level; Midwest Co. internal only)
       │
       ▼
  org:admin    (Dispensary owner / operator)
       │
       ├──▶  org:manager    (Store manager / compliance officer)
       │          │
       │          ├──▶  org:budtender   (POS-facing sales associate)
       │          │
       │          └──▶  org:viewer      (Read-only: analytics, reports)
       │
       └──▶  org:associate  (Limited POS access; no admin functions)

  (external)   customer      (Storefront / e-commerce; NOT org member)

2.2 Role Definitions

Role IDClerk MappingDescriptionSession Scope
super_adminpublicMetadata.role = "super_admin"Platform operator. Full access across all organizations.Cross-org
org:adminClerk Organization role adminDispensary owner. Controls org settings, users, billing, compliance config.Single org
org:managerClerk Organization role managerStore-level manager. Manages inventory, staff schedules, compliance reports. Cannot modify org billing or delete org.Single org
org:budtenderClerk Organization role budtenderPOS operator. Processes transactions, verifies IDs, manages cart. Cannot access analytics or admin.Single org
org:associateClerk Organization role associateFloor associate. View-only on POS catalog; limited transaction creation.Single org
org:viewerClerk Organization role viewerReporting/analytics read-only. No write access.Single org
customerClerk userId in publicMetadata.userType = "customer"Storefront shopper. No org membership. Accesses only public-facing and self-service routes.N/A (no org)

2.3 Role Inheritance

Roles are additive upward. A user with org:manager inherits all permissions of org:budtender and org:viewer. org:admin inherits all permissions of all org-scoped roles. super_admin bypasses all org-scoped checks.


3. Permission Matrix

The following matrix documents every permission dimension discovered across API routes, documented auth checks (src/app/api/auth/check-role/route.ts), and the Clerk sync endpoints.

Legend

  • ✅ Full access
  • 📖 Read only
  • ✏️ Write / create
  • ❌ No access
  • 🔒 Self only (own records)
  • ⚠️ Requires additional runtime check (e.g. purchase limit, state compliance)

3.1 Core Permission Matrix

ResourceActionsuper_adminorg:adminorg:managerorg:budtenderorg:associateorg:viewercustomer
OrganizationsCreate
OrganizationsRead📖
OrganizationsUpdate settings
OrganizationsDelete
Users / MembersInvite
Users / MembersView list📖
Users / MembersUpdate role
Users / MembersRemove
AssociatesCreate
AssociatesRead📖
AssociatesUpdate🔒
AssociatesDelete
Products (Catalog)Read
Products (Catalog)Create
Products (Catalog)Update
Products (Catalog)Bulk operations
Products (Catalog)Delete
InventoryRead📖📖
InventoryAdjust / receive
InventoryMETRC sync
POS / TransactionsCreate transaction⚠️
POS / TransactionsVoid / refund
POS / TransactionsApply discount
POS / TransactionsView history🔒📖
Cash DrawerOpen / reconcile
Cash DrawerAudit log view📖
CustomersCreate
CustomersRead PII🔒
CustomersUpdate🔒
CustomersDelete / anonymize
Analytics — DashboardView
Analytics — RevenueView📖
Analytics — ReportsGenerate📖
Analytics — ComplianceView / export
Analytics — PredictionsView
AI FeaturesGenerate images
AI FeaturesGenerate promotions
AI FeaturesExtract colors
Compliance ReportsView
Compliance ReportsExport / file
Onboarding (Admin)Complete / configure
BackupTrigger / download
Clerk Org SyncSync organization
Clerk MembershipAdd / remove
CareersSubmit application
Canonical DataRead brands/categories

4. Protected Route Inventory

Every API route in src/app/api/ is documented below with its required authentication level, role requirement, and the enforcement mechanism.

4.1 Admin Routes

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/admin/onboarding/completePOST✅ Clerk sessionorg:adminauth() + role checkBR-001
/api/backupGET, POST✅ Clerk sessionorg:adminauth() + role checkBR-002

4.2 AI Routes

All AI routes consume paid third-party API credits (Anthropic, OpenAI) and are restricted to org-level staff.

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/ai/extract-colorsPOST✅ Clerk sessionorg:managerauth()BR-010
/api/ai/generate-heroPOST✅ Clerk sessionorg:managerauth()BR-010
/api/ai/generate-imagePOST✅ Clerk sessionorg:managerauth()BR-010
/api/ai/generate-promotionPOST✅ Clerk sessionorg:managerauth()BR-010
/api/ai/save-generated-imagePOST✅ Clerk sessionorg:managerauth()BR-010

4.3 Analytics Routes

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/analytics/customersGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/dashboardGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/dashboard/performanceGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/dashboard/real-timeGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/dashboard/trendsGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/insightsGET✅ Clerk sessionorg:managerauth() + org scopeBR-021
/api/analytics/inventoryGET✅ Clerk sessionorg:managerauth() + org scopeBR-021
/api/analytics/inventory-insightsGET✅ Clerk sessionorg:managerauth() + org scopeBR-021
/api/analytics/performanceGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/predictionsGET✅ Clerk sessionorg:adminauth() + role checkBR-022
/api/analytics/reportsGET, POST✅ Clerk sessionorg:managerauth() + org scopeBR-023
/api/analytics/reports/complianceGET, POST✅ Clerk sessionorg:managerauth() + role checkBR-024
/api/analytics/reports/inventoryGET✅ Clerk sessionorg:managerauth() + org scopeBR-023
/api/analytics/reports/salesGET✅ Clerk sessionorg:managerauth() + org scopeBR-023
/api/analytics/revenueGET✅ Clerk sessionorg:managerauth() + org scopeBR-020
/api/analytics/vitalsGET✅ Clerk sessionorg:managerauth() + org scopeBR-020

4.4 Associate Routes

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/associatesGET✅ Clerk sessionorg:managerauth() + org scopeBR-030
/api/associatesPOST✅ Clerk sessionorg:managerauth() + role checkBR-030
/api/associates/:associateIdGET✅ Clerk sessionorg:manager OR selfauth() + ownership checkBR-031
/api/associates/:associateIdPUT, PATCH✅ Clerk sessionorg:manager OR self (limited fields)auth() + role checkBR-031
/api/associates/:associateIdDELETE✅ Clerk sessionorg:adminauth() + role checkBR-032

4.5 Auth Utility Routes

These routes are used internally to validate and hydrate user session state.

RouteMethod(s)Auth RequiredMinimum RoleEnforcementNotes
/api/auth/check-roleGET✅ Clerk sessionAny authenticatedauth()Returns caller's role; no elevation
/api/auth/check-user-contextGET✅ Clerk sessionAny authenticatedauth()Returns org context + metadata
/api/auth/employeeGET, POST✅ Clerk sessionorg:managerauth() + role checkEmployee PIN/badge auth for POS

4.6 Clerk Sync Routes

These routes are called from Clerk webhooks (svix signature verified) and internal admin flows.

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/clerk/add-to-organizationPOST✅ Clerk sessionorg:adminauth() + role checkBR-040
/api/clerk/syncPOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041
/api/clerk/sync/customerPOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041
/api/clerk/sync/membership/addPOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041
/api/clerk/sync/membership/removePOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041
/api/clerk/sync/organizationPOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041
/api/clerk/sync/userPOSTSvix webhook signatureN/A (webhook)svix HMAC verificationBR-041

4.7 Operational Routes

RouteMethod(s)Auth RequiredMinimum RoleEnforcementBR Ref
/api/cash-drawerGET✅ Clerk sessionorg:budtenderauth() + org scopeBR-050
/api/cash-drawerPOST✅ Clerk sessionorg:budtenderauth() + POS station checkBR-050

4.8 Public / Unauthenticated Routes

RouteMethod(s)Auth RequiredNotes
/api/careers/applyPOST❌ NonePublic job application submission
/api/canonical/brandsGET❌ NoneReference data; no PII
/api/canonical/categoriesGET❌ NoneReference data; no PII
/api/canonical/effectsGET❌ NoneReference data; no PII

5. Clerk Integration Architecture

5.1 Organization Model

CNBS maps dispensary entities to Clerk Organizations using a parent-child organization architecture (documented in docs/architecture/PARENT_CHILD_ORGANIZATION_ARCHITECTURE.md). A dispensary group (e.g., a multi-location operator) holds a parent organization; individual store locations are child organizations.

Clerk Organization Hierarchy
═══════════════════════════════════════════════

  [Parent Org: "Green Valley Group"]          ← org:admin manages billing, group-wide settings
       │
       ├── [Child Org: "Green Valley - Denver"]   ← store-level org:admin, org:manager, etc.
       ├── [Child Org: "Green Valley - Boulder"]
       └── [Child Org: "Green Valley - Aspen"]

Cross-org analytics (e.g., group-wide revenue) require super_admin or a parent-org org:admin. Single-location analytics are scoped to the child org.

5.2 Session Claims & Custom Metadata

Clerk injects the following claims into the session JWT. API routes read these via auth() in Next.js Server Components and Route Handlers.

publicMetadata fields (set by platform / admin):

interface ClerkUserPublicMetadata {
  role?: "super_admin";           // Platform-level override; set only by CNBS backend
  userType?: "customer" | "staff"; // Distinguishes storefront vs POS users
  dispensaryId?: string;           // Supabase org UUID for RLS policy matching
  onboardingComplete?: boolean;    // Controls /admin/onboarding redirect
}

privateMetadata fields (set by platform; never sent to client):

interface ClerkUserPrivateMetadata {
  supabaseUserId?: string;   // Mirrors auth.users.id in Supabase
  employeePIN?: string;      // Hashed PIN for POS quick-auth (BR-053)
  stripeCustomerId?: string; // Billing linkage
}

unsafeMetadata fields (user-controlled; never trusted for permissions):

interface ClerkUserUnsafeMetadata {
  preferences?: Record<string, unknown>; // UI preferences only; never used in authz
}

Organization membership claim (injected by Clerk):

interface ClerkOrgClaims {
  org_id: string;        // Clerk organization ID
  org_slug: string;      // e.g. "green-valley-denver"
  org_role: string;      // e.g. "org:admin", "org:budtender"
  org_permissions: string[]; // Fine-grained permissions if configured in Clerk dashboard
}

5.3 Role-to-Permission Mapping

The org_role claim from Clerk maps to CNBS application permissions as follows. This mapping is implemented in src/app/api/auth/check-role/route.ts and referenced throughout API route handlers.

// Canonical role mapping — src/lib/auth/roles.ts (inferred from check-role route)
const ROLE_HIERARCHY: Record<string, number> = {
  "super_admin": 100,
  "org:admin": 80,
  "org:manager": 60,
  "org:budtender": 40,
  "org:associate": 20,
  "org:viewer": 10,
};

function hasMinimumRole(userRole: string, requiredRole: string): boolean {
  return (ROLE_HIERARCHY[userRole] ?? 0) >= (ROLE_HIERARCHY[requiredRole] ?? 999);
}

6. Middleware Configuration

6.1 Route Protection Strategy

The Next.js middleware at src/middleware.ts uses Clerk's clerkMiddleware() to enforce authentication at the edge before any route handler executes.

// src/middleware.ts — inferred from Clerk integration patterns
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

// Public routes that bypass Clerk session requirement
const isPublicRoute = createRouteMatcher([
  "/",                          // Marketing home
  "/sign-in(.*)",               // Clerk-hosted sign-in
  "/sign-up(.*)",               // Clerk-hosted sign-up
  "/about(.*)",                 // Public marketing pages
  "/menu(.*)",                  // Public storefront menu (read-only)
  "/products(.*)",              // Public product catalog
  "/api/careers/apply",         // Public job applications
  "/api/canonical/(.*)",        // Reference data (brands, categories, effects)
  "/api/clerk/sync(.*)",        // Clerk webhooks (verified by svix, not Clerk session)
  "/api/auth/employee",         // POS employee PIN auth (handles its own auth)
]);

// Admin-only routes — require org:admin or super_admin
const isAdminRoute = createRouteMatcher([
  "/dashboard/admin(.*)",
  "/dashboard/settings(.*)",
  "/dashboard/onboarding(.*)",
  "/api/admin/(.*)",
  "/api/backup",
]);

export default clerkMiddleware(async (auth, req) => {
  if (isPublicRoute(req)) return;

  // Enforce authentication on all other routes
  await auth.protect();

  // Admin routes require elevated role
  if (isAdminRoute(req)) {
    await auth.protect((has) =>
      has({ role: "org:admin" }) || has({ permission: "super_admin" })
    );
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

6.2 Middleware Route Matcher Scope

The config.matcher ensures middleware runs on:

  • All page routes excluding Next.js static assets
  • All /api/ routes
  • All /trpc/ routes

Static files under _next/, images, fonts, and other assets bypass middleware entirely.


7. Server-Side Auth Patterns

7.1 Standard API Route Auth Check

Every protected API route follows this pattern, discovered from the auth utility routes and inferred from the route structure:

// Pattern used across src/app/api/**/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { userId, orgId, orgRole } = await auth();

  // 1. Authentication check
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. Organization context check (multi-tenant boundary)
  if (!orgId) {
    return NextResponse.json(
      { error: "No organization context" },
      { status: 403 }
    );
  }

  // 3. Role authorization check
  if (!hasMinimumRole(orgRole, "org:manager")) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  // 4. All data queries scoped to orgId (enforces tenant isolation)
  const data = await db.query.table.findMany({
    where: eq(table.organizationId, orgId),
  });

  return NextResponse.json({ data });
}

7.2 auth() vs currentUser()

FunctionImportUse CaseData Source
auth()@clerk/nextjs/serverAPI routes, Server Actions — fast, no additional network callJWT session claims
currentUser()@clerk/nextjs/serverWhen full user profile data is needed (e.g., onboarding)Clerk API call

auth() is preferred in all API routes because it reads from the JWT (zero-latency). currentUser() is used only in flows that require fields not present in the JWT, such as the admin onboarding flow at /api/admin/onboarding/complete.

7.3 Webhook Auth Pattern (/api/clerk/sync/*)

Clerk sync routes do not use Clerk session auth. They are called by Clerk's webhook infrastructure and authenticated via Svix HMAC signature verification:

// src/app/api/clerk/sync/route.ts — inferred pattern
import { Webhook } from "svix";

export async function POST(request: Request) {
  const webhookSecret = process.env.CLERK_WEBHOOK_SECRET;

  const svixId = request.headers.get("svix-id");
  const svixTimestamp = request.headers.get("svix-timestamp");
  const svixSignature = request.headers.get("svix-signature");

  if (!svixId || !svixTimestamp || !svixSignature) {
    return new Response("Missing svix headers", { status: 400 });
  }

  const body = await request.text();
  const wh = new Webhook(webhookSecret);

  let event;
  try {
    event = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    });
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  // Process