Security-First Architecture — Final Four-Brain Consensus

OA Portal Architecture

Four-brain consensus: Sterling (Claude Opus), Claude cross-check, ChatGPT 5.5, and Sterling's final reconciliation. Definitive architecture for Client Portal, Employee Portal, and Admin Panel.

Four-Brain Consensus: Final Architecture Decision

All AI systems independently converged on the same core architecture.

Sterling (Claude Opus), Brad's Claude cross-check, ChatGPT 5.5, and Sterling's final reconciliation all agree: two completely separate production databases (employee vs. client), managed authentication via Clerk, one shared client database with strict tenant middleware, and one unified admin panel that orchestrates both systems.

ChatGPT 5.5 provided the critical correction on client-side isolation. The original recommendation of ~150 per-client D1 databases was over-engineering for purity. A single shared client database with the TenantScopedDB middleware pattern is the correct architecture for OA's scale and growth trajectory. Brad agrees with 95% of ChatGPT's evaluation.

What All Four Brains Agree On

  • These are two different products, not two user types. The Client Portal and Employee Portal have different navigation, different data sources, different permission models, and different evolution trajectories. They happen to be operated by the same company. That's it.
  • Two separate D1 databases (employee vs. client). Blast-radius containment. A breach of the client-facing surface physically cannot reach employee payroll, health insurance, or HR data. The connection does not exist at the infrastructure level.
  • Managed auth provider (Clerk). Authentication is easy to get 95% right and catastrophic when the remaining 5% fails. Clerk is free for OA's scale (~900 total users) and SOC 2 certified.
  • ONE shared client database with strict tenant architecture. Every table has a company_id column. Every query is automatically scoped through TenantScopedDB middleware. Developers never write raw queries. Cross-client leaks are structurally prevented at the application layer.
  • One unified admin panel (an orchestrator, NOT a third database). A single Worker at admin.outsourceaccess.com binds to both databases. OA staff push content to either portal from one interface. The admin panel is a bridge, not a data store.
  • Authentication is not authorization. Separate logins do not prevent data leaks. Server-side authorization enforcement on every request, with identity derived from cryptographically signed JWTs (never from browser-sent parameters), is mandatory.

What Each Cross-Check Added

  • Claude cross-check pushed harder on enforcement mechanics: "Show me endpoint by endpoint." "Include concrete test scenarios." "Don't tell me it's isolated, show me." This forced the detailed code patterns, authorization middleware, and GIVEN/WHEN/THEN test scenarios documented throughout this document.
  • ChatGPT 5.5 added the "two different products" framing, emphasized long-term architectural evolution, and pushed back hard on per-client databases. Its key insight: "Optimize for the architecture you'll have at 5,000 clients, not just 150." At 5,000 clients, 5,000 separate databases is operationally untenable. The TenantScopedDB middleware pattern is battle-tested at massive scale by every major SaaS company.
  • Sterling's reconciliation synthesized both cross-checks. The per-client DB approach was rejected in favor of ChatGPT's shared tenant model. The Claude cross-check's enforcement rigor was preserved. The result is a cleaner, more scalable architecture.
  • All cross-checks correctly identified that cost-driven reasoning was clouding the original analysis. With cost explicitly off the table, the decision becomes purely about security, maintainability, and independent evolution.

Where the Original Recommendation Was Corrected

  • Per-client D1 databases (~150 separate databases): REJECTED. ChatGPT 5.5 demonstrated that every schema migration, backup, reporting query, analytics query, AI model, and index must happen 150 times. At OA's growth target of 5,000 clients, this becomes 5,000 separate databases. Cross-client analytics becomes impractical. The TenantScopedDB middleware pattern already makes the shared approach nearly as secure, and it's the pattern used by every major SaaS company at scale.
  • "Team dashboards" under Client Portal: Incorrect. Team dashboards are internal OA tools and belong on the Employee Portal side. Client Portal shows VA performance, industry research, resources, satisfaction history, and owner-only feedback. Fixed in this version.
  • Cost as a factor: Sterling's original analysis weighed "simplicity" and "cost savings" in the architecture decision. Brad explicitly directed: cost is not a factor at OA's scale (~900 users). When cost is irrelevant, always choose the option that maximizes security, isolation, and independent evolution.

Final Architecture Diagram

graph TD
    subgraph "Authentication Layer"
        CK[Clerk - Managed Auth
Free up to 10K MAU, SOC 2] end subgraph "Application Layer" CW[Client Worker
clients.outsourceaccess.com] EW[Employee Worker
server.outsourceaccess.com] AW[Admin Worker
admin.outsourceaccess.com
Orchestrator] end subgraph "Data Layer" CDB[Client DB
oa-client-db
Shared tenant architecture
company_id on every table] EDB[Employee DB
oa-employee-db
Payroll, HR, Benefits] end CK --> CW CK --> EW CK --> AW CW --> CDB EW --> EDB AW -->|"Binding 1"| CDB AW -->|"Binding 2"| EDB style CK fill:#48A7DB,color:#fff,stroke:#0c4da2 style CW fill:#0c4da2,color:#fff style EW fill:#362456,color:#fff style AW fill:#7c3aed,color:#fff style CDB fill:#059669,color:#fff style EDB fill:#dc2626,color:#fff
ChatGPT 5.5's Key Insight (Worth Preserving)

