This page documents the layered defenses that protect the workspace and the data behind it. For the live permission grid see Workspace → RBAC; for the audit log catalog see Audit Log.
┌─────────────────────────────────────────────────────────────────┐
│ Edge: Clerk session check + email domain allowlist │ apps/workspace/proxy.ts
├─────────────────────────────────────────────────────────────────┤
│ Layout: re-check allowlist, fetch RBAC roles, hide UI │ app/(workspace)/layout.tsx
├─────────────────────────────────────────────────────────────────┤
│ Route handler: checkRole(<permission>) before any side-effect │ app/api/**/route.ts
├─────────────────────────────────────────────────────────────────┤
│ Zod schemas: validate body/query before touching the DB │ lib/utils/validation
├─────────────────────────────────────────────────────────────────┤
│ Audit log: every mutation writes to MongoDB `audit_logs` │ lib/security/audit
└─────────────────────────────────────────────────────────────────┘
If any layer fails open, the next one still catches it.
matters.ai ↔ workspace.matters.ai, the Clerk session cookie's Domain
must be set to .matters.ai in the Clerk dashboard.@matters/auth's isAllowedEmail() (canonical source:
packages/auth/src/email-allowlist.ts) is the primary gate on workspace access. Only
addresses ending in one of ALLOWED_EMAIL_DOMAINS may access protected routes — even with a
valid Clerk session.
Adding a domain is a security review item: it grants every Clerk user at that domain access to internal data. Never broaden the list without a written reason and a sign-off.
| Concept | Where it lives |
|---|---|
| Roles (13) | lib/auth/rbac-shared.ts → ROLE_LABELS, ROLE_HIERARCHY |
| Permissions (60+) | lib/auth/rbac-shared.ts → Permission type |
| Role → Permission map | lib/auth/rbac-shared.ts → ROLE_PERMISSIONS |
| Runtime check (async) | lib/auth/rbac.ts → checkRole(permission) |
| Synchronous check | lib/auth/rbac-shared.ts → hasPermission(roles, permission) |
| Page-level guard | lib/auth/guards.ts → requirePermission(permission) |
org:super_admin is in PROTECTED_ROLES and cannot be combined with other roles. Roles live
in Clerk's publicMetadata.role (the primary role) plus publicMetadata.additionalRoles (an
array of extra roles) — there is no publicMetadata.roles field (Clerk Organisations replaces
this scheme once migrated).
The Matters surface is a federation of seven hosts, all under matters.ai. Public APIs at the
root Next.js app (/api/demo-form, /api/whitepapers/submit, /api/datasheets/submit) accept
calls from exactly that set — never from arbitrary origins.
| Allowed origin | Purpose |
|---|---|
https://matters.ai | Apex marketing site |
https://www.matters.ai | Public site (www) |
https://beta.web.matters.ai | Beta preview of the public site |
https://workspace.matters.ai | Workspace app |
https://webdocs.matters.ai | This documentation site |
https://weblabs.matters.ai | Labs / experiments |
https://memo.matters.ai | Memo (DocSend-style HTML hosting) |
The canonical list is PROD_ORIGINS in lib/cors.ts. In non-production,
http://localhost:* and http://127.0.0.1:* are also accepted (regex-matched). For one-off
preview/staging origins, set NEXT_PUBLIC_EXTRA_ALLOWED_ORIGIN in the Vercel project.
CORS is browser-only — curl ignores it entirely. So we pair the Access-Control-Allow-Origin
header with explicit server-side origin enforcement:
// lib/cors.ts
export function requireMattersOrigin(request: NextRequest): NextResponse | null {
const origin = request.headers.get("origin");
const referer = request.headers.get("referer");
if (origin) return isAllowedOrigin(origin) ? null : forbidden();
if (referer) return isAllowedOrigin(new URL(referer).origin) ? null : forbidden();
return forbidden(); // no Origin and no Referer → reject
}
Public mutation routes call this first:
export async function POST(req: NextRequest) {
const blocked = requireMattersOrigin(req);
if (blocked) return blocked;
// … handler
}
A failed origin check returns 403 Forbidden with body { "error": "Forbidden", "reason": "cross_origin_blocked" }. Receivers should treat this as a hard 403, not a retry.
Even same-origin requests must pass the allow-list — because requireMattersOrigin checks
the calling origin string, not whether origin equals host. So adding a new Matters surface
without adding it to PROD_ORIGINS will silently break its own fetch() calls from the
browser. This happened once: beta.web.matters.ai was deployed before it was added to the
list, and its own demo form started returning cross_origin_blocked. Fix: extend
PROD_ORIGINS.
https://<sub>.matters.ai to PROD_ORIGINS in lib/cors.ts.requireMattersOrigin; other apps gate via Clerk).| Gap | When closed | Where |
|---|---|---|
/api/workspace/webinars/event-logs had no RBAC check (PII leak) | Slice 3 hardening | apps/workspace/app/api/workspace/webinars/event-logs/route.ts |
/api/workspace/sales/export accepted any MongoDB collection name from the request body | Slice 3 hardening | apps/workspace/app/api/workspace/sales/export/route.ts — enum-bound via Zod |
lib/security/rate-limit.ts) but not yet applied to every
high-risk mutation route. Top priorities: bulk-invite, export, sync-salesforce.lib/api/wrapper.ts has a TODO for requireAuth; either finish or delete..env.local and .env.secrets (gitignored).packages/lib/src/config/env.ts — a single Zod-validated object that
validates on boot. Missing required vars cause loud startup failures, not silent runtime ones.If you find something exploitable, do not open a public issue. Email security@matters.ai
with a description and (if you can) a proof-of-concept. We acknowledge within 24 hours and ship
a patch within 7 days for anything we can reproduce.