Identity & Access
Who are you, and what are you allowed to do here?
The Job
When a platform serves multiple organisations, help every user land in the right org with the right permissions — so no one is locked out, no one sees data they shouldn't, and the builder never needs to touch the database to onboard a customer.
| Trigger Event | Current Failure | Desired Progress |
|---|---|---|
| Owner deploys production app | Locked out — infinite redirect loop (PostgreSQL 22P02) | First user auto-bootstrapped as Admin |
| Second user joins | Manual DB insert or Clerk dashboard manipulation | Invite-by-email → right org, right role |
| Second org signs up | No org isolation enforcement beyond FK | Automatic org scoping at middleware level |
defaultAllow: true in production | Everyone sees everything or nothing | Default-deny, role-based access enforced |
Pitch-Prompt Deck
Five cards. Five headlines. Five pictures. The meme layer — 80 cents in the dollar.
| Card | Headline | Persuasion | Picture | Problem → Question → Decision |
|---|---|---|---|---|
| Principles | The owner can't get in | Ethos | Outcome Map | Auth succeeds but authorization fails → Fix the guard or fix the query? → Fix the query — 22P02 uses wrong ID type |
| Performance | 789 errors, zero revenue | Logos | Value Stream | Auth regression blocks all revenue PRDs → Which tier first? → Tier 0: fix query + break redirect loop |
| Platform | 80% built, 0% working | Topos | Dependency Map | Schema exists, guards deployed, no wiring → What blocks what? → User-Role query passes Clerk ID where UUID expected |
| Protocols | Every venture waits on this | Kairos | Capability Map | Kill date 27 days away, can't onboard customers → Can Tier 0 ship today? → Yes — two bugs, both have known fixes |
| Players | Builder locked from own house | Pathos | A&ID | Platform owner can't use platform → Who fixes, who commissions? → Engineering fixes query, dream team commissions via browser |
Priority Score
PRIORITY = Pain x Demand x Edge x Trend x Conversion
| Dimension | Score (1-5) | Evidence |
|---|---|---|
| Pain — What's broken? | 5 | Owner locked out NOW. 789 errors in 30 minutes. Revenue kill date 2026-03-24 — can't onboard paying customers without working auth |
| Demand — What is needed? | 3 | Every venture (Sales CRM, Content Amplifier, Prompt Deck) requires multi-tenancy. Table stakes for SaaS. Internal demand across all products, no external demand for multi-tenancy itself |
| Edge — What is everyone missing? | 2 | No unique edge — multi-tenancy is commodity infrastructure. Advantage: 80% already built (schema, guards, identity layer). Speed only |
| Trend — Where is this heading? | 3 | Multi-tenant SaaS is stable, mature market. Not growing, not shrinking. Every B2B product requires it |
| Conversion — Who needs convincing? | 4 | Direct path: fix auth → unblock Sales CRM → Stripe payments → first paying customer. No conversion gap — this is prerequisite infrastructure |
| Composite | 360 | Platform infrastructure — enables all revenue-generating PRDs. Tiebreaker: "enables other PRDs" = builds first |
The Incident
2026-02-24: Owner locked out of production dashboard. App completely inaccessible — infinite redirect loop between /dashboard and /sign-in?error=admin_access_required every ~400ms.
Root cause (from Vercel production logs, request ID dx89j-1771901662057-8dbab5d4899c):
- Authentication succeeds — Clerk validates session,
ClerkAuthAdapterresolves userId → systemUserId → organisationId. All correct. - User-Role query fails with PostgreSQL 22P02 (invalid text representation) — the role lookup passes the Clerk
userIdstring (user_36MjIJRckjpXRqdOHaZcgMpFjzE) to a column expecting UUID. ThesystemUserId(f388dbc7-cb98-41ee-9708-d6f5d2c54fe0) is the correct UUID to use. - Feature access denied — because the query errored (not because the role doesn't exist). The system logs
feature_access_deniedfor dashboard read. - Infinite redirect loop — dashboard denies access → redirects to
/sign-in?error=admin_access_required→ Clerk sees user IS authenticated →afterSignInUrl: "/dashboard"→ redirect back → loop.
The bug is in the User-Role query, not the role data. Even if roles were seeded correctly, the query would still fail because it's casting a string to UUID. Fix the query first, then seed the roles.
Evidence: 789 warnings + 789 errors in the Vercel logs within 30 minutes from the redirect loop alone.
What this reveals: Authentication and authorization are different systems with different failure modes. Authentication failing = "I can't get in." Authorization failing = "I got in but I can't do anything." The second is worse — it looks like a bug, erodes trust, and blocks revenue.
Current State Audit
What's built vs what's wired.
| Layer | Component | Built | Wired | Working |
|---|---|---|---|---|
| Authentication | Clerk middleware | Yes | Yes | Yes |
| Authentication | ClerkAuthAdapter (3-layer validation) | Yes | Yes | Yes |
| Identity | Auto-provision user + "Personal Workspace" org | Yes | Yes | Untested |
| Identity | org_system_user table (Clerk ID → internal UUID → org) | Yes | Yes | Yes |
| Identity | Branded ID types (ExternalAuthId, SystemUserId, OrganisationId) | Yes | Yes | Yes |
| Authorization | isOrgAdmin() guard | Yes | Yes | Blocks owner |
| Authorization | ADMIN_EMAILS env var check | Yes | Not configured | No |
| Authorization | governance_user_roles table | Yes | No seed data | No |
| Authorization | governance_permissions table | Yes | Schema only | No |
| Authorization | governance_role_permissions join | Yes | Schema only | No |
| Authorization | PolicyEngine (canI()) | Yes | defaultAllow: true | Bypassed |
| Multi-tenancy | org_organisation table | Yes | Yes | Yes |
| Multi-tenancy | Org-scoped data (all business tables FK to org) | Yes | Yes | Yes |
| Multi-tenancy | Org switching UI | No | No | No |
| Billing | org_subscription table | Yes | Schema only | No |
| Billing | Stripe customer on org | Yes | Not wired | No |
| Settings | Org-level settings | Partial | No | No |
| Settings | User-per-org settings | No | No | No |
| Analytics | Org-scoped events | No | No | No |
Summary: Authentication is solid. Multi-tenancy schema is solid. Authorization is a landmine — schema exists, guards deployed, but no data and no admin bootstrapping. Billing, settings, and analytics are unbuilt.
Multi-Tenant Checklist Audit
Every item from the Multi-Tenant SaaS Checklist, scored against the current codebase. This is the specification — every item must pass before multi-tenancy is commissioned.
1. Foundational Model
| Checklist Item | Status | Evidence |
|---|---|---|
Top-level tenant model exists (Organisation) | Pass | org_organisation table exists with branded OrganisationId type |
| Access model chosen and documented | Partial | GitHub-style model (user belongs to multiple orgs). Decision not documented in /docs/problem-solving/decisions/tech-decisions |
| Users can belong to multiple orgs | Pass | Composite unique on (external_auth_id, organisation_id) in org_system_user |
| Membership model exists | Partial | org_system_user + agent_profile + governance_user_roles chain. Checklist asks: would a single membership table simplify? |
| Personal accounts are single-user orgs | Pass | Auto-provision creates "Personal Workspace" org |
2. Data Isolation
| Checklist Item | Status | Evidence |
|---|---|---|
organisationId on every domain table | Pass | All business tables FK to org_organisation |
All DB reads scoped by organisationId | Unverified | Queries use organisation_id FK but no automated audit. No guarantee every repository method includes the filter |
All DB writes include organisationId | Unverified | Same — no enforcement layer, relies on developer discipline |
| Enforcement approach documented | Fail | Loose approach (separate FK + index) used but not documented. No compensating controls documented |
| Access layer enforces scoping | Partial | ApplicationContext carries organisationId but nothing prevents a repository method from ignoring it |
3. User-Org Association
| Checklist Item | Status | Evidence |
|---|---|---|
| Invitation uses membership-first pattern | Fail | No invitation flow exists. Users self-provision as "Personal Workspace" only |
| Items assigned to membership, not user | Partial | Deals, tasks reference agent_profile (org-scoped) but some may reference system_user directly |
| Revocation doesn't orphan data | Fail | No revocation flow exists. No reassignment logic |
| Invite-before-signup works | Fail | No pre-acceptance invite capability |
4. Organisation ID Propagation
| Checklist Item | Status | Evidence |
|---|---|---|
organisationId in URLs | Fail | URLs do not include org context. Resolved from session only. Tradeoff not documented |
organisationId in all server action inputs | Pass | Via ApplicationContext from ClerkAuthAdapter. No action trusts client-provided org ID |
organisationId in all repository calls | Unverified | Composition root injects context but audit not performed |
organisationId in all mutation inputs | Unverified | Same as above |
5. Authentication and Sessions
| Checklist Item | Status | Evidence |
|---|---|---|
| Session stores org context | Pass | ApplicationContext resolves org from ClerkAuthAdapter |
| Concurrent org sessions supported | Fail | Single active org per session. No concurrent access |
| Org switcher exists | Fail | No UI for switching orgs |
| Cross-org URL access works | Fail | No org in URL, no resolution logic for cross-org access |
| Auth provider decoupled | Partial | ClerkAuthAdapter abstracts Clerk, but no Principal type supporting multiple providers |
6. RBAC
| Checklist Item | Status | Evidence |
|---|---|---|
| Role stored per-org, not per-user | Pass | governance_user_roles links agentProfileId (org-scoped) to orgRoleId |
| Permissions defined as code-level constants | Pass | governance_permissions table with code field |
| Permission check on every protected endpoint | Fail | defaultAllow: true bypasses all checks. canI() exists but is never enforcing |
| Deny takes precedence over allow | Pass | governance_role_permissions.effect supports allow/deny |
| Default-deny policy | Fail | defaultAllow: true — the opposite of what's required |
| Role visible in session without DB call | Fail | Role requires DB query on every check |
| Temporal role assignments | Pass | effectiveFrom / effectiveUntil on governance_user_roles schema exists |
7. Billing and Subscriptions
| Checklist Item | Status | Evidence |
|---|---|---|
| Billing belongs to org, not user | Partial | Schema supports stripeCustomerId on org, but not wired |
| Per-seat billing counts memberships | Fail | No seat counting logic |
| Subscription lifecycle tables exist | Pass | org_subscription table with Stripe fields exists |
| Quota/usage tracking per org | Partial | api_organization_quota + api_usage_record tables exist in schema |
8. Settings
| Checklist Item | Status | Evidence |
|---|---|---|
| Org-level settings | Partial | Basic fields on org_organisation (name, type). No dedicated settings table |
| User-per-org settings | Fail | No per-org user settings on agent_profile |
| User global settings | Fail | No global settings on user record |
| Settings scoped correctly | N/A | No settings exist to scope |
9. Onboarding
| Checklist Item | Status | Evidence |
|---|---|---|
| New signup creates org + membership atomically | Unverified | Auto-provision creates user + org but transactional guarantee not verified |
| Domain-based auto-join | Fail | No findOrgByEmailDomain() exists |
| Onboarding state tracked | Fail | No onboarding state — half-onboarded users can access the app |
| Invitation acceptance links user to existing membership | Fail | No invitation flow |
10. Analytics
| Checklist Item | Status | Evidence |
|---|---|---|
| Analytics events include org context | Fail | No analytics events send organisationId |
| User linked to org in analytics provider | Fail | No analytics.group() call |
| Org-level analytics dashboards | Fail | No per-org analytics |
11. Security and Isolation
| Checklist Item | Status | Evidence |
|---|---|---|
| No cross-tenant data leakage (negative tests) | Fail | No negative security tests exist |
| Fail-closed on missing org context | Partial | ClerkAuthAdapter validates context, but defaultAllow: true undermines it |
| Client-supplied IDs never trusted for ownership | Pass | ApplicationContext resolves from session, not client input |
| Audit trail for access decisions | Pass | governance_access_audit table exists in schema |
| Soft-delete with tenant scoping | Partial | Soft-delete exists on some tables but not consistently applied |
12. Future-Proofing
| Checklist Item | Status | Evidence |
|---|---|---|
| Parent/child orgs | Fail | org_type field exists but no parent FK |
| Org-to-org collaboration | Fail | No cross-org access model |
| Sandbox/clone capability | Fail | No org cloning |
| SSO/SAML per org | Fail | Clerk supports it, not configured |
Audit Summary
| Section | Items | Pass | Partial | Fail | Unverified |
|---|---|---|---|---|---|
| Foundational Model | 5 | 3 | 2 | 0 | 0 |
| Data Isolation | 5 | 1 | 1 | 1 | 2 |
| User-Org Association | 4 | 0 | 1 | 3 | 0 |
| Org ID Propagation | 4 | 1 | 0 | 1 | 2 |
| Authentication & Sessions | 5 | 1 | 1 | 3 | 0 |
| RBAC | 7 | 3 | 0 | 3 | 0 |
| Billing & Subscriptions | 4 | 1 | 2 | 1 | 0 |
| Settings | 4 | 0 | 1 | 2 | 1 |
| Onboarding | 4 | 0 | 0 | 3 | 1 |
| Analytics | 3 | 0 | 0 | 3 | 0 |
| Security & Isolation | 5 | 2 | 2 | 1 | 0 |
| Future-Proofing | 4 | 0 | 0 | 4 | 0 |
| Total | 54 | 12 (22%) | 10 (19%) | 25 (46%) | 7 (13%) |
22% passing. Schema depth is real — most "Fail" items have tables built but no wiring, no data, no enforcement. The gap is operational, not architectural.
Demand-Side Jobs
Job 1: I Built This — Let Me In
Situation: Platform owner deploys the app. First user. No admin role exists yet. The guard that should protect the app from strangers is blocking the builder.
| Element | Detail |
|---|---|
| Struggling moment | Deployed production app, can't access my own dashboard |
| Current workaround | Manually set ADMIN_EMAILS env var on Vercel, or insert role row in database |
| What progress looks like | First user (or specified bootstrap user) automatically gets admin role. No manual DB intervention required |
| Hidden objection | "I don't want to weaken security just to fix onboarding" |
| Switch trigger | Every deploy where this breaks and costs debugging time |
Features that serve this job:
- Bootstrap admin: first user in an org OR
ADMIN_EMAILSenv var gets Admin role auto-assigned - Admin bootstrap runs on first login, not manual DB insert
-
ADMIN_EMAILSenv var documented and configured in Vercel production - If
isOrgAdmin()fails, show clear error message (not just a redirect param) - Admin status visible in user profile/settings
Job 2: Control Who Sees What
Situation: The platform grows. Sales team member joins. They should see their deals, not admin settings. A client gets a portal login. They should see their RFP status, not the pipeline.
| Element | Detail |
|---|---|
| Struggling moment | Everyone sees everything or everyone sees nothing — no middle ground |
| Current workaround | defaultAllow: true in governance config — no access control enforced |
| What progress looks like | Three tiers: Admin (everything), Member (own org data), Viewer (read-only portal) |
| Hidden objection | "RBAC is complex — we'll spend weeks on permissions instead of features" |
| Switch trigger | When a client sees another client's data, or a team member breaks something in admin |
Features that serve this job:
- Three roles defined: Admin, Member, Viewer
- Role assignment on invite/onboarding
- PolicyEngine switched from
defaultAllow: truetodefaultAllow: false - Per-resource permission checks on server actions
- Role-based UI: admin routes, member routes, viewer routes
- Audit trail for access decisions (governance_access_audit table already exists)
Job 3: Separate My Clients' Worlds
Situation: Platform serves multiple organisations. Each org's data must be isolated. An RFP from Company A must never appear in Company B's dashboard.
| Element | Detail |
|---|---|
| Struggling moment | Querying data without org context leaks across tenants |
| Current workaround | All queries scoped by organisation_id FK — but no automated enforcement |
| What progress looks like | Org context injected at the middleware level. Every query filtered automatically. Impossible to forget |
| Hidden objection | "Row-level security is hard to debug when things don't show up" |
| Switch trigger | First time a user sees data that isn't theirs |
Features that serve this job:
-
ApplicationContext.organizationIdalways present (enforced by ClerkAuthAdapter) - All repository queries filter by org ID (audit existing queries)
- Database-level RLS policies as defense-in-depth (Supabase/Postgres)
- Org switching for users who belong to multiple orgs
- Org creation flow (name, settings, invite first members)
Job 4: Invite Someone Without a Support Ticket
Situation: Admin wants to add a team member. They shouldn't need to ask a developer to insert a database row.
| Element | Detail |
|---|---|
| Struggling moment | No invite flow — users can only self-provision as Personal Workspace |
| Current workaround | Manual database manipulation or Clerk dashboard |
| What progress looks like | Admin sends email invite → recipient signs up → lands in the right org with the right role |
| Hidden objection | "What if I invite someone by mistake? Can I revoke?" |
| Switch trigger | When onboarding a second team member requires developer intervention |
Features that serve this job:
- Invite-by-email from admin settings
- Invite creates pending membership (accepted on sign-up)
- Role assigned at invite time
- Invite revocable before acceptance
- Member removable after acceptance
- Clerk organization sync (Clerk orgs ↔ internal orgs)
Job 5: Know What's Happening in My Org
Situation: Admin needs visibility into who did what, when, in their org. Billing needs to count seats. Settings need to stay within org boundaries.
| Element | Detail |
|---|---|
| Struggling moment | No org-level analytics, no billing integration, no settings scope |
| Current workaround | Vercel logs for activity, manual Stripe for billing, no per-org settings |
| What progress looks like | Org-level dashboard with activity, billing tied to org, settings scoped correctly |
| Hidden objection | "This is plumbing — customers don't pay for settings pages" |
| Switch trigger | First customer asks "how many seats am I using?" or "what did my team do this week?" |
Features that serve this job:
- Billing on org, not user —
stripeCustomerIdwired toorg_organisation - Seat counting from memberships
- Org-level settings page
- Per-org user preferences on
agent_profile - Analytics events include
organisationId - Org-scoped activity feed or audit log UI
Architecture: The Desired Flow
Authentication Flow (WHO are you?)
Browser → Clerk Middleware → Session Valid?
├── NO → /sign-in (public route)
└── YES → ClerkAuthAdapter.validateContext()
├── Clerk userId present?
│ ├── NO → INVALID_SESSION error
│ └── YES → Lookup in org_system_user
│ ├── FOUND → Return ApplicationContext
│ └── NOT FOUND → Auto-provision
│ ├── Create org_system_user
│ ├── Create "Personal Workspace" org
│ ├── Assign Admin role (bootstrap)
│ └── Return ApplicationContext
└── Error → LOOKUP_FAILED / PROVISION_FAILED
This flow exists and works. The gap: auto-provision creates the user and org but does NOT assign the Admin role.
Authorization Flow (WHAT can you do?)
Server Action called
→ buildService() → authAdapter.validateContext()
→ Returns ApplicationContext { userId, systemUserId, organizationId }
→ governance.policyEngine.canI(context, action, resourceType)
├── defaultAllow: true → ALLOW (current state — bypass)
└── defaultAllow: false → Check governance_role_permissions
├── Permission found → ALLOW
└── Permission not found → DENY → 403
Admin route guard (current):
/admin/layout.tsx
→ isOrgAdmin()
→ Check 1: Is email in ADMIN_EMAILS env var? → YES = admin
→ Check 2: Does user have "Admin" role in governance_user_roles? → YES = admin
→ Neither? → redirect('/dashboard?error=admin_access_required')
Multi-Tenancy Model
Clerk User (external)
└── org_system_user (internal, branded SystemUserId)
└── org_organisation (tenant boundary)
├── All business data scoped here
├── governance_user_roles (what can this user do in this org?)
└── governance_permissions (what actions exist?)
One user can belong to multiple orgs (composite unique: external_auth_id + organisation_id)
Data Flow: Org ID Propagation
Request arrives
→ Clerk Middleware (validates session cookie)
→ ClerkAuthAdapter.validateContext()
→ Resolves: externalAuthId → systemUserId → organisationId
→ Returns ApplicationContext { systemUserId, organisationId, ... }
→ buildService(context)
→ Every repository method receives organisationId
→ Every SQL query includes WHERE organisation_id = $1
→ Response scoped to tenant
The enforcement gap: Nothing prevents a repository method from omitting the WHERE clause. The fix is either:
- Code-level: base repository class that requires
organisationId(Tier 2) - Database-level: Postgres RLS policies as defense-in-depth (Tier 3)
Implementation Tiers
Tier 0: Unblock Owner (Immediate)
Fix the infinite redirect loop. Two bugs, both must be fixed.
| Task | What | Where | Why |
|---|---|---|---|
| T0.1 | Fix User-Role query: use systemUserId not Clerk userId | Wherever governance_user_roles is queried — the query casts a user_ prefixed string to UUID (PostgreSQL 22P02) | This is the crash. Even with correct data, the query errors |
| T0.2 | Break the redirect loop | Dashboard and/or sign-in page — when access is denied to an authenticated user, show an error page instead of redirecting to sign-in (which redirects back) | Infinite loop burns 789 errors in 30 minutes |
| T0.3 | Set ADMIN_EMAILS env var in Vercel production | Vercel dashboard → Environment Variables | Workaround: isOrgAdmin() checks this BEFORE the DB query |
| T0.4 | Seed Admin role in org_roles table | Migration or seed script | The role must exist before it can be assigned |
| T0.5 | Assign Admin role to owner in governance_user_roles | Migration or seed script | Owner needs the role row |
| T0.6 | Verify: owner can access /dashboard and /admin routes | Browser commissioning protocol | Dream team validates via browser, not code review |
Success criteria: Owner logs in → lands on dashboard → no redirect loop → can access /admin. Verified by nav (dream team) via browser commissioning.
Tier 1: Bootstrap Admin on First Login
Make Tier 0 unnecessary for future deploys.
| Task | What | Where |
|---|---|---|
| T1.1 | During auto-provision, assign Admin role to first user in org | ClerkAuthAdapter or provisioning service |
| T1.2 | During auto-provision, check ADMIN_EMAILS — if match, assign Admin | Same |
| T1.3 | Show meaningful error on dashboard when admin_access_required | Dashboard page component |
| T1.4 | Add admin status indicator to user profile | Settings page |
| T1.5 | Verify auto-provision is transactional (rollback on failure) | Provisioning service |
Success criteria: Fresh deploy → first sign-up → automatic admin → full access.
Tier 2: Role-Based Access Control
Switch from defaultAllow: true to enforced permissions.
| Task | What | Where |
|---|---|---|
| T2.1 | Define three roles: Admin, Member, Viewer | org_roles seed data |
| T2.2 | Define permissions per role | governance_role_permissions seed data |
| T2.3 | Switch defaultAllow to false | Composition root config |
| T2.4 | Add canI() checks to all server actions | Each action file |
| T2.5 | Route-level guards: admin routes, member routes, viewer routes | Layout components |
| T2.6 | Base repository class enforces organisationId on every query | Repository layer |
| T2.7 | Test: Member cannot access admin routes | Integration test |
| T2.8 | Test: Viewer sees read-only UI | Integration test |
| T2.9 | Test: User in org1 cannot access org2 data (negative test) | Security test |
Success criteria: defaultAllow: false in production. No permission = no access. Audit log captures every deny.
Tier 3: Multi-Org & Invites
Full multi-tenancy operations.
| Task | What | Where |
|---|---|---|
| T3.1 | Invite-by-email flow (admin settings) | New UI + API route |
| T3.2 | Pending invite → accepted → org membership | Invitation service |
| T3.3 | Org switcher in nav (for multi-org users) | Nav component |
| T3.4 | Org creation flow | Settings page |
| T3.5 | Member management (view, remove, change role) | Admin settings |
| T3.6 | Sync Clerk organizations ↔ internal orgs | Webhook handler |
| T3.7 | Database-level RLS as defense-in-depth | Postgres policies |
| T3.8 | Items assigned to membership — audit agent_profile vs system_user references | Domain models |
Success criteria: Admin invites user by email → user signs up → lands in correct org with correct role → admin can manage members from UI.
Tier 4: Billing, Settings, Analytics
Operational infrastructure for paying customers.
| Task | What | Where |
|---|---|---|
| T4.1 | Wire stripeCustomerId to org_organisation | Stripe integration |
| T4.2 | Seat counting from org memberships | Billing service |
| T4.3 | Subscription lifecycle wired (org_subscription → Stripe webhooks) | Payment flow |
| T4.4 | Org-level settings page | Admin settings UI |
| T4.5 | User-per-org settings on agent_profile | Settings service |
| T4.6 | Analytics events include organisationId | Event tracking |
| T4.7 | analytics.group() call links user to org | Analytics provider |
| T4.8 | Org-scoped activity dashboard | Admin UI |
Success criteria: Admin can see billing, manage settings, view org-scoped analytics. Stripe invoices go to the org, not the user.
Tier 5: Hardening
Defense-in-depth and future-proofing.
| Task | What | Where |
|---|---|---|
| T5.1 | Negative security tests: cross-tenant data leakage per endpoint | Test suite |
| T5.2 | Fail-closed on missing org context (remove defaultAllow entirely) | Middleware |
| T5.3 | Audit trail UI (read governance_access_audit) | Admin UI |
| T5.4 | Soft-delete consistency audit | Schema review |
| T5.5 | Document enforcement approach (loose FK vs compound PK) | /docs/problem-solving/decisions/tech-decisions |
| T5.6 | Parent/child orgs (enterprise) | Schema + service |
| T5.7 | SSO/SAML per org via Clerk | Auth config |
Success criteria: No cross-tenant leakage under any role. Security tests automated. Enforcement approach documented.
Role Definitions
| Role | Dashboard | CRM Data | RFP Data | Admin Settings | Org Management | Invite Users |
|---|---|---|---|---|---|---|
| Admin | Full | Full CRUD | Full CRUD | Full | Full | Yes |
| Member | Own org | Full CRUD | Full CRUD | View only | No | No |
| Viewer | Read only | Read only | Read only | No | No | No |
Permission Model
Permissions follow the pattern already in governance_permissions schema:
{resource_type}:{action}
| Resource Type | Actions | Admin | Member | Viewer |
|---|---|---|---|---|
dashboard | read | Yes | Yes | Yes |
contact | read, create, update, delete | All | All | read |
deal | read, create, update, delete | All | All | read |
rfp_project | read, create, update, delete | All | All | read |
admin_settings | read, update | All | read | No |
org_members | read, invite, remove, change_role | All | No | No |
org_settings | read, update | All | No | No |
billing | read, update | All | No | No |
analytics | read | All | read | No |
Commissioning
Browser-based validation against this PRD. The builder never validates their own work.
| Component | Tier | Status | How to Verify |
|---|---|---|---|
| Owner can access /dashboard | T0 | Blocked | Navigate to dreamineering.com/dashboard, confirm no redirect loop |
| Owner can access /admin | T0 | Blocked | Navigate to dreamineering.com/admin, confirm admin panel renders |
ADMIN_EMAILS env var configured | T0 | Fail | Check Vercel dashboard → Environment Variables |
| Roles seeded in database | T0 | Fail | Admin panel → user management (or direct DB query) |
| Auto-bootstrap Admin on first login | T1 | Not started | Fresh deploy, new user sign-up, verify Admin role assigned |
| Error page instead of redirect loop | T1 | Not started | Unauthenticated access → clear error message visible |
| Three roles defined (Admin, Member, Viewer) | T2 | Not started | Admin panel → roles management |
defaultAllow: false enforced | T2 | Not started | Viewer role cannot access admin routes |
| Cross-tenant isolation (negative test) | T2 | Not started | Log in as user in org1, attempt to access org2 data via URL manipulation |
| Invite-by-email works end-to-end | T3 | Not started | Admin sends invite → recipient signs up → lands in correct org |
| Org switcher functional | T3 | Not started | User with 2+ orgs can switch between them |
| Stripe billing on org | T4 | Not started | Payment creates invoice tied to org, not user |
| Org-scoped analytics visible | T4 | Not started | Admin sees activity scoped to their org only |
| No cross-tenant leakage (security test suite) | T5 | Not started | Automated test suite passes for all endpoints |
Risk + Kill Signal
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Auth regression blocks every deploy | H | H | Tier 0 is immediate. Tier 1 prevents recurrence |
defaultAllow: true leaks data before Tier 2 | M | H | All data already scoped by org FK. defaultAllow affects route guards, not data queries |
| RBAC complexity delays revenue-generating features | M | M | Three roles only. No custom permissions until proven needed |
| Clerk lock-in prevents auth provider switch | L | M | ClerkAuthAdapter abstracts Clerk. Adding a new provider = new adapter, same interface |
| Cross-tenant leakage discovered by customer | L | H | Negative security tests in Tier 2. RLS in Tier 3. Defense-in-depth |
Kill signal: None — this is mandatory infrastructure. Every SaaS product requires multi-tenancy. The question is not "should we build this" but "in what order." Kill signals apply to the ventures that depend on this, not to this capability itself.
Business Validation
| Question | Answer |
|---|---|
| Problem exists? | Yes — owner locked out of own production app right now |
| People pay for this? | Table stakes. Every SaaS needs auth/authz. Not a differentiator — a requirement |
| Can we deliver? | 80% built. Schema exists. Guards exist. Gap is wiring + seed data + bootstrap logic |
| Unit economics? | Clerk free tier covers initial usage. No additional infrastructure cost |
| Kill signal? | None — this is mandatory infrastructure. Ship or don't ship the platform |
Success Criteria
| Criteria | Metric | Target | How to Measure |
|---|---|---|---|
| Owner access | Login → dashboard without redirect | Zero redirect loops | Browser commissioning |
| Admin bootstrap | First user in new org gets Admin role | 100% of new orgs | Auto-provision test |
| ADMIN_EMAILS | Env var set and documented | Configured in Vercel | Vercel dashboard check |
| Error messaging | Human-readable error on access denied | No raw query params | Browser commissioning |
| Role seeding | Three roles in database | Admin, Member, Viewer | DB query or admin panel |
| Default-deny | defaultAllow: false in production | No bypassed permissions | Config review + integration test |
| Invite flow | Email invite → sign-up → correct org + role | End-to-end works | Browser commissioning |
| Cross-tenant isolation | User in org1 cannot access org2 data | Zero leakage | Negative security test |
| Billing on org | Stripe customer tied to org | Invoice shows org name | Stripe dashboard |
| Checklist compliance | Multi-Tenant SaaS Checklist items passing | 80%+ Pass (currently 22%) | Re-audit after each tier |
Context
- Multi-Tenant SaaS Checklist — the engineering standard this PRD must satisfy (54 items, 12 passing)
- Browser Commissioning Protocol — how dream team validates fixes
- Sales CRM & RFP — the product blocked by this regression
- Hexagonal Architecture — the pattern that makes isolation enforceable
- Identity and Security — auth provider decisions
- Clerk Documentation — authentication provider
- Jobs to Be Done — demand-side thinking
- Commissioning Dashboard — maturity tracking