"Optimize for the architecture you'll have at 5,000 clients, not just 150. Every schema migration, backup, reporting query, analytics query, AI model, and index must happen N times with per-client databases. At 5,000 clients, that's operationally untenable. The TenantScopedDB middleware pattern already makes the shared approach nearly as secure. Cross-client analytics becomes trivial with a shared DB (one query with GROUP BY) vs. querying 5,000 separate databases."

Detailed Sections

  1. Authentication vs. Authorization (The Core Distinction)
  2. Two Separate Production Databases (Employee vs. Client)
  3. Client Tenant Isolation (Shared DB with TenantScopedDB Middleware)
  4. Authentication Provider (Clerk)
  5. Cross-Domain VA Reference (Employees on Client Dashboards)
  6. Admin Panel as Orchestrator
  7. Concrete Test Scenarios
  8. Scale Architecture Principle
  9. Security Checklist
  10. Endpoint-by-Endpoint Authorization Map

Authentication vs. Authorization

The Wrong Assumption to Correct

Separate login pages do NOT mean data is isolated. Authentication answers "who is this person?" Authorization answers "what can this person access?" A system can authenticate someone perfectly and still hand them data they should never see if authorization is not enforced on every single request, server-side, without trusting anything the client sends.

Authentication = verifying identity. "This session belongs to user #472, who is a client_owner at ABC Landscaping." This happens once at login. Clerk handles this and issues a signed JWT.

Authorization = enforcing access rules on every request. "User #472 is requesting the performance dashboard for company #38. Is user #472 authorized to see company #38's data?" This happens on every single API call, server-side, by deriving the user's identity from the signed JWT and checking it against the database.

How Authorization Is Enforced: Endpoint by Endpoint

The server NEVER trusts a company_id, tenant_id, or user_id sent from the browser. Identity is always derived from the JWT, and permissions are checked against the database before any data is returned.

