ZoomProp

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

  1. Overview
  2. Role Hierarchy
  3. Business Rules — Access Control
  4. Permission Matrix
  5. Clerk Integration Architecture
  6. Protected Route Inventory
  7. Middleware Configuration
  8. Server-Side Auth Patterns
  9. Client-Side Auth Guards
  10. Onboarding & Identity Verification Flow
  11. Row-Level Security Patterns
  12. Subscription-Tier Access Control
  13. Audit Logging
  14. 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:

LayerMechanismLocation
Route-levelclerkMiddleware() + createRouteMatchersrc/middleware.ts
API-levelauth() / currentUser() server helpersPer-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 RoleClerk orgRole valueClerk publicMetadata key
Adminorg:adminrole: "admin"
Memberorg:memberrole: "member"
Viewerorg:viewerrole: "viewer"
Free / Unverified(no org membership)subscriptionTier: "free"

2.2 Custom Metadata Fields (Clerk User publicMetadata)

FieldTypePurpose
role"admin" | "member" | "viewer"App-level role mirror
subscriptionTier"free" | "pro" | "enterprise"Billing gate
onboardingCompletebooleanPost-signup flow gate
identityVerifiedbooleanKYC gate (identity-verification-step)
businessVerifiedbooleanBusiness verification gate
monthlyFreeQueriesGrantednumberFree-tier AI query budget
investmentInterestsstring[]Personalization, not a permission gate
geographicFocusstring[]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

ResourceAdminMemberViewerUnauthenticated
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

VariablePurpose
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYClient-side Clerk initialization
CLERK_SECRET_KEYServer-side API calls (not in next.config.ts public env)
NEXT_PUBLIC_CLERK_SIGN_IN_URLRedirect target for unauthenticated users
NEXT_PUBLIC_CLERK_SIGN_UP_URLRegistration 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

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/auth/sessionGETAny authenticatedBR-001
/api/auth/active-orgGETAny authenticatedBR-007
/api/auth/testGETAdmin onlyBR-014

6.2 AI Chat & Conversation Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/ai/chatPOSTMember (paid)BR-001, BR-005
/api/ai/conversationsGET, POSTMemberBR-001, BR-013
/api/ai/conversations/[id]GET, DELETEMember (own)BR-013
/api/ai/generate-titlePOSTMemberBR-001
/api/ai/intentPOSTMemberBR-001
/api/ai/suggestionsPOSTMemberBR-001
/api/ai/suggest-tagsPOSTMemberBR-001

6.3 AI Analysis Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/ai/analysisPOSTMember (paid)BR-001, BR-005
/api/ai/property-analysisPOSTMember (paid)BR-001, BR-005
/api/ai/commercial-analysisPOSTMember (paid)BR-001, BR-005
/api/ai/commercial-estimatesPOSTMember (paid)BR-001, BR-005
/api/ai/maintenance-analysisPOSTMember (paid)BR-001, BR-005
/api/ai/offer-analysisPOSTMember (paid)BR-001, BR-005
/api/ai/property-inspection-analysisPOSTMember (paid)BR-001, BR-005
/api/ai/performance-monitoringPOSTMember (paid)BR-001, BR-005

6.4 AI Persona Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/ai/personasGETViewerBR-001
/api/ai/personasPOST, PUT, DELETEAdminBR-008

6.5 AI Search Template Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/ai-search-templatesGETMember (own)BR-001, BR-012
/api/ai-search-templatesPOSTMemberBR-001
/api/ai-search-templatesPUT, DELETEMember (own) / AdminBR-012

6.6 Alert Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/alerts/configureGETViewerBR-001
/api/alerts/configurePOSTAdminBR-004
/api/alerts/historyGETViewerBR-001
/api/alerts/preferencesGETViewerBR-001
/api/alerts/preferencesPUTMemberBR-009
/api/alerts/testPOSTAdminBR-010
/api/alerts/triggerPOSTAdminBR-004
/api/alerts/usersGETMemberBR-003
/api/alerts/messages/archivePOSTMember (own)BR-009
/api/alerts/messages/unarchivePOSTMember (own)BR-009

6.7 Alert Filter Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/alert-filtersGETViewerBR-001
/api/alert-filtersPOSTMemberBR-009
/api/alert-filtersPUT, DELETEMember (own) / AdminBR-009
/api/alert-filters/assignmentsGETMemberBR-003
/api/alert-filters/assignmentsPOSTAdminBR-003, BR-004

6.8 Analytics Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/analytics-dashboardGETViewerBR-001
/api/analytics/appreciation-distributionGETViewerBR-001
/api/analytics/marketsGETViewerBR-001
/api/analytics/performanceGETMemberBR-011
/api/analytics/portfolioGETMemberBR-011
/api/analytics/propertiesGETViewerBR-001

6.9 Content & Automation Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/articlesGETViewerBR-001
/api/automation/analyticsGETMemberBR-001
/api/automation/board-configGETMemberBR-001
/api/automation/board-configPOSTAdminBR-003

6.10 Property Action Routes

RouteMethod(s)Auth RequiredMin RoleBusiness Rule
/api/actions/analyze-property-investigationPOSTMember (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/
│   ├