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
- Overview & Security Model
- Role Hierarchy
- Permission Matrix
- Protected Route Inventory
- Clerk Integration Architecture
- Middleware Configuration
- Server-Side Auth Patterns
- Client-Side Auth Guards
- Row-Level Security (RLS)
- Organization & Multi-Tenancy Model
- Audit Logging
- Business Rules Register
- Security Test Coverage
1. Overview & Security Model
CNBS implements a multi-tenant, organization-scoped RBAC model enforced at three layers:
| Layer | Mechanism | Location |
|---|---|---|
| Edge / Network | Clerk middleware route matching | src/middleware.ts |
| API | auth() server-side checks + role validation | src/app/api/**/route.ts |
| Database | Supabase Row-Level Security (RLS) policies | PostgreSQL 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 ID | Clerk Mapping | Description | Session Scope |
|---|---|---|---|
super_admin | publicMetadata.role = "super_admin" | Platform operator. Full access across all organizations. | Cross-org |
org:admin | Clerk Organization role admin | Dispensary owner. Controls org settings, users, billing, compliance config. | Single org |
org:manager | Clerk Organization role manager | Store-level manager. Manages inventory, staff schedules, compliance reports. Cannot modify org billing or delete org. | Single org |
org:budtender | Clerk Organization role budtender | POS operator. Processes transactions, verifies IDs, manages cart. Cannot access analytics or admin. | Single org |
org:associate | Clerk Organization role associate | Floor associate. View-only on POS catalog; limited transaction creation. | Single org |
org:viewer | Clerk Organization role viewer | Reporting/analytics read-only. No write access. | Single org |
customer | Clerk 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
| Resource | Action | super_admin | org:admin | org:manager | org:budtender | org:associate | org:viewer | customer |
|---|---|---|---|---|---|---|---|---|
| Organizations | Create | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Organizations | Read | ✅ | ✅ | 📖 | ❌ | ❌ | ❌ | ❌ |
| Organizations | Update settings | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Organizations | Delete | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Users / Members | Invite | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Users / Members | View list | ✅ | ✅ | ✅ | ❌ | ❌ | 📖 | ❌ |
| Users / Members | Update role | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Users / Members | Remove | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Associates | Create | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Associates | Read | ✅ | ✅ | ✅ | ❌ | ❌ | 📖 | ❌ |
| Associates | Update | ✅ | ✅ | ✅ | 🔒 | ❌ | ❌ | ❌ |
| Associates | Delete | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Products (Catalog) | Read | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Products (Catalog) | Create | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Products (Catalog) | Update | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Products (Catalog) | Bulk operations | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Products (Catalog) | Delete | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Inventory | Read | ✅ | ✅ | ✅ | ✅ | 📖 | 📖 | ❌ |
| Inventory | Adjust / receive | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Inventory | METRC sync | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| POS / Transactions | Create transaction | ✅ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
| POS / Transactions | Void / refund | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| POS / Transactions | Apply discount | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| POS / Transactions | View history | ✅ | ✅ | ✅ | 🔒 | ❌ | 📖 | ❌ |
| Cash Drawer | Open / reconcile | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Cash Drawer | Audit log view | ✅ | ✅ | ✅ | ❌ | ❌ | 📖 | ❌ |
| Customers | Create | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Customers | Read PII | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | 🔒 |
| Customers | Update | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | 🔒 |
| Customers | Delete / anonymize | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Analytics — Dashboard | View | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| Analytics — Revenue | View | ✅ | ✅ | ✅ | ❌ | ❌ | 📖 | ❌ |
| Analytics — Reports | Generate | ✅ | ✅ | ✅ | ❌ | ❌ | 📖 | ❌ |
| Analytics — Compliance | View / export | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Analytics — Predictions | View | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| AI Features | Generate images | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| AI Features | Generate promotions | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| AI Features | Extract colors | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Compliance Reports | View | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Compliance Reports | Export / file | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Onboarding (Admin) | Complete / configure | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Backup | Trigger / download | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Clerk Org Sync | Sync organization | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Clerk Membership | Add / remove | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Careers | Submit application | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Canonical Data | Read 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
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/admin/onboarding/complete | POST | ✅ Clerk session | org:admin | auth() + role check | BR-001 |
/api/backup | GET, POST | ✅ Clerk session | org:admin | auth() + role check | BR-002 |
4.2 AI Routes
All AI routes consume paid third-party API credits (Anthropic, OpenAI) and are restricted to org-level staff.
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/ai/extract-colors | POST | ✅ Clerk session | org:manager | auth() | BR-010 |
/api/ai/generate-hero | POST | ✅ Clerk session | org:manager | auth() | BR-010 |
/api/ai/generate-image | POST | ✅ Clerk session | org:manager | auth() | BR-010 |
/api/ai/generate-promotion | POST | ✅ Clerk session | org:manager | auth() | BR-010 |
/api/ai/save-generated-image | POST | ✅ Clerk session | org:manager | auth() | BR-010 |
4.3 Analytics Routes
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/analytics/customers | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/dashboard | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/dashboard/performance | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/dashboard/real-time | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/dashboard/trends | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/insights | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-021 |
/api/analytics/inventory | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-021 |
/api/analytics/inventory-insights | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-021 |
/api/analytics/performance | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/predictions | GET | ✅ Clerk session | org:admin | auth() + role check | BR-022 |
/api/analytics/reports | GET, POST | ✅ Clerk session | org:manager | auth() + org scope | BR-023 |
/api/analytics/reports/compliance | GET, POST | ✅ Clerk session | org:manager | auth() + role check | BR-024 |
/api/analytics/reports/inventory | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-023 |
/api/analytics/reports/sales | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-023 |
/api/analytics/revenue | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
/api/analytics/vitals | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-020 |
4.4 Associate Routes
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/associates | GET | ✅ Clerk session | org:manager | auth() + org scope | BR-030 |
/api/associates | POST | ✅ Clerk session | org:manager | auth() + role check | BR-030 |
/api/associates/:associateId | GET | ✅ Clerk session | org:manager OR self | auth() + ownership check | BR-031 |
/api/associates/:associateId | PUT, PATCH | ✅ Clerk session | org:manager OR self (limited fields) | auth() + role check | BR-031 |
/api/associates/:associateId | DELETE | ✅ Clerk session | org:admin | auth() + role check | BR-032 |
4.5 Auth Utility Routes
These routes are used internally to validate and hydrate user session state.
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | Notes |
|---|---|---|---|---|---|
/api/auth/check-role | GET | ✅ Clerk session | Any authenticated | auth() | Returns caller's role; no elevation |
/api/auth/check-user-context | GET | ✅ Clerk session | Any authenticated | auth() | Returns org context + metadata |
/api/auth/employee | GET, POST | ✅ Clerk session | org:manager | auth() + role check | Employee PIN/badge auth for POS |
4.6 Clerk Sync Routes
These routes are called from Clerk webhooks (svix signature verified) and internal admin flows.
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/clerk/add-to-organization | POST | ✅ Clerk session | org:admin | auth() + role check | BR-040 |
/api/clerk/sync | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
/api/clerk/sync/customer | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
/api/clerk/sync/membership/add | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
/api/clerk/sync/membership/remove | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
/api/clerk/sync/organization | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
/api/clerk/sync/user | POST | Svix webhook signature | N/A (webhook) | svix HMAC verification | BR-041 |
4.7 Operational Routes
| Route | Method(s) | Auth Required | Minimum Role | Enforcement | BR Ref |
|---|---|---|---|---|---|
/api/cash-drawer | GET | ✅ Clerk session | org:budtender | auth() + org scope | BR-050 |
/api/cash-drawer | POST | ✅ Clerk session | org:budtender | auth() + POS station check | BR-050 |
4.8 Public / Unauthenticated Routes
| Route | Method(s) | Auth Required | Notes |
|---|---|---|---|
/api/careers/apply | POST | ❌ None | Public job application submission |
/api/canonical/brands | GET | ❌ None | Reference data; no PII |
/api/canonical/categories | GET | ❌ None | Reference data; no PII |
/api/canonical/effects | GET | ❌ None | Reference 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()
| Function | Import | Use Case | Data Source |
|---|---|---|---|
auth() | @clerk/nextjs/server | API routes, Server Actions — fast, no additional network call | JWT session claims |
currentUser() | @clerk/nextjs/server | When 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