Middleware: Authorization Enforcement (runs on EVERY request) // This middleware runs before any endpoint handler. // It extracts identity from the signed JWT (not from query params, // not from request body, not from anything the browser controls). async function authorizationMiddleware(request, env) { // Step 1: Extract the JWT from the cookie or Authorization header const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return new Response('Unauthorized', { status: 401 }); // Step 2: Verify the JWT signature (Clerk's public key) // If the JWT is forged or expired, this fails. Game over for the attacker. const claims = await verifyClerkJWT(token, env.CLERK_PUBLIC_KEY); // Step 3: From the verified JWT, extract the user ID // This ID comes from Clerk's signed token, NOT from the request. const userId = claims.sub; // e.g., "user_2xK9mN..." // Step 4: Look up this user in OUR database to get their role + scope const user = await env.CLIENT_DB.prepare( 'SELECT id, company_id, role FROM client_users WHERE clerk_id = ?' ).bind(userId).first(); if (!user) return new Response('Forbidden', { status: 403 }); // Step 5: Attach server-derived identity to the request context // Downstream handlers use THIS, never anything from the request body request.auth = { userId: user.id, companyId: user.company_id, // server-derived, not client-sent role: user.role // "client_owner" | "client_manager" | "client_viewer" }; return null; // proceed to handler }
Endpoint Example: GET /api/client/performance // The company_id is NEVER taken from the URL or request body. // It comes from request.auth.companyId, which was derived server-side. async function getClientPerformance(request, env) { const { companyId, role } = request.auth; // Even if an attacker sends ?company_id=OTHER_COMPANY in the URL, // we ignore it. We use the server-derived companyId. const data = await request.scopedDb.select('va_performance'); // TenantScopedDB automatically filters: WHERE company_id = [server-derived] return Response.json(data.results); }
Endpoint Example: GET /api/client/feedback (owner-only data) async function getStaffFeedback(request, env) { const { companyId, role } = request.auth; // Authorization check: only client_owner can see staff feedback if (role !== 'client_owner') { return new Response('Forbidden', { status: 403 }); } const data = await request.scopedDb.select('staff_feedback'); // Automatically scoped to this company only return Response.json(data.results); }

Key Principle: Server-Side Identity Derivation

  • The browser sends a JWT (set by Clerk at login). That is the only piece of client-sent data we trust, because it is cryptographically signed and verified against Clerk's public key.
  • From the verified JWT, we extract the user ID. From the user ID, we look up the company_id and role in our database.
  • The company_id and role are NEVER sent by the browser. They are derived server-side, every time, on every request.
  • If an attacker steals a session token, they can only access data for the company that token is associated with. They cannot pivot to another company's data because the company_id is bound to the session server-side.
  • "We tag content by client" is NOT the answer. The answer is: we derive the company identity from a cryptographically verified JWT and use that derived identity as the sole filter on every database query, enforced automatically through the TenantScopedDB middleware.

Two Separate Production Databases: Employee vs. Client

Two completely separate D1 databases. Client data and employee data never share a store.

Employee HR data (payroll, health insurance, pay stubs) is a higher sensitivity class than client business performance data. A breach of the client portal must be structurally unable to expose employee payroll. Separate databases enforce this at the infrastructure level.

D1 Database: oa-employee-db

  • ~400 OA employees
  • Payroll records + pay stubs
  • Health insurance enrollment
  • Department/division KPIs for leaders
  • HR announcements, holiday calendars
  • Strategic initiatives (admin-pushed)
  • Sensitivity: HIGH (PII, compensation, health)

D1 Database: oa-client-db (Shared Tenant Architecture)

  • ~150 client companies today, scaling to 5,000+
  • Every table has company_id column
  • VA performance dashboards (HubSpot)
  • Time tracking (Time Doctor)
  • Industry research, resources
  • Satisfaction surveys, staff feedback
  • Sensitivity: MEDIUM (business data)

Why Separate Databases (Not Just Separate Tables)

Blast-Radius Containment

  • Infrastructure-level isolation: The client portal Worker binds to oa-client-db. It physically cannot query oa-employee-db because it has no binding to it. A full remote code execution exploit in the client portal Worker still cannot reach employee payroll because the connection does not exist at the Cloudflare runtime level.
  • Different sensitivity classes: Employee compensation and health data may trigger compliance requirements (SOC 2, data residency, retention policies) that client business data does not. Separate stores let you apply different policies cleanly.
  • Independent failure domains: A bad migration on the employee schema cannot lock or corrupt the client database. A D1 storage quota event on one cannot affect the other.
  • Zero data overlap: No table in the employee database has a foreign key relationship to any table in the client database. They share no schema. Combining them into one database would be two databases sharing a container for no reason.
wrangler.toml for client portal Worker (single D1 binding) # The client Worker has NO binding to the employee database. # Even if an attacker achieves code execution in this Worker, # env.EMPLOYEE_DB does not exist. The variable is undefined. [[d1_databases]] binding = "CLIENT_DB" database_name = "oa-client-db" database_id = "<client-db-id>"
wrangler.toml for employee portal Worker (single D1 binding) # The employee Worker has NO binding to the client database. # Same principle: separate runtime, separate data access. [[d1_databases]] binding = "EMPLOYEE_DB" database_name = "oa-employee-db" database_id = "<employee-db-id>"

Client Tenant Isolation: ONE Shared Database with TenantScopedDB

One shared client database with strict tenant middleware. NOT 150+ per-client databases.

ChatGPT 5.5 pushed back hard on per-client databases, and Brad agrees. The TenantScopedDB middleware pattern makes cross-client leaks structurally impossible at the application layer. It is the same pattern used by every major SaaS company (Salesforce, HubSpot, Stripe, Shopify) and scales to thousands of tenants without operational overhead.

Why Per-Client Databases Were Rejected

The Operational Reality at Scale

  • Schema migrations multiply. Every time a column is added or a table is changed, the migration must execute against 150 databases today, and 5,000 databases at OA's growth target. One migration failure in database #2,847 means a partially-migrated fleet that is painful to debug and reconcile.
  • Backups multiply. 150 backup schedules. 150 retention policies. 150 restore procedures. At 5,000 clients, this becomes a full-time infrastructure job.
  • Cross-client analytics becomes impractical. "What is the average satisfaction score across all clients?" With per-client databases, that means querying 150+ separate databases and aggregating the results. With one shared database, it's a single SELECT AVG(score) FROM satisfaction_surveys GROUP BY company_id.
  • AI and reporting become painful. Training a model on aggregated client data, building reports across the portfolio, running business intelligence queries... all of this requires cross-database orchestration when data is split per-client. With a shared database, it's just SQL.
  • Index tuning multiplies. Each database needs its own index analysis, performance monitoring, and optimization. At scale, this is a serious operational burden.
  • ChatGPT's summary: "The per-client approach was over-engineering for purity when discipline-based isolation via middleware is battle-tested at massive scale."

The Recommended Architecture: TenantScopedDB Middleware

The key insight: developers never write SELECT * FROM performance. They write db.performance.for_company(current_company), where current_company comes from the authenticated session. The middleware makes it structurally impossible to forget the tenant filter.

TenantScopedDB: Automatic company_id scoping on every query // Instead of letting handlers write raw SQL, they use this builder. // It ALWAYS injects the company_id filter. Cannot be bypassed. // Handlers receive request.scopedDb, NEVER env.CLIENT_DB directly. class TenantScopedDB { constructor(db, companyId) { this.db = db; this.companyId = companyId; } // Every SELECT automatically includes WHERE company_id = ? async select(table, columns = '*', extraWhere = '', extraParams = []) { const where = extraWhere ? `WHERE company_id = ? AND (${extraWhere})` : 'WHERE company_id = ?'; return this.db.prepare( `SELECT ${columns} FROM ${table} ${where}` ).bind(this.companyId, ...extraParams).all(); } // INSERT automatically includes company_id column async insert(table, data) { data.company_id = this.companyId; // forced, regardless of input const cols = Object.keys(data).join(', '); const placeholders = Object.keys(data).map(() => '?').join(', '); return this.db.prepare( `INSERT INTO ${table} (${cols}) VALUES (${placeholders})` ).bind(...Object.values(data)).run(); } // UPDATE scoped to company_id (cannot update another company's rows) async update(table, data, where, params = []) { const sets = Object.keys(data).map(k => `${k} = ?`).join(', '); return this.db.prepare( `UPDATE ${table} SET ${sets} WHERE company_id = ? AND (${where})` ).bind(...Object.values(data), this.companyId, ...params).run(); } // DELETE scoped to company_id async delete(table, where, params = []) { return this.db.prepare( `DELETE FROM ${table} WHERE company_id = ? AND (${where})` ).bind(this.companyId, ...params).run(); } } // Usage in middleware: handlers receive a scoped DB, not the raw DB request.scopedDb = new TenantScopedDB(env.CLIENT_DB, request.auth.companyId);
With the scoped builder, handlers cannot bypass the filter // Handler ONLY has access to request.scopedDb, never env.CLIENT_DB directly. // Every query automatically includes WHERE company_id = [server-derived value]. async function getPerformance(request) { // This is the pattern ChatGPT recommended: // db.performance.for_company(current_company) const data = await request.scopedDb.select('va_performance'); return Response.json(data.results); } // Even if someone writes a bad query, scopedDb enforces the filter. // The raw env.CLIENT_DB binding is only available in the middleware, // never passed to individual route handlers.

Client Database Schema

All tables contain a company_id column. All queries are automatically scoped by the TenantScopedDB middleware. Here is the recommended schema from ChatGPT 5.5's evaluation.

TablePurposeKey Columns
Companies Master record for each client company id, name, industry, plan_tier, onboarded_at, hubspot_company_id
ClientUsers People who log in to the Client Portal id, company_id, clerk_id, role, email, name
VirtualAssistants VA assignments visible to client (display-only) id, company_id, display_name, photo_url, role_title, start_date, employee_ref_id
TimeDoctorMetrics Time tracking data pulled from Time Doctor API id, company_id, va_id, date, hours_worked, productive_pct
HubSpotMetrics Performance data pulled from HubSpot id, company_id, va_id, period, metric_type, value
Resources Industry research, playbooks, guides id, company_id, title, type, industry_tag, content_url
PerformanceHistory Historical performance snapshots for trend analysis id, company_id, va_id, snapshot_date, satisfaction_score, kpi_data
IndustryResearch Industry-specific insights and benchmarks id, company_id, industry, research_type, content, published_at
Why This Pattern Works at Scale

The TenantScopedDB middleware creates a structural barrier that is almost as strong as separate databases. Developers never touch the raw database binding. They only interact with request.scopedDb, which automatically scopes every operation to the authenticated company. A new developer cannot accidentally write a cross-tenant query because the raw DB is simply not available in handler scope. This is the same pattern Salesforce, Shopify, and HubSpot use across millions of tenants.

Authentication: Managed Provider (Clerk)

Brad's directive: cost is off the table. Evaluate on security alone.

Recommendation: Clerk (managed auth provider)

Authentication is the one component where getting it 95% right is worse than getting it 100% right. The 5% you miss (token rotation edge cases, session fixation, cookie SameSite behavior across browsers, brute-force protection, credential stuffing mitigation) are exactly the attack vectors that sophisticated attackers exploit. Clerk's team works on nothing but these problems. Sterling can build auth in 8 minutes, but Clerk's team has prevented auth bugs Sterling would never anticipate because they see millions of auth requests across thousands of apps.

Managed Auth (Clerk)

  • Security surface area Sterling does NOT manage: Password hashing, salt generation, timing-safe comparison, bcrypt cost factor tuning, credential stuffing detection, brute-force rate limiting, CAPTCHA integration, session token rotation, refresh token revocation, cookie security flags (HttpOnly, Secure, SameSite), CSRF protection, OAuth flow security, JWT signing key rotation
  • Compliance: SOC 2 Type II certified. They maintain the audit trail, not us.
  • Multi-factor auth: Built in. TOTP, SMS, email magic links. We turn it on with a flag.
  • Session management: Automatic token rotation, device tracking, session revocation from dashboard.
  • SSO/SAML: Available if enterprise clients want to use their own identity provider. INFERRED Not needed today but eliminates a future rebuild.
  • Free for the first 10,000 monthly active users. OA has ~900 total users. $0 for the foreseeable future.

Hand-Built Auth

  • Security surface area Sterling DOES manage: All of the items listed to the left. Every one is a potential vulnerability if implemented imperfectly.
  • Sterling builds fast, but auth bugs are subtle: A timing side-channel in password comparison leaks information. A missing SameSite=Lax flag enables CSRF. A refresh token that does not get rotated enables persistent session hijacking. These are not bugs you find in testing. They are bugs attackers find in production.
  • Ongoing maintenance: When Chrome ships a new cookie policy (they do this regularly), when Apple changes WebKit session storage behavior, when a new class of session fixation attack is published, Sterling has to update. Clerk does this automatically.
  • The "warranty" argument: Clerk is a specialist whose entire business depends on auth being correct. Hand-built auth has no warranty. Sterling IS the warranty, and every hour spent debugging an auth edge case is an hour not spent on revenue-moving work.
How Clerk Fits the Architecture

Clerk handles identity (login, session tokens, MFA). OA's D1 databases handle authorization (which company does this user belong to, what role do they have, what data can they see). Clerk's JWT tells us WHO. Our database tells us WHAT THEY CAN DO. The two are separate concerns, as they should be.

Clerk integration in the Worker import { verifyToken } from '@clerk/backend'; async function authenticateRequest(request, env) { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); // Clerk verifies the JWT signature, checks expiry, validates issuer const payload = await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY, }); // Clerk gives us a verified user ID. Now we look up authorization. const user = await env.CLIENT_DB.prepare( 'SELECT id, company_id, role FROM client_users WHERE clerk_id = ?' ).bind(payload.sub).first(); if (!user) throw new Error('User not found in authorization database'); return user; // { id, company_id, role } }

Cross-Domain Link: VAs on Client Dashboards

VAs are OA employees. Their names and performance metrics appear on client dashboards. This creates a cross-domain reference between the employee database and the client database. The solution: narrow reference, not database merging.

Approach: VA Summary in the Client Database (VirtualAssistants Table)

The client database contains a VirtualAssistants table with the minimal information a client needs to see: VA display name, profile photo URL, start date, and role. This is NOT a copy of the employee record. It is a purpose-built, minimal reference that contains only what the client portal needs to render the dashboard.

  • What the client DB stores: display_name, photo_url, start_date, role_title, employee_ref_id (opaque reference, not the employee's internal DB primary key).
  • What the client DB does NOT store: Payroll, health insurance, personal email, phone number, department assignments, KPI scores, HR records. None of that exists in the client database.
  • Sync mechanism: A scheduled Worker job (Cron Trigger) reads minimal VA assignment info from the employee DB and writes the summary fields to the client DB. This runs on admin infrastructure (admin Worker, which has both bindings). INFERRED
  • The employee_ref_id is opaque: It is a UUID or hash, not the employee's D1 primary key. Even if a client somehow obtained this ID, they could not use it to query the employee database because the client portal Worker has no binding to that database.
  • Tenant-scoped: Like every other table, VirtualAssistants has a company_id column and is filtered by the TenantScopedDB middleware. Client A sees only their assigned VAs, never Client B's.
graph LR
    subgraph EmpDB["oa-employee-db"]
        E["employees table
Full record: name, email,
payroll, health, department"] end subgraph Sync["Admin Worker (Cron)"] S["Copies ONLY:
display_name, photo_url,
start_date, role_title"] end subgraph ClientDB["oa-client-db"] V["VirtualAssistants table
Minimal: display_name,
photo_url, start_date,
role_title, employee_ref_id
+ company_id (tenant scoped)"] end E -->|"Minimal fields only"| S S -->|"Write summary"| V style EmpDB fill:#1a1a2e,stroke:#dc2626,color:#fff style ClientDB fill:#1a1a2e,stroke:#059669,color:#fff style Sync fill:#48A7DB,stroke:#48A7DB,color:#fff

The employee database keeps the full record. The client database gets a purpose-built summary with only what the client dashboard needs. No database merging. No shared tables. No foreign key relationships across databases.

Admin Panel: An Orchestrator, NOT a Third Database

The admin panel is a bridge between two systems. It does not store its own data.

ChatGPT 5.5 nailed the framing: the admin panel at admin.outsourceaccess.com is a Cloudflare Worker that binds to BOTH the employee database and the client database. It can read from and write to either system. But it is not a third database. It is an orchestration layer that lets OA staff manage both portals from a single interface.

Admin Portal +------------------+ | Admin Worker | +------------------+ / \ / \ / \ Employee APIs Client APIs | | Employee DB Client DB

How the Admin Worker Works

  • Dual D1 bindings: The admin Worker's wrangler.toml binds to both oa-employee-db and oa-client-db. It is the only Worker with access to both.
  • Not a third database: Admin user accounts, roles, and permissions live in the employee database (since admins are OA employees). The admin panel does not have its own separate user store. It queries the employee DB to verify "is this person an OA admin?"
  • Routing logic: Admin endpoints are prefixed by target system. /api/admin/client/* routes queries to oa-client-db. /api/admin/employee/* routes queries to oa-employee-db. The routing is explicit, never implicit.
  • Audit logging: Every write operation through the admin panel is logged with: who did it, what they changed, when, and on which system. This is critical for accountability when OA staff push content or modify records.
  • Cross-system boundary: Clients never access the admin panel. Employees never access client data through the employee portal. The admin panel is the only place where both data sources are visible, and it requires OA admin credentials (not client or general employee credentials).
wrangler.toml for admin Worker (dual D1 bindings) # The admin Worker binds to BOTH databases. # The client Worker and employee Worker each bind to only their own. # This is the sole bridge between the two data systems. [[d1_databases]] binding = "EMPLOYEE_DB" database_name = "oa-employee-db" database_id = "<employee-db-id>" [[d1_databases]] binding = "CLIENT_DB" database_name = "oa-client-db" database_id = "<client-db-id>"
Admin Worker: routing to the correct database async function handleAdminRequest(request, env) { // Step 1: Verify this is an OA admin (from employee DB) const admin = await verifyAdminAuth(request, env); if (!admin) return new Response('Forbidden', { status: 403 }); const url = new URL(request.url); // Step 2: Route to the correct database based on the path if (url.pathname.startsWith('/api/admin/client/')) { // Client-side operations use env.CLIENT_DB return handleClientAdmin(request, env.CLIENT_DB, admin); } if (url.pathname.startsWith('/api/admin/employee/')) { // Employee-side operations use env.EMPLOYEE_DB return handleEmployeeAdmin(request, env.EMPLOYEE_DB, admin); } if (url.pathname.startsWith('/api/admin/analytics/')) { // Cross-system analytics can query both return handleAnalytics(request, env.CLIENT_DB, env.EMPLOYEE_DB, admin); } return new Response('Not Found', { status: 404 }); }
Why Not a Third Database?

A third database would create a confusing question: "Where does this data live?" The answer should always be clear. Employee data lives in oa-employee-db. Client data lives in oa-client-db. Admin configuration and user roles live in oa-employee-db (since admins are employees). The admin panel just has the keys to both doors. It is a routing layer with authentication, not a data store.

Concrete Test Scenarios

Every isolation claim ships with a test. These are specific, runnable scenarios that prove the authorization model works.

Cross-Client Isolation Tests (Tenant Scoping)

Test 1: Client A attempts to fetch Client B's performance data by manipulating the URL

GIVEN User "Jane" is authenticated as client_owner for "ABC Landscaping" (company_id = 38)
WHEN Jane sends GET /api/client/performance?company_id=42 (where 42 = "XYZ Plumbing")
THEN The server ignores the query parameter company_id=42. The TenantScopedDB middleware derives company_id=38 from Jane's JWT. The response contains ONLY ABC Landscaping's data. REQUEST SCOPED TO OWN DATA
Why this works mechanically // The handler never reads request.query.company_id. // It uses request.scopedDb, which was constructed with company_id from the JWT. async function getPerformance(request) { // request.scopedDb was created with companyId = 38 (from JWT) // The ?company_id=42 in the URL is completely ignored. const data = await request.scopedDb.select('va_performance'); // Executes: SELECT * FROM va_performance WHERE company_id = 38 // Company 42's data is in the same table but the WHERE clause excludes it. return Response.json(data.results); }

Test 2: Client A attempts to fetch Client B's record by guessing the record ID

GIVEN User "Jane" is authenticated as client_owner for company_id = 38
WHEN Jane sends GET /api/client/surveys/survey_id_999 (a survey belonging to company_id = 42)
THEN TenantScopedDB query: SELECT * FROM satisfaction_surveys WHERE company_id = 38 AND id = 999. Returns 0 rows because survey 999 belongs to company 42, not 38. Response: 404 Not Found. DENIED - 404

Test 3: Tenant isolation via TenantScopedDB middleware bypass attempt

GIVEN A handler function receives request.scopedDb (scoped to company_id = 38)
WHEN The handler attempts to access env.CLIENT_DB directly (the raw, unscoped binding)
THEN env.CLIENT_DB is not passed to handlers. Only the middleware has access to the raw binding. The handler's scope contains request.scopedDb and nothing else. The raw DB is structurally inaccessible from handler code. RAW DB NOT IN SCOPE

Cross-Portal Isolation Tests

Test 4: Client session attempts to reach employee data

GIVEN User "Jane" has a valid client portal session (JWT from clients.outsourceaccess.com)
WHEN Jane sends GET https://server.outsourceaccess.com/api/employee/payroll (employee portal endpoint)
THEN The employee portal Worker validates Jane's JWT against the employee users table. Jane does not exist in the employee database. Response: 403 Forbidden. DENIED - 403
Why cross-portal access fails at multiple layers // Layer 1: Different Clerk applications // The client portal and employee portal use SEPARATE Clerk apps. // Jane's client JWT was signed by the client Clerk app. // The employee Worker verifies against the employee Clerk app's key. // Signature verification fails. Request rejected at JWT validation. // Layer 2: Even if using one Clerk app (simpler setup), // the employee Worker looks up the user in oa-employee-db. // Jane is not in that database. 403 Forbidden. // Layer 3: Even if an attacker somehow bypasses auth, // the client portal Worker has NO D1 binding to oa-employee-db. // env.EMPLOYEE_DB is undefined. The database does not exist // in this Worker's execution context.

Test 5: Client employee (non-owner) attempts to access owner-only feedback

GIVEN User "Mike" is authenticated as client_manager for company_id = 38
WHEN Mike sends GET /api/client/feedback
THEN Middleware checks role. Mike's role is "client_manager", not "client_owner". Response: 403 Forbidden. DENIED - ROLE CHECK

Test 6: Unauthenticated request to any API endpoint

GIVEN No JWT token in request headers
WHEN Anonymous user sends GET /api/client/performance
THEN Authorization middleware finds no token. Response: 401 Unauthorized. No database query is ever executed. DENIED - 401

Test 7: Forged JWT with a different company_id claim

GIVEN Attacker crafts a JWT with sub = "fake_user" and company_id = 42
WHEN Attacker sends GET /api/client/performance with this forged JWT
THEN Clerk's verifyToken() checks the JWT signature against Clerk's public key. The forged token's signature does not match. Verification fails. Response: 401 Unauthorized. The company_id claim in the JWT body is never even read (we derive it from our DB, not from the JWT body). DENIED - INVALID SIGNATURE

Admin Panel Tests

Test 8: Non-admin user attempts to access admin panel

GIVEN User "Jane" has a valid client session (role = client_owner)
WHEN Jane sends GET https://admin.outsourceaccess.com/api/admin/users
THEN Admin Worker validates JWT and looks up user in admin_users table (employee DB). Jane is not an admin. Response: 403 Forbidden. DENIED - NOT ADMIN

Scale Architecture Principle

"Optimize for the architecture you'll have at 5,000 clients, not just 150."

This was ChatGPT 5.5's core insight, and it changed the architecture recommendation. OA has ~150 clients today. The growth target is 5,000+. Every architectural decision should be evaluated against the 5,000-client scenario, not just the current state.

Shared Tenant DB at 5,000 Clients

  • One schema migration, runs once
  • One backup schedule, one retention policy
  • One set of indexes to tune
  • Cross-client analytics: single SQL query
  • AI/ML training: one data source
  • New client onboarding: one INSERT statement
  • Operational complexity: O(1)

Per-Client DB at 5,000 Clients

  • Schema migration runs 5,000 times
  • 5,000 backup schedules to manage
  • 5,000 sets of indexes to tune
  • Cross-client analytics: query 5,000 DBs + aggregate
  • AI/ML training: ETL from 5,000 sources
  • New client onboarding: provision new DB + Worker binding
  • Operational complexity: O(N) where N = client count

When Per-Client Databases WOULD Make Sense

Legitimate Reasons for Separate Databases

  • Regulatory requirements: If specific clients are in regulated industries (healthcare with HIPAA, finance with SOX) that legally require physical data separation, per-client databases may be mandated.
  • Contractual obligations: If enterprise clients negotiate contracts requiring dedicated infrastructure (their own database, their own encryption keys), per-client databases become a business requirement.
  • Enterprise customers demanding it: Large clients with their own security teams may require proof of physical isolation as part of their vendor evaluation process.
  • Extreme performance isolation: If one client's query patterns could starve others of database resources, physical separation prevents noisy-neighbor effects.

Why Shared Tenant Architecture Is Correct for OA

  • No regulatory mandate: OA's client data (VA performance metrics, time tracking, satisfaction scores) is not subject to HIPAA, SOX, or similar physical-isolation requirements.
  • No enterprise procurement requirements: OA serves SMBs ($1M-$300M revenue), not Fortune 500 companies with dedicated security review teams.
  • Uniform data shape: Every client has the same schema (companies, users, VAs, metrics, resources). There is no per-client schema variation that would benefit from separate databases.
  • Analytics is a core value proposition: OA will increasingly want to show clients how they compare to industry benchmarks, what top-performing clients do differently, and aggregate insights across the portfolio. This requires cross-client data access, which is trivial with a shared DB and painful with 5,000 separate ones.
  • The TenantScopedDB middleware provides "good enough" isolation: It is not physically impossible for a bug to cross tenant boundaries (unlike separate databases). But the middleware makes it structurally very difficult. Combined with code review, testing, and the test scenarios in Section 7, the risk is acceptably low for OA's data sensitivity class (medium, not high).
The Right Question to Ask

"Is the additional security of physical database separation worth the operational cost at 5,000 clients, given that our client data is medium-sensitivity business metrics (not PII, not health data, not financial records)?" For OA, the answer is no. The TenantScopedDB pattern provides the right level of isolation for the data type, and it scales without operational overhead. The employee database (which IS high-sensitivity) remains physically separate. That boundary is absolute.

Reusable Security Protocol Checklist

This checklist applies to every portal, every endpoint, and every new feature added to the OA platform. It is the standing security contract.

Security Checklist (Every Endpoint, Every Feature)

1
Authentication is not authorization. Verifying WHO someone is (authentication) does not determine WHAT they can access (authorization). Both must be checked on every request. Authentication happens via Clerk JWT verification. Authorization happens via server-side role and scope lookup in OA's D1 database. One without the other is incomplete.
2
Never trust IDs from the client/browser. User IDs, company IDs, tenant IDs, and record IDs sent in query parameters, URL paths, or request bodies are user-controlled input. They can be manipulated. Always derive identity server-side from the cryptographically verified JWT, then look up permissions in the database. Treat all client-sent IDs as untrusted hints at best, ignored by default.
3
Use the right isolation strategy for the data sensitivity class. HIGH sensitivity data (employee payroll, health, PII) gets physically separate databases. No shared container, no shared binding, no code path that can reach across. MEDIUM sensitivity data (client business metrics) uses a shared database with mandatory TenantScopedDB middleware, where the application layer makes cross-tenant queries structurally impossible from handler code. The raw DB binding never leaves the middleware. Individual handlers only receive the tenant-scoped wrapper.
4
Least privilege by default. Every user starts with zero access. Permissions are explicitly granted, never implied. The client_viewer role sees less than client_manager, which sees less than client_owner. Each role's allowed endpoints are explicitly listed. If a new endpoint is added, it is denied by default until a role is explicitly granted access.
5
Defense in depth. No single layer is the whole defense. The architecture stacks multiple layers: (1) Cloudflare WAF blocks common attacks at the edge, (2) Clerk verifies identity via signed JWTs, (3) Middleware derives server-side identity and checks authorization, (4) TenantScopedDB middleware scopes every query to the authenticated company, (5) Database bindings enforce infrastructure-level isolation between employee and client data. An attacker must defeat ALL layers, not just one.
6
Every isolation claim ships with a test. "Client A cannot see Client B's data" is not a design document assertion. It is a testable scenario with GIVEN/WHEN/THEN steps, expected HTTP status codes, and a mechanistic explanation of why it works. Before deploying any new isolation boundary, write the test scenario first. If you cannot write the test, you do not understand the boundary.
How to Use This Checklist

Before any new endpoint or feature goes live, walk through all 6 items. For items 1-2, confirm the endpoint uses the authorization middleware and never reads IDs from the request. For item 3, confirm which isolation mechanism applies (physical separation for employee data, tenant middleware for client data). For item 4, confirm the new endpoint is deny-by-default. For item 5, count how many layers protect this data. For item 6, write the test scenario.

Endpoint-by-Endpoint Authorization Map

Every API endpoint, what database it touches, how identity is derived, and what authorization check runs.

Client Portal Endpoints (clients.outsourceaccess.com)

EndpointDBIdentity SourceAuthorization CheckRoles Allowed
GET /api/client/performance oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB filters by company_id owner, manager, viewer
GET /api/client/time-tracking oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB filters by company_id owner, manager, viewer
GET /api/client/surveys oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB filters by company_id owner, manager
GET /api/client/feedback oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB + role = owner owner ONLY
GET /api/client/resources oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB + industry tag filter owner, manager, viewer
POST /api/client/users/invite oa-client-db JWT → clerk_id → ClientUsers.company_id TenantScopedDB + role = owner owner ONLY

Employee Portal Endpoints (server.outsourceaccess.com)

EndpointDBIdentity SourceAuthorization CheckRoles Allowed
GET /api/employee/payroll oa-employee-db JWT → clerk_id → employees.id Filter by server-derived employee_id (own records only) All employees (own data)
GET /api/employee/health-insurance oa-employee-db JWT → clerk_id → employees.id Filter by server-derived employee_id All employees (own data)
GET /api/employee/kpis oa-employee-db JWT → clerk_id → employees.department_id Filter by department_id + role >= team_leader team_leader, ops_manager, admin, owner
GET /api/employee/announcements oa-employee-db JWT → clerk_id → employees.id Public to all authenticated employees All employees

Admin Panel Endpoints (admin.outsourceaccess.com)

EndpointDBIdentity SourceAuthorization CheckRoles Allowed
POST /api/admin/content/push Both (routed by path) JWT → admin_users table (employee DB) Admin role + target portal validation OA admin staff only
GET /api/admin/client/list oa-client-db JWT → admin_users table (employee DB) Admin role required OA admin staff only
POST /api/admin/employee/create oa-employee-db JWT → admin_users table (employee DB) Admin role + HR permission flag OA HR admin only
GET /api/admin/analytics/cross-client oa-client-db JWT → admin_users table (employee DB) Admin role + analytics permission OA admin staff only
GET /api/admin/audit-log Both JWT → admin_users table (employee DB) Admin owner role Brad + senior admin only
Key Pattern Across All Endpoints

Every endpoint follows the same pattern: (1) JWT verified by Clerk, (2) user ID extracted from verified JWT, (3) user looked up in the appropriate database to get role + scope, (4) authorization check runs against the server-derived role + scope, (5) database query filtered by server-derived identity (via TenantScopedDB for client endpoints, via direct employee_id filter for employee endpoints). No endpoint trusts client-sent IDs. No endpoint skips the authorization middleware.

Final Architecture Decisions

Architecture Decisions (Four-Brain Consensus)

1. Auth vs. Authz: Clerk handles authentication (identity). OA's Workers enforce authorization (access control) on every request, server-side, deriving identity from signed JWTs and never trusting client-sent IDs.

2. Data stores: Two separate D1 databases (oa-employee-db for HIGH sensitivity PII/payroll, oa-client-db for MEDIUM sensitivity business metrics). Admin Worker bridges both. Client and Employee Workers each bind to only their own.

3. Client tenant isolation: ONE shared client database with TenantScopedDB middleware. Every table has a company_id column. Every query is automatically scoped. Handlers never access the raw DB binding. Per-client databases were rejected as over-engineering that does not scale to 5,000 clients.

4. Auth provider: Clerk (managed). Auth is the one domain where specialist beats generalist. Free for the first 10K MAU. SOC 2 compliant. Handles the subtle edge cases (token rotation, brute-force, session fixation) that hand-built auth misses.

5. VA cross-reference: Minimal VirtualAssistants table in client DB with display-only fields. No database merging. Opaque employee reference ID. Tenant-scoped like every other table.

6. Admin panel: An orchestrator Worker with dual DB bindings, NOT a third database. Routes to the correct database based on the request path. Admin users authenticated via the employee database (since admins are OA employees). All writes audit-logged.

7. Scale principle: Architecture designed for 5,000 clients, not 150. Operational complexity is O(1), not O(N). Cross-client analytics, AI training, and portfolio reporting are first-class capabilities, not afterthoughts.

8. Every isolation claim has a test: 8 concrete test scenarios with GIVEN/WHEN/THEN + mechanistic code explanations. Tenant isolation, cross-portal isolation, role-based access, and forged JWT defense are all tested.

9. Security checklist: 6-point reusable protocol. Apply to every new endpoint and feature before it ships.