RBAC & permissions
RBAC & permissions
Last updated 5/3/2026
ZoomProp RBAC & Permissions Documentation
Document ID: TECH-SEC-001
Version: 0.1.0-alpha
Auth Provider: Clerk (@clerk/nextjs v6.23.1)
Methodology: PRD-driven Context Engineering
Table of Contents
- Overview
- Role Hierarchy
- Business Rules — Access Control
- Permission Matrix
- Clerk Integration Architecture
- Protected Route Inventory
- Middleware Configuration
- Server-Side Auth Patterns
- Client-Side Auth Guards
- Onboarding & Identity Verification Flow
- Row-Level Security Patterns
- Subscription-Tier Access Control
- Audit Logging
- Known Gaps & Hardening Recommendations
1. Overview
ZoomProp is a real-estate investment intelligence platform. Authentication is entirely delegated to Clerk (@clerk/nextjs v6.23.1, @clerk/elements v0.23.35). Authorization is enforced at three layers:
| Layer | Mechanism | Location |
|---|---|---|
| Route-level | clerkMiddleware() + createRouteMatcher | src/middleware.ts |
| API-level | auth() / currentUser() server helpers | Per-route route.ts files |
| UI-level | <SignedIn> / <SignedOut>, useAuth(), useUser() | React components |
The platform distinguishes between authentication state (signed in / not signed in), organization membership (personal vs. org context), and subscription tier (free-query grant vs. paid plan). A dedicated authorization guide exists at context/AUTHORIZATION_SYSTEM_GUIDE.md.
2. Role Hierarchy
ZoomProp maps Clerk's organization role primitives to four application-level roles. Roles are stored in Clerk Organization membership metadata and optionally mirrored in publicMetadata on the Clerk User object.
┌──────────────────────────────────────────────────────────┐
│ org:admin │
│ Full platform access, billing management, user mgmt, │
│ alert configuration, AI persona management │
├──────────────────────────────────────────────────────────┤
│ org:member │
│ Standard access: portfolio, AI chat, search, │
│ alert subscriptions, watchlist, analytics │
├──────────────────────────────────────────────────────────┤
│ org:viewer │
│ Read-only: property views, market data, reports │
│ No write operations, no alert configuration │
├──────────────────────────────────────────────────────────┤
│ unauthenticated / free_user │
│ Monthly free-query grant only (introduced PR #455). │
│ No portfolio, no alerts, no AI conversations │
└──────────────────────────────────────────────────────────┘
2.1 Clerk Role Identifiers
| App Role | Clerk orgRole value | Clerk publicMetadata key |
|---|---|---|
| Admin | org:admin | role: "admin" |
| Member | org:member | role: "member" |
| Viewer | org:viewer | role: "viewer" |
| Free / Unverified | (no org membership) | subscriptionTier: "free" |
2.2 Custom Metadata Fields (Clerk User publicMetadata)
| Field | Type | Purpose |
|---|---|---|
role | "admin" | "member" | "viewer" | App-level role mirror |
subscriptionTier | "free" | "pro" | "enterprise" | Billing gate |
onboardingComplete | boolean | Post-signup flow gate |
identityVerified | boolean | KYC gate (identity-verification-step) |
businessVerified | boolean | Business verification gate |
monthlyFreeQueriesGranted | number | Free-tier AI query budget |
investmentInterests | string[] | Personalization, not a permission gate |
geographicFocus | string[] | Personalization |
3. Business Rules — Access Control
BR-001 — Authentication Required for All Dashboard Routes
All routes under the /(dashboard) Next.js route group require an active Clerk session. Unauthenticated requests are redirected to /sign-in. This is enforced in src/middleware.ts via clerkMiddleware().
Trigger: Any HTTP request to a /dashboard/*, /alerts/*, /portfolio/*, /discover/*, /chat/*, /analytics/* path without a valid Clerk session JWT.
Action: HTTP 302 redirect to NEXT_PUBLIC_CLERK_SIGN_IN_URL.
BR-002 — Onboarding Completion Gate
Users who have completed Clerk sign-up but have publicMetadata.onboardingComplete !== true are redirected to /onboarding before accessing any dashboard resource.
Trigger: Authenticated session where user.publicMetadata.onboardingComplete is falsy.
Action: Redirect to /onboarding. All dashboard API routes return 403 if the onboarding gate has not been cleared.
Source: src/app/(auth)/onboarding/ wizard, src/app/api/auth/session/route.ts.
BR-003 — Organization Context Required for Team Features
Alert filter assignments (/api/alert-filters/assignments), organization-level automation board configuration (/api/automation/board-config), and multi-user alert user management (/api/alerts/users) require an active Clerk organization context (i.e., auth().orgId must be present).
Trigger: Request to org-scoped endpoint without orgId in session.
Action: 403 response with {"error": "Organization context required"}.
BR-004 — Admin Role Required for Alert Configuration
Creating or modifying alert configurations (POST /api/alerts/configure) and triggering system-level alerts (POST /api/alerts/trigger) requires org:admin role.
Trigger: Non-admin org member attempts to POST to /api/alerts/configure or /api/alerts/trigger.
Action: 403 response. Read access (GET) is permitted to org:member and org:viewer.
BR-005 — Free-Tier AI Query Budget Enforcement
Users without an active paid subscription are granted a monthly free-query budget stored in publicMetadata.monthlyFreeQueriesGranted. Once exhausted, AI chat (/api/ai/chat), property analysis (/api/ai/property-analysis), and all /api/ai/* endpoints return 402 until the next grant cycle.
Trigger: AI endpoint called when remainingFreeQueries <= 0 and subscriptionTier === "free".
Action: 402 response with upgrade prompt. Introduced in PR #455 (feat: add monthly free queries grant API).
Source: src/app/api/ — monthly free queries grant API.
BR-006 — Identity Verification Gate for Investment Actions
Steps in the onboarding wizard (src/app/(auth)/onboarding/_components/steps/identity-verification-step.tsx) and business verification (business-verification-form.tsx) must be completed before a user can access co-investment workflows (co-investment-step.tsx) or portfolio write operations.
Trigger: User attempts to access co-investment or portfolio creation with identityVerified !== true.
Action: Redirect to identity verification step; write API calls return 403.
BR-007 — Session Validity Check on Active Organization
GET /api/auth/active-org validates that the session's orgId corresponds to an organization the user is still a member of. Stale sessions with revoked org membership return 401.
Trigger: Every authenticated page load in org context via the layout component.
Action: 401 + Clerk session invalidation if org membership has been revoked.
Source: src/app/api/auth/active-org/route.ts.
BR-008 — AI Persona Management Restricted to Admins
Creating, updating, or deleting AI personas (/api/ai/personas) is restricted to org:admin. Members and viewers may read personas but cannot mutate them.
Trigger: Non-admin user attempts POST/PUT/DELETE on /api/ai/personas.
Action: 403 response.
BR-009 — Viewer Role Is Read-Only Across All Resources
Users with org:viewer role may only perform HTTP GET operations. Any POST, PUT, PATCH, or DELETE request to any /api/* route returns 403 for viewer-role sessions.
Trigger: Viewer-role session makes a mutating HTTP request.
Action: 403 response with {"error": "Insufficient permissions. Read-only access."}.
BR-010 — Alert Test Endpoint Restricted to Admins
POST /api/alerts/test sends live notification payloads through Knock. This endpoint is restricted to org:admin to prevent notification spam.
Trigger: Non-admin attempts POST to /api/alerts/test.
Action: 403 response.
BR-011 — Analytics Dashboard Access Requires Member or Admin
The analytics dashboard (/api/analytics-dashboard, /api/analytics/performance, /api/analytics/portfolio) requires at minimum org:member role. Viewers are excluded from portfolio-level analytics to protect financial data.
Trigger: org:viewer session requests analytics portfolio or performance endpoints.
Action: 403 response.
BR-012 — AI Search Templates Are User-Scoped
AI search templates (/api/ai-search-templates) are scoped to the authenticated user's userId. A user may not read, update, or delete templates belonging to another user. Admins may manage templates for all users within their organization.
Trigger: User attempts to access a template record where template.userId !== auth().userId and auth().orgRole !== "org:admin".
Action: 404 response (to avoid enumeration).
BR-013 — Conversation History Is User-Scoped
AI conversation records (/api/ai/conversations, /api/ai/conversations/:id) are scoped to userId. No cross-user conversation access is permitted even within the same organization.
Trigger: Request to /api/ai/conversations/[id] where conversation.userId !== auth().userId.
Action: 404 response.
BR-014 — Auth Test Route Is Non-Production Only
GET /api/auth/test is a diagnostic endpoint that returns raw session claims. It must be disabled or protected behind an environment check in production builds.
Trigger: Any request to /api/auth/test in a production environment.
Recommended Action: Return 404 when NODE_ENV === "production". Currently flagged as a hardening gap (see §14).
4. Permission Matrix
The following table documents every resource category against each role and the permitted HTTP operations.
Legend: ✅ Permitted · ❌ Denied · 🔒 Own records only · 💳 Paid tier required
| Resource | Admin | Member | Viewer | Unauthenticated |
|---|---|---|---|---|
| Authentication | ||||
GET /api/auth/session | ✅ | ✅ | ✅ | ❌ |
GET /api/auth/active-org | ✅ | ✅ | ✅ | ❌ |
GET /api/auth/test | ✅ | ❌ | ❌ | ❌ |
| AI Chat & Conversations | ||||
POST /api/ai/chat | ✅💳 | ✅💳 | ❌ | ❌ |
GET /api/ai/conversations | 🔒 | 🔒 | ❌ | ❌ |
POST /api/ai/conversations | ✅ | ✅ | ❌ | ❌ |
GET /api/ai/conversations/:id | 🔒 | 🔒 | ❌ | ❌ |
DELETE /api/ai/conversations/:id | 🔒 | 🔒 | ❌ | ❌ |
POST /api/ai/generate-title | ✅ | ✅ | ❌ | ❌ |
POST /api/ai/intent | ✅ | ✅ | ❌ | ❌ |
POST /api/ai/suggestions | ✅ | ✅ | ❌ | ❌ |
| AI Analysis | ||||
POST /api/ai/analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/property-analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/commercial-analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/commercial-estimates | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/maintenance-analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/offer-analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/property-inspection-analysis | ✅💳 | ✅💳 | ❌ | ❌ |
POST /api/ai/performance-monitoring | ✅💳 | ✅💳 | ❌ | ❌ |
| AI Personas | ||||
GET /api/ai/personas | ✅ | ✅ | ✅ | ❌ |
POST /api/ai/personas | ✅ | ❌ | ❌ | ❌ |
PUT /api/ai/personas | ✅ | ❌ | ❌ | ❌ |
DELETE /api/ai/personas | ✅ | ❌ | ❌ | ❌ |
| AI Search Templates | ||||
GET /api/ai-search-templates | ✅ | 🔒 | 🔒 | ❌ |
POST /api/ai-search-templates | ✅ | ✅ | ❌ | ❌ |
PUT /api/ai-search-templates | ✅ | 🔒 | ❌ | ❌ |
DELETE /api/ai-search-templates | ✅ | 🔒 | ❌ | ❌ |
| AI Tags & Suggestions | ||||
POST /api/ai/suggest-tags | ✅ | ✅ | ❌ | ❌ |
| Property Actions | ||||
POST /api/actions/analyze-property-investigation | ✅💳 | ✅💳 | ❌ | ❌ |
| Alerts | ||||
GET /api/alerts/configure | ✅ | ✅ | ✅ | ❌ |
POST /api/alerts/configure | ✅ | ❌ | ❌ | ❌ |
GET /api/alerts/history | ✅ | ✅ | ✅ | ❌ |
GET /api/alerts/preferences | ✅ | ✅ | ✅ | ❌ |
PUT /api/alerts/preferences | ✅ | ✅ | ❌ | ❌ |
POST /api/alerts/test | ✅ | ❌ | ❌ | ❌ |
POST /api/alerts/trigger | ✅ | ❌ | ❌ | ❌ |
GET /api/alerts/users | ✅ | ✅ | ❌ | ❌ |
POST /api/alerts/messages/archive | ✅ | 🔒 | ❌ | ❌ |
POST /api/alerts/messages/unarchive | ✅ | 🔒 | ❌ | ❌ |
| Alert Filters | ||||
GET /api/alert-filters | ✅ | ✅ | ✅ | ❌ |
POST /api/alert-filters | ✅ | ✅ | ❌ | ❌ |
PUT /api/alert-filters | ✅ | 🔒 | ❌ | ❌ |
DELETE /api/alert-filters | ✅ | 🔒 | ❌ | ❌ |
GET /api/alert-filters/assignments | ✅ | ✅ | ❌ | ❌ |
POST /api/alert-filters/assignments | ✅ | ❌ | ❌ | ❌ |
| Analytics | ||||
GET /api/analytics-dashboard | ✅ | ✅ | ✅ | ❌ |
GET /api/analytics/appreciation-distribution | ✅ | ✅ | ✅ | ❌ |
GET /api/analytics/markets | ✅ | ✅ | ✅ | ❌ |
GET /api/analytics/performance | ✅ | ✅ | ❌ | ❌ |
GET /api/analytics/portfolio | ✅ | ✅ | ❌ | ❌ |
GET /api/analytics/properties | ✅ | ✅ | ✅ | ❌ |
| Articles | ||||
GET /api/articles | ✅ | ✅ | ✅ | ❌ |
| Automation | ||||
GET /api/automation/analytics | ✅ | ✅ | ❌ | ❌ |
GET /api/automation/board-config | ✅ | ✅ | ❌ | ❌ |
POST /api/automation/board-config | ✅ | ❌ | ❌ | ❌ |
5. Clerk Integration Architecture
5.1 Package Dependencies
"@clerk/nextjs": "^6.23.1",
"@clerk/elements": "^0.23.35"
@clerk/nextjs provides the Next.js App Router integration (clerkMiddleware, auth(), currentUser(), React context providers). @clerk/elements provides headless UI primitives used in the custom sign-in and sign-up pages at src/app/(auth)/sign-in/[[...sign-in]]/page.tsx and src/app/(auth)/sign-up/[[...sign-up]]/page.tsx.
5.2 Environment Variables
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Client-side Clerk initialization |
CLERK_SECRET_KEY | Server-side API calls (not in next.config.ts public env) |
NEXT_PUBLIC_CLERK_SIGN_IN_URL | Redirect target for unauthenticated users |
NEXT_PUBLIC_CLERK_SIGN_UP_URL | Registration entry point |
Declared in next.config.ts under the env block; CLERK_SECRET_KEY is consumed server-side only and must not appear in NEXT_PUBLIC_* variables.
5.3 Organization Roles Mapping
Clerk Organizations are used to represent investment teams or firm accounts. The orgRole claim in the Clerk session JWT maps directly to ZoomProp permissions:
Clerk orgRole → ZoomProp Permission Level
─────────────────────────────────────────────────────
org:admin → Full read/write + admin ops
org:member → Read/write own resources
org:viewer → Read-only
(no orgId in session) → Free tier / personal account
Organization creation flow is documented at src/app/(auth)/sign-up/organization/page.tsx and src/app/(auth)/sign-up/organization/created/page.tsx.
5.4 Session Claims
The Clerk JWT session token contains the following claims relevant to ZoomProp authorization:
// Claims available via auth() in Server Components / Route Handlers
interface ZoomPropSessionClaims {
sub: string; // Clerk userId — used for user-scoped resource ownership
org_id?: string; // orgId — required for org-context endpoints (BR-003)
org_role?: string; // "org:admin" | "org:member" | "org:viewer"
org_slug?: string; // Human-readable org identifier
// publicMetadata promoted to claims via Clerk session customization:
metadata: {
role?: "admin" | "member" | "viewer";
subscriptionTier?: "free" | "pro" | "enterprise";
onboardingComplete?: boolean;
identityVerified?: boolean;
businessVerified?: boolean;
monthlyFreeQueriesGranted?: number;
};
}
Custom metadata is surfaced in session claims by configuring a session token customization in the Clerk Dashboard to include user.public_metadata under a metadata key.
5.5 Mock Implementation (Test Suite)
The Clerk mock at tests/__mocks__/@clerk/nextjs.tsx stubs all Clerk exports for unit and integration tests. This mock must replicate the session claim shape above to ensure auth checks behave correctly in tests. The auth-guard component tests are at src/components/core/auth/__tests__/auth-guard.test.tsx.
6. Protected Route Inventory
Every API route is listed with its authentication requirement, minimum role, and the governing business rule.
6.1 Authentication & Session Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/auth/session | GET | ✅ | Any authenticated | BR-001 |
/api/auth/active-org | GET | ✅ | Any authenticated | BR-007 |
/api/auth/test | GET | ✅ | Admin only | BR-014 |
6.2 AI Chat & Conversation Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/ai/chat | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/conversations | GET, POST | ✅ | Member | BR-001, BR-013 |
/api/ai/conversations/[id] | GET, DELETE | ✅ | Member (own) | BR-013 |
/api/ai/generate-title | POST | ✅ | Member | BR-001 |
/api/ai/intent | POST | ✅ | Member | BR-001 |
/api/ai/suggestions | POST | ✅ | Member | BR-001 |
/api/ai/suggest-tags | POST | ✅ | Member | BR-001 |
6.3 AI Analysis Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/ai/analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/property-analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/commercial-analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/commercial-estimates | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/maintenance-analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/offer-analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/property-inspection-analysis | POST | ✅ | Member (paid) | BR-001, BR-005 |
/api/ai/performance-monitoring | POST | ✅ | Member (paid) | BR-001, BR-005 |
6.4 AI Persona Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/ai/personas | GET | ✅ | Viewer | BR-001 |
/api/ai/personas | POST, PUT, DELETE | ✅ | Admin | BR-008 |
6.5 AI Search Template Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/ai-search-templates | GET | ✅ | Member (own) | BR-001, BR-012 |
/api/ai-search-templates | POST | ✅ | Member | BR-001 |
/api/ai-search-templates | PUT, DELETE | ✅ | Member (own) / Admin | BR-012 |
6.6 Alert Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/alerts/configure | GET | ✅ | Viewer | BR-001 |
/api/alerts/configure | POST | ✅ | Admin | BR-004 |
/api/alerts/history | GET | ✅ | Viewer | BR-001 |
/api/alerts/preferences | GET | ✅ | Viewer | BR-001 |
/api/alerts/preferences | PUT | ✅ | Member | BR-009 |
/api/alerts/test | POST | ✅ | Admin | BR-010 |
/api/alerts/trigger | POST | ✅ | Admin | BR-004 |
/api/alerts/users | GET | ✅ | Member | BR-003 |
/api/alerts/messages/archive | POST | ✅ | Member (own) | BR-009 |
/api/alerts/messages/unarchive | POST | ✅ | Member (own) | BR-009 |
6.7 Alert Filter Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/alert-filters | GET | ✅ | Viewer | BR-001 |
/api/alert-filters | POST | ✅ | Member | BR-009 |
/api/alert-filters | PUT, DELETE | ✅ | Member (own) / Admin | BR-009 |
/api/alert-filters/assignments | GET | ✅ | Member | BR-003 |
/api/alert-filters/assignments | POST | ✅ | Admin | BR-003, BR-004 |
6.8 Analytics Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/analytics-dashboard | GET | ✅ | Viewer | BR-001 |
/api/analytics/appreciation-distribution | GET | ✅ | Viewer | BR-001 |
/api/analytics/markets | GET | ✅ | Viewer | BR-001 |
/api/analytics/performance | GET | ✅ | Member | BR-011 |
/api/analytics/portfolio | GET | ✅ | Member | BR-011 |
/api/analytics/properties | GET | ✅ | Viewer | BR-001 |
6.9 Content & Automation Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/articles | GET | ✅ | Viewer | BR-001 |
/api/automation/analytics | GET | ✅ | Member | BR-001 |
/api/automation/board-config | GET | ✅ | Member | BR-001 |
/api/automation/board-config | POST | ✅ | Admin | BR-003 |
6.10 Property Action Routes
| Route | Method(s) | Auth Required | Min Role | Business Rule |
|---|---|---|---|---|
/api/actions/analyze-property-investigation | POST | ✅ | Member (paid) | BR-001, BR-005, BR-006 |
7. Middleware Configuration
7.1 Location
src/middleware.ts
7.2 Pattern
ZoomProp uses Clerk's clerkMiddleware() (v6 App Router API) with createRouteMatcher to define public vs. protected matchers. The Next.js config.matcher array determines which request paths the middleware processes.
// src/middleware.ts — reconstructed from codebase signals
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
// Public routes that bypass authentication
const isPublicRoute = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
'/login(.*)',
'/register(.*)',
'/api/auth/test', // NOTE: BR-014 — should be restricted in prod
'/', // Marketing / landing
'/careers(.*)',
]);
// Auth redirect route — needs special handling
const isAuthRedirectRoute = createRouteMatcher([
'/auth/redirect(.*)',
]);
export default clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
7.3 Auth Redirect Page
src/app/(auth)/auth/redirect/page.tsx handles post-authentication routing logic — determining whether to send a user to onboarding or the dashboard based on publicMetadata.onboardingComplete (BR-002). This page runs after Clerk's OAuth/SSO callback.
7.4 Route Groups
src/app/
├── (auth)/ ← Public auth routes: sign-in, sign-up, onboarding
│ ├── sign-in/
│ ├