Security Architecture
Threat model, controls, and operations. Sibling docs: system · backend · realtime · ai.
Top invariant (repeated from
AGENTS.md, normative): never trust frontend authorization. Every backend operation re-validates server-side. The UI hides what you can't touch; only the server decides what you can touch.
1. Authentication
1.1 Token model
| Token | Form | Lifetime | Storage | Purpose |
|---|---|---|---|---|
| Access token | JWT (HS256, packages/auth), claims {sub: userId, wid: workspaceId, email, role, iat, exp, iss: "factory", jti} |
15 min | JS memory (not localStorage — XSS-resistant); optionally a non-HttpOnly cookie for SSR reads, plus the HttpOnly refresh cookie as the source of truth | REST auth (Authorization: Bearer), realtime WS handshake |
| Refresh token | Opaque 256-bit random string; SHA-256 hash stored in refresh_tokens (raw value never persisted) |
30 days, sliding (each use renews the window) | HttpOnly, Secure, SameSite=Lax, Path=/ cookie factory_rt |
Silent session renewal; rotation |
| Session | sessions row per login (device) |
30 d or revoked | DB (tokenHash, userAgent, ipAddress, status) |
"active devices" UI, revocation, abuse detection |
| API key | fk_live_… 40-byte random; SHA-256 hash stored (api_keys.keyHash), raw shown once |
until expiry/revocation | client (env/header) | public API auth (Authorization: Bearer fk_live_…) |
| Share token | 256-bit random, shares.token (raw, unique index — the capability IS the token) |
until expiry/revocation | URL | public share pages (/share/:token), optional bcrypt password |
| SSO session | IdP assertion (SAML) / OIDC code |
single use | sso_identities links IdP externalId → User |
enterprise login |
1.2 Full session lifecycle (login → rotation → revocation)
┌─ LOGIN ─────────────────────────────────────────────────────────────────────────┐
│ 1. POST /v1/auth/login {email, password} │
│ → rate limit (10/15 min/IP, 5/15 min/account) │
│ → constant-time lookup + bcrypt.compare (cost 12; dummy-hash compare when │
│ user unknown — no timing user-existence oracle) │
│ → user status checks: SUSPENDED → 403; UNVERIFIED → 403 EMAIL_NOT_VERIFIED │
│ (login allowed only for verified or grace-period accounts) │
│ 2. create: Session row (ACTIVE, ua, ip) + RefreshToken row (hash, 30 d) │
│ → audit user.login {ip, ua, sessionAgent} │
│ 3. response: Set-Cookie factory_rt=<opaque>; HttpOnly; Secure; SameSite=Lax │
│ + JSON body: { accessToken (15 m JWT), user: UserDto } │
└──────────────────────────────────────────────────────────────────────────────────┘
┌─ USAGE ─────────────────────────────────────────────────────────────────────────┐
│ REST: Authorization: Bearer <JWT> (JwtAuthGuard: sig/exp/iss) · WS: token=<JWT> │
│ in the upgrade query (auth bridge, realtime §2.1). The JWT's wid/role claims are │
│ convenience only — the guard RE-DERIVES membership for every decision. │
└──────────────────────────────────────────────────────────────────────────────────┘
┌─ REFRESH ROTATION (BFF or /v1/auth/refresh) ────────────────────────────────────┐
│ 1. POST /v1/auth/refresh (cookie factory_rt) │
│ 2. h = SHA256(raw); row = refresh_tokens WHERE tokenHash = h │
│ 3. row missing → 401 (token never seen: replay after revocation or forged) │
│ row.revokedAt set AND this is a REUSE (raw != current expected) │
│ → SECURITY EVENT: the whole session chain is compromised: │
│ revoke ALL of the user's sessions + refresh tokens (family kill), │
│ audit auth.refresh.reuse_detected, notify user (email), 401 │
│ row.revokedAt set but expected rotation chain (replacedBy == row.id) │
│ → 401 (stale retry of an already-rotated token — client re-logins) │
│ 4. else: mark row.revokedAt = now, row.replacedBy = newId │
│ insert new RefreshToken (hash, expires = now + 30 d) │
│ mint new JWT (fresh wid/role claims — role changes take effect on next │
│ rotation, ≤ 15 min) │
│ 5. audit auth.refresh (session id chain for the device list) │
└──────────────────────────────────────────────────────────────────────────────────┘
┌─ REVOCATION ────────────────────────────────────────────────────────────────────┐
│ • logout POST /v1/auth/logout → refresh row revoked + session REVOKED │
│ • DELETE /v1/auth/sessions/:id revokes one device; "sign out everywhere" = all │
│ • admin suspends a user → all sessions revoked + JWT denylist: │
│ Redis SET jwt:deny:<jti> TTL=remaining lifetime, checked in JwtAuthGuard │
│ (one GET, 5 s cache) — closes the ≤15-min access-token window for │
│ SUSPENDED/revoked users on state-changing ops │
└──────────────────────────────────────────────────────────────────────────────────┘
Why opaque + rotating: a stolen refresh token is single-use and its reuse is detectable (family kill above); DB rows give per-device revocation ("active devices"); the short access JWT makes a stolen token worthless after 15 min.
Sensitive-op double check: operations that must honor revocation immediately
(password change, permission change, workspace delete, API key creation) run
Session.isActive(userId) in the same transaction — a user who just logged out can't
land these even with an unexpired JWT.
1.3 Passwords & recovery
- bcrypt cost 12 (tunable via env, min 10); policy: ≥ 10 chars, no top-10k-common (zxcvbn score ≥ 3); change requires current password (or SSO).
- Password reset:
POST /auth/password-reset/request(email-based; constant response whether or not the email exists — no account enumeration) → 1-hour single-use token (hash stored,PasswordReset.statusPENDING→USED) → confirm sets new password, revokes all sessions (credential change ⇒ re-auth everywhere), audituser.password_reset. - Email verification: same token pattern; verified flag gates invite-sending and SSO-external features.
- Brute force: per-IP + per-account limits (§8), lockout is rate-based, not
hard-lock (no DoS vector); 5 consecutive failures on one account → 15-min cooldown
- audit
auth.login.failed.streak.
- audit
1.4 SSO (SAML / OIDC) & SCIM (Phase 14)
SsoProviderper org (SAML: entityId + IdP cert ref, cert envelope-encrypted, never inlined inconfig; OIDC: issuer + client id, client secret envelope-encrypted).- OIDC: authorization-code + PKCE; the BFF holds the code and does the server-
to-server token exchange;
state= single-use Redis ticket (5 min),noncebound to the ticket. SAML: POST-redirect binding, assertion signature verified against the IdP cert,RelayState= same ticket pattern; audience (ACS) + issuer pinned. - JIT provisioning: first login with unknown
externalId→ createUser(ACTIVE,passwordHash = NULL— SSO-only) +SsoIdentitylink + org membership per IdP group claims (group→workspace role map inssoConfig.groups); auditsso.login. - Enforcement:
enforce = true→ password login for org members refused (403 "SSO required"); org policy can set this. - SCIM:
GET/PATCH/POST /organizations/:id/scim/v2/Users|Groupswith a dedicated SCIM bearer token (hashed like API keys, scopeADMIN); create → User + membership,active:false→ SUSPENDED + session revocation, delete → deprovision (soft: SUSPENDED, content retained per retention policy — never hard-deleted). Auditscim.*for every mutation.
1.5 API-key scope enforcement (TASK-0127 — the no-creator-leak rule)
An API key is a workspace credential, not a user credential. The
enforcement contract (normative; proven by
tests/integration/api-keys.integration.test.ts):
- Acts as the workspace. A key's permission in workspace W is decided
ONLY by (a) the key's own workspace is W, and (b) the key's scopes. The
key's creator (
createdById) is resolved on lookup for attribution (createdBy-style columns, auditactorId), but the creator's membership, role, or grants in ANY workspace are never consulted for the decision. The creator is OWNER of workspace X and the key belongs to workspace Y ⇒ the key gets nothing in X (403 workspace_mismatch), full stop. This is the IDOR class the phase exit criterion targets. - Scope → capability cap.
READ⇒ VIEW-level asserts only;WRITE⇒ up to EDIT-level asserts;ADMIN⇒ all levels (the workspace's OWNER/ADMIN-gated surface opens, e.g. creating webhooks/integrations/ automations). A cap mismatch ⇒403 FORBIDDENwitherror.details[0].reason = "api_key_scope_write"(public alias SCOPE_DENIED,docs/api/authentication.md§9.1). - A key is never OWNER, never a user. OWNER-only gates (workspace
delete/transfer) and every user-identity-only endpoint
(
users/me, invitations-as-user, sessions, notifications, orgs, personal AI conversations —requireUser) return403(requires_user_credentials) for any key, regardless of scope. - Revocation is immediate (
revokedAtchecked on every lookup; no cache of the live key) and audited; a dead key's auth attempts are auditedapi_key.auth_failurethrottled to 1/hour per key (RedisSET … NX) so a leaked key cannot spam the audit table; unknown keys are NOT audited per key (timing must not leak existence — docs §7). - Per-key rate limit + idempotency are credential-scoped. The key's
window is keyed by the key id (600 req/min default, or the key's
rateLimit— docs §11); the idempotency memo keyspace isidem:api:{workspaceId}:{keyId}:{digest}:{key}so no cross-key / cross-workspace replay is possible.
Implementation: the actor is bound once per request (the outermost
ActorContextInterceptor → AsyncLocalStorage); assertCan/assertRole
and the actorLevel/actorMembership/actorIsAdminKey helpers in
lib/authorize.ts read it. A request with kind: "api" takes the
API-key branch before any membership lookup — the leak is
structurally impossible, not merely prevented at each call site.
2. Authorization
2.1 The two layers
| Layer | Data | Answers |
|---|---|---|
| RBAC (workspace roles) | workspace_members.role ∈ OWNER/ADMIN/MEMBER/GUEST (+ org roles in organization_members) |
default level for workspace-level resources; who can manage members/roles/settings |
| Resource permissions | permissions rows: grantee (user/team/role) × resource (workspace/teamspace/page/database/record/file) × level (NONE/VIEW/COMMENT/EDIT/FULL_ACCESS) |
per-resource overrides and extensions, with inheritance down the page tree |
Level ordering: NONE < VIEW < COMMENT < EDIT < FULL_ACCESS. EDIT implies
COMMENT and VIEW; FULL_ACCESS adds: share management, permission grants,
delete/purge, move to trash.
Role floors (workspace-level only — the role is a floor for workspace-wide content, not a ceiling):
| Role | Floor on workspace content | Extra rights |
|---|---|---|
| OWNER | FULL_ACCESS | transfer ownership, delete workspace, billing |
| ADMIN | EDIT | manage members/roles, manage shares at workspace level, settings, integrations, API keys, automations, templates |
| MEMBER | EDIT (non-private pages; own content) | create/edit own + shared content, comment anywhere editable |
| GUEST | VIEW | view shared content, comment where COMMENT granted |
2.2 Evaluation algorithm (normative — one implementation, packages/permissions)
effectiveLevel(ctx, R ∈ {type, id, workspaceId}):
# 1. identity
G = { ("user", ctx.userId) } ∪ { ("team", t.id) for t in teamsOf(ctx.userId, workspace) }
∪ { ("role", roleOf(ctx.userId, workspace)) } # workspace role as grantee
# 2. collect the resource chain (top-down):
# record → its page (record=page, ADR-006) → parent pages … → workspace root
# database → embedding page (databases.pageId) → … → workspace root
# file → owning page/block's page → … → workspace root
# teamspace/team → workspace root
# (max depth 50; cycle guard on pageId walk)
chain = [R, parent(R), …, workspace(R)]
# 3. grants for this grantee-set, for EVERY resource in the chain (one indexed
# query: WHERE workspace_id = w AND (grantee_type,grantee_id) IN G
# AND (resource_type,resource_id) IN chain) — cached 60 s (§2.4)
# 4. explicit deny: if any grant in the chain has level = NONE → return NONE
# (explicit NONE at ANY level of the chain vetoes everything below it)
# 5. level = max of all grants found (NONE contributes nothing)
# 6. if no grants found: level = roleFloor(ctx.role) (workspace resources only;
# for deep resources with no grants at all, the floor applies through the
# page's isPrivate flag: isPrivate page → floor collapses to the creator +
# explicit grants)
# 7. owner bypass: workspace OWNER → FULL_ACCESS (except: owner cannot grant
# FULL_ACCESS to others — grant cap for owners is EDIT, admin grants FULL)
# 8. trashed: R.page.trashedAt set → cap at VIEW
# 9. cache + return { level, reason } (reason ∈ no_grant | explicit_none |
# role_floor | owner_bypass | trashed_cap | grant_max)
Inheritance semantics, precisely:
- Grants flow down the page tree (a parent grant applies to all descendants) but never up; an explicit NONE at any level of the chain vetoes everything below it, otherwise the highest grant along the chain wins.
- Team grants = union over the user's teams;
roleas grantee resolves to all current and future members at evaluation time. - Record = its page (one chain hop); a
permissionsrow withresourceType = 'record'overrides the page's effective level for that record. - Files inherit from their containing page/block; a
file-type grant overrides.
2.3 Guard integration (backend) & the decision log
- Every state-changing endpoint declares
@RequirePermission(resource, level, …); the guard runseffectiveLeveland 403s with areasonindetails(never a bare 403). List endpoints pass the level into the query for visibility scoping — the sidebar never renders what you can't see. - Decision log: denials on state-changing ops are audit-logged
(
permission.denied{resource, requested vs effective level, reason}), throttled 1/5 s/actor/resource to absorb probe floods; allowed ops audit their normal action. The Phase-14 "decision log" view joins both: who could do what, and when denied. - Frontend mirroring: the web app calls the same engine for UI decisions only
(
GET /v1/permissions/checkbatch, cached 60 s, same{level, reason}shape — one algorithm, zero drift). It's cosmetic; the server re-checks every mutation.
2.4 Cache & invalidation
Permission decisions: Redis perm:{wid}:{resType}:{resId}:{granteeHash} TTL 60 s;
permission.changed event → DEL prefix perm:{wid}:* (bounded: workspaces have
thousands of decisions, not millions). Membership/role changes emit
workspace.member.* events → same invalidation. Worst-case staleness 60 s; the
sensitive-op double check (§1.2) never relies on the cache.
3. Transport Security
3.1 TLS
- Edge (nginx/ingress): TLS 1.2+ (1.3 preferred), ECDHE-AES256-GCM suites (+1.3
defaults), HSTS
max-age=31536000; includeSubDomains; preload; no cert pinning (self-host flexibility). - Internal pod↔pod: plaintext inside the NetworkPolicy-isolated cluster (documented assumption; cross-AZ mTLS via service mesh — Phase 15).
- WebSocket: WSS only — nginx refuses plaintext
ws://(pod listeners are cluster-network-bound).
3.2 Cookies
| Cookie | Value | Flags |
|---|---|---|
factory_rt |
opaque refresh token | HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000 |
factory_csrf |
random 128-bit (double-submit) | Secure; SameSite=Lax; Path=/ (NOT HttpOnly — JS reads it to echo) |
factory_theme |
theme pref | Secure; SameSite=Lax (non-sensitive) |
- The access JWT is not in a cookie by default (memory only + BFF-injected for
SSR reads); the
SameSite=Laxrefresh cookie + CSRF double-submit is the cookie surface. Secureis enforced in dev too (local HTTPS via mkcert in the compose profile).
3.3 CORS
| Origin policy | Rules |
|---|---|
API (/v1) |
no Access-Control-Allow-Origin: * ever. Allowed origins: the app origin (env WEB_ORIGIN) + workspace custom domains (publicUrl, validated against a per-org allowlist at save time). Allow-Credentials: true only for the app origin (cookie flows). Allow-Methods: GET,POST,PATCH,DELETE,OPTIONS; Allow-Headers: Content-Type, Authorization, X-CSRF-Token, X-Request-Id; Max-Age: 600. |
| Realtime WS | Origin checked at upgrade: must match app origin or workspace domain, else 403 close 4403. |
| Share pages / forms | same-origin (served by the app) — no CORS needed; file preview proxy is same-origin. |
| Public API consumers | they call with Authorization (no cookies) and need no CORS at all; browsers calling cross-origin without credentials get allow-origin of the requesting origin reflected only for Bearer-authed requests (safe: no credentials cross) — this is what makes the public API usable from serverless functions and admin tools. |
3.4 Security headers (nginx + Next, defense in depth)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
# 'unsafe-eval' scoped: required by Next.js dev only — PRODUCTION build ships
# WITHOUT unsafe-eval (Next 14 production compiles away eval; the dev CSP is a
# separate env-driven policy). img-src 'self' data: blob: <signed file host>;
# connect-src 'self' wss:<host>; frame-ancestors 'none';
# base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY (frame-ancestors 'none' in CSP — no clickjacking)
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cache-Control: no-store (authed JSON; public assets: long cache + hash)
4. Input Validation & Sanitization
4.1 The pipeline
HTTP body/query/params
→ ZodValidationPipe (packages/shared schemas — the SAME schemas the client uses)
• type coercion, trimming, enum checks, cuid shape, JSONB deep schemas
(BlockContentSchema, FilterTreeSchema, view configs, formula source text)
→ service-level domain validation (invariants Zod can't express: parent exists,
option belongs to the select, relation cardinality, formula AST parse)
→ Prisma (parameterized — no string SQL in app code; raw SQL only in migrations)
→ (for text entering collaborative content) the editor boundary sanitizers
4.2 Per-input-class rules
| Class | Control |
|---|---|
| REST/WS JSON | Zod schemas only; unknown keys stripped (.strict() on security-sensitive DTOs: auth, permissions, webhooks, api keys); numbers bounded (pageSize ≤ 100, batch ≤ 500); strings length-capped (title ≤ 200, comment ≤ 20k, formula ≤ 2k) |
| SQL | Prisma parameterization only; no query( with interpolation; the one raw-SQL usage (migrations) is code-reviewed; search queries are built by packages/search from typed params (OpenSearch query DSL, not string concat) |
| Command/OS | none exist — no shell-outs in any service (the file pipeline uses SDKs: sharp/libheif for images, a pure-JS PDF text extractor, no ffmpeg exec — thumbnails are image-only; video "thumbnails" = a static poster, Phase 9 decision) |
| Formula input | safe parser → AST → bounded evaluator (10k step limit, 1 MB output cap, fixed function whitelist, no property access beyond prop()); never eval/Function — CI greps the whole repo for eval(, new Function(, vm.runIn (allowlist: none) |
| Output (JSON API) | serializers whitelist fields (entity → DTO); no internal ids (S3 keys, token hashes, secret refs) in any DTO; requestId is the only cross-reference to logs |
| HTML/JSX rendering (web) | React escapes by default; the only dangerouslySetInnerHTML is the comment markdown renderer (markdown-it with html: false, linkify sanitized to https?: scheme + rel="noopener") — no raw HTML ever from user content |
| Editor content (blocks) | text is stored as structured runs (Tiptap marks), not HTML; paste-from-web is parsed through the importer (HTML → AST → blocks, tags/attrs allowlisted, styles dropped except a 40-color map); content JSONB is Zod-validated per block type on write, so a stored block can't carry an unexpected shape |
| File names | length ≤ 255, control chars stripped, path traversal chars removed (.., /, \); the S3 key is server-generated ({wid}/{fileId}/{version}/{hash}.{ext}) — user names never touch the storage key |
| URLs (bookmarks, embeds, webhooks, file) | scheme allowlist (https:, mailto: for mentions); webhook URLs additionally must be https: (public internet) or RFC1918 with an explicit org-policy opt-in (self-hosted LAN) — SSRF control, §6.4 |
| Email/phone inputs | format-validated (Zod) + lowercased; email is the unique key (case-insensitive via stored lowercase) |
4.3 Content sanitization & XSS classes
| Vector | Control |
|---|---|
| Stored XSS (block content) | content is structured (no HTML field); markdown rendered with html:false; mention/link marks carry only {href (validated), label, id} |
| Stored XSS (comments / share pages) | markdown-it html:false; share pages add X-Frame-Options: DENY + CSP default-src 'self' (no scripts) |
| DOM XSS | no innerHTML assignment of user data anywhere (lint no-imperative-dom); URL params Zod-parsed before use (/share/[token] shape-validated) |
| Prototype pollution | Zod objects (no __proto__ survives parse — .strict() + key allowlists); deep-merge of untrusted objects banned (lint) |
| Open redirect | next.redirect targets validated against app origin/workspace domain; url cells and bookmarks are display-only (clicks go through the app's link handler, rel=noopener) |
5. Rate Limiting & CSRF
5.1 Rate limiting (summary — full table in backend, §10)
Redis sliding window (atomic Lua), scopes: auth (per-IP + per-account), per-user
API, per-API-key (per-key override), per-IP unauthenticated, AI (per-user + budget),
form submissions (per-form per-IP), realtime connects (per-IP per-user). 429 carries
Retry-After; all 429s metered (rate_limited_total{scope}) and alerted.
5.2 CSRF
- Double-submit cookie:
factory_csrfcookie (JS-readable) +X-CSRF-Tokenheader must match on state-changing cookie-authenticated requests (POST/PATCH/ DELETE withoutAuthorization/API-key header). - Exempt: Bearer-authed and API-key requests — they use no cookies, so a cross-
site page can't attach credentials (the CSRF precondition is absent);
SameSite=Laxonfactory_rtis the second wall. - Flow: client fetches the token on app load (
GET /v1/csrf-tokensets the cookie + returns the value to JS memory); the api client injects the header automatically. The realtime WS is exempt (token-in-query, not cookies). - Why not pure SameSite: Lax still ships the cookie on top-level navigations and older browsers are in the self-host base; double-submit is independent of SameSite support.
6. File Security
6.1 Upload pipeline (defense at each stage)
1. REQUEST POST /v1/files {name, size, mimeType, blockId?/pageId?}
• permission EDIT on target · plan caps (maxFileSizeMb default 200, 5 GB hard cap,
>100 MB → multipart) · name/mime sanity → File row (UPLOADING) + presigned PUT
(15 min TTL); S3 key server-generated: {wid}/{fileId}/{v}/{sha1-prefix}.{safe-ext}
2. UPLOAD browser → S3 directly; presign policy locks Content-Length + Content-Type
(the client cannot rewrite the type to smuggle)
3. PIPELINE (worker, queue 'files')
a. VERIFY: re-fetch; sha256 (presign-mandated x-amz-meta) matches — mismatch →
FAILED, object deleted
b. MIME SNIFF: magic bytes (NOT the declared mimeType): mismatch → reject.
Supported: png/jpeg/gif/webp/heic/avif, pdf, mp4/webm, mp3/m4a/wav,
docx/xlsx/pptx, csv/tsv/json/md/txt, zip, 7z. Sniffed executable/script types
(exe, dll, extensionless js, sh, bat, html-as-file) REFUSED outright —
double-extension trick killed (sniffed type always wins)
c. CLAMAV: per-file scan (sidecar, 120 s timeout) → ANY detection:
VIRUS_DETECTED, object kept 7 d forensically then deleted, audit + alert
d. METADATA: dimensions / duration (header parse, no decode) / page count /
text extraction (pdf/docx → search, 1 MB cap)
e. THUMBNAILS: sharp → thumbs bucket (400×400 webp + 1200px hero); text: icon only
f. READY: → event file.uploaded (search + UI)
| Status | UI | Served? |
|---|---|---|
UPLOADING |
spinner | no |
SCANNING |
spinner | no |
READY |
normal | yes (signed URL) |
VIRUS_DETECTED |
"File blocked — virus detected" | never (download + preview both 403) |
FAILED |
"Upload failed — retry" | no (object deleted) |
6.2 Access control & storage isolation
- Downloads are never direct.
GET /v1/files/:id/download→ permission check (file level → containing page) → short-lived signed S3 URL (5 min, one object key,response-content-typepinned to the sniffed mime,attachmentdisposition for non-preview types) → 302 (or the BFF streams it — env-configurable, zero key exposure either way). - Storage isolation: private bucket
factory-files, per-workspace prefix isolation ({wid}/…); the storage client signs only the request-contextwid's prefix — a bug cannot mint a URL for another tenant.factory-thumbssame rule; thumbs are served via the BFF proxy which re-checks permission per request (a leaked thumb URL alone is useless). Buckets private + versioned;VIRUS_DETECTED/FAILEDobjects auto-delete after 7 d. - Public-form uploads: same pipeline, system actor; permission follows the record (an anonymous submitter never gets a standing token).
6.3 Previews
- Images: BFF proxy
/api/preview/image/:fileId(permission re-check, 5-min cache headers — the URL is not a capability, the cookie is). - PDF: same proxy (range requests);
application/pdf+nosniff; the viewer renders in asandbox=""iframe (no scripts) so PDF-internal JS can't read the page DOM. - Video/audio: native
<video>/<audio>at the 5-min signed URL,controls; no plugin/embed surface.
6.4 SSRF (webhooks, embeds, bookmarks, custom AI endpoints)
- Webhook URLs (worker): resolved IP must not be RFC1918/127.0.0.0/8/169.254/::1/
fe80 unless org policy
webhooks.allowLan = true(self-hosted LAN); pin the resolved IP at connect + verify SNI (DNS-rebinding guard);http:refused for public URLs; response body capped 64 KB; ≤ 2 redirects, each hop re-validated (redirect-to-169.254.169.254 = the cloud-metadata attack, blocked by the same rule). - Embeds/bookmarks: og-metadata fetched client-side only; the API never fetches a user-supplied URL except webhooks + integration configs (both guarded above).
- Custom AI endpoints: health-probed at save with the same rules; the gateway's per-endpoint allowlist is the runtime check.
7. Multi-Tenancy Threat Surface
| Threat | Control |
|---|---|
| Cross-tenant read via API | repository-layer workspaceId scoping (every query method takes it — database, §2.5); TenantGuard rejects path/claim mismatches; property test in CI (random two-tenant ops, assert zero cross-reads) |
| Cross-tenant via id guessing | ids are cuids (unpredictable, 128-bit effective) and every read re-checks the row's workspaceId against the context — an existing id from another tenant is NOT_FOUND (existence not leaked) |
| Cross-tenant via search | workspace_id filter is a required parameter in the query builder (compile-time); permission snapshot per doc (search, §5) |
| Cross-tenant via realtime | room key includes workspaceId; join auth checks membership per room; share-token principals are bound to the share's workspace |
| Cross-tenant via queue/stream events | envelopes carry workspaceId; consumers scope all work to it; consumer code has no "all workspaces" path except the reconciliation sweep (which is per-workspace by design) |
| Tenant DoS (one workspace starving others) | per-workspace: AI budgets, rate limits (per-key/per-user), file size caps, room count (realtime HPA is per-connection, and a workspace's rooms are bounded by its pages-in-edit — soft cap 5k rooms/workspace with alert), query pageSize caps; automation run concurrency cap per workspace (10 parallel runs) |
| Cache cross-tenant bleed | every cache key includes wid (backend, §11); no tenantless cache holds tenant data (suggestions keyed by wid) |
8. Secrets Management
8.1 Sources & handling
| Secret | Source | Handling |
|---|---|---|
DATABASE_URL, REDIS_URL, S3_*, JWT_SECRET, ENCRYPTION_MASTER_KEY |
env (secret store in prod — never image, never repo) | Zod-validated at boot (fail-fast); JWT_SECRET ≥ 256-bit random, rotated via dual-secret window (JWT_SECRET_PREV for verification only, 24 h max); no secrets in logs (pino redactor: keys matching /(password |
Provider AI keys (provider_secrets) |
created via API (admin) | envelope-encrypted (below); shown once; listed by name only |
Integration tokens (integrations.config) |
created via API | same envelope encryption (the JSONB holds {"_enc": "v1:<ciphertext>"}) |
SSO client secrets / SAML certs (sso_providers.config) |
org settings UI | same envelope encryption |
Webhook signing secrets (webhooks) |
created via API, shown once | raw kept envelope-encrypted in the webhook's encrypted config column (signing needs the raw secret at delivery — so secretHash is for verification of the secret by admins, the encrypted raw is for signing; both derived from the one-time value) |
| API keys / refresh / session / invite / reset tokens | server-generated | hashed at rest (SHA-256; bcrypt for user-chosen share passwords) — a DB dump yields no usable credential |
| Refresh/CSRF cookie values | client-side | HttpOnly (refresh); double-submit (CSRF) — §3.2/§5.2 |
8.2 Envelope encryption (at rest)
ENCRYPTION_MASTER_KEY (env, 256-bit, secret store — the ONLY key ever in env) = KEK
│
▼ wraps one DEK per secret (envelope: rotation re-wraps DEKs, not all data)
DEK: AES-256-GCM(random 96-bit nonce, plaintext) → stored as {v:1, nonce, ct, tag}
packages/auth/src/envelope.ts(shared, pure):seal(plaintext, dek)/open(payload, kek); GCM tag verified — tamper → loudSECRET_TAMPERED+ audit.- Rotation: re-wrap the stored DEKs (one query per encrypted column, minutes, not
hours); old KEK kept as
ENCRYPTION_MASTER_KEY_PREVfor one window. - Scope: provider secrets, integration configs, SSO config — not the whole DB (block PII is protected by access control + TLS + infra-layer DB encryption).
8.3 Webhook signing & verification
- Outbound (we → customer):
payloadbytes are signed:X-Factor-Signature: t=<unix-ts>,v1=hex(HMAC-SHA256(secret, t + "." + rawBody))(Stripe-style; the timestamp prevents replay); customers verify with the secret they received at webhook creation; a 5-mintskew tolerance. - Inbound (customer → our
POST /v1/webhooks/incoming/:idautomation trigger): same scheme verified against the webhook's secret (constant-time); missing/invalid signature → 401UNAUTHENTICATED(the automation trigger never fires on a mismatch); the raw body is what's signed (the API buffers the body before parsing).
9. Audit Logging
9.1 Mandatory event table (CI-enforced checklist)
| Event | Emitted by | Metadata (beyond actor/ts/ip/ua) |
|---|---|---|
user.registered |
AuthModule | email (lowercased) |
user.login / user.logout |
AuthModule | sessionAgent, sessionId |
user.login.failed.streak |
AuthModule | failureCount, email |
user.password_reset |
AuthModule | resetTokenId (hash) |
auth.refresh.reuse_detected |
AuthModule | sessionId, replacedBy chain (the family-kill event) |
sso.login |
AuthModule | providerId, externalId |
page.created / page.updated / page.deleted |
PageModule | parentId, title (created/deleted) |
page.shared / share.created / share.revoked |
ShareModule | tokenHash, scope, password-set bool, expiry |
permission.changed |
permission services | resource{type,id}, grantee{type,id}, oldLevel, newLevel |
workspace.member.added / workspace.member.removed / workspace.member.role_changed |
WorkspaceModule | userId, role (old/new) |
database.created |
DatabaseModule | databaseId, propertyCount |
record.created / record.updated |
DatabaseModule (incl. form submissions, automation actions) | databaseId, changedProperties[] |
file.uploaded / file.deleted / file.virus_detected |
FileModule (worker) | fileId, size, mimeType (virus: engine + detection name) |
automation.executed |
AutomationModule (worker) | runId, status, actionType |
api_key.created / api_key.revoked |
ApiKeyModule | keyPrefix (never the key) |
webhook.created / webhook.revoked |
WebhookModule | url (host only in log lines; full in the row) |
scim.user.created / scim.user.updated / scim.user.deactivated |
OrganizationModule | externalId |
ai.budget.exhausted / ai.budget.override |
AIModule | budget kind, tokens |
realtime.connection.abuse |
apps/realtime | reason (slow consumer/oversize/malformed), ip |
permission.denied (throttled) |
PermissionGuard | requested vs effective level, reason |
admin.search.reindex / admin.retention.purge |
admin endpoints | scope, affectedCount |
Rules: (1) the list is a CI test (tests/audit/mandatory-events.test.ts) that
checks each service's mutation methods for their audit call — a mutation without its
event fails CI; (2) audit rows commit with the mutation (same transaction) — no
audit without the change; (3) append-only: no UPDATE/DELETE path in app code (retention
detaches partitions — database, §5.2);
(4) least-privilege content: hashes where a raw secret exists, host-not-path for URLs,
diffs capped at 4 KB.
9.2 Retention & access
- Org
retention_policies(scope: 'audit_logs',retainDaysorHOLD); the Phase-14 retention job purges by partition DETACH (never row delete), HOLD freezes purges (legal-hold). - Viewer: workspace ADMIN (or org policy
audit.viewergrant); export: streamed CSV, itself audited (admin.audit.export{filter, rowCount}).
10. Threat Model Summary (OWASP ASVS L2 mapping)
Threat inventory (STRIDE-flavored) with the control that closes it:
| # | Threat (asset) | ASVS L2 ref | Control |
|---|---|---|---|
| T1 | Credential stuffing / password brute force (accounts) | 2.1, 4.x | bcrypt 12, constant-time, per-IP + per-account rate limits, streak cooldown + audit, email verification gate |
| T2 | Session hijack via stolen refresh cookie (session) | 2.2, 15.x | HttpOnly+Secure+SameSite, rotation with reuse detection + family kill, per-device revocation, 30 d max, denylist for revoked JTIs |
| T3 | CSRF on cookie-auth mutations (integrity) | 15.2 | double-submit token + SameSite=Lax + Bearer-exempt design; WS token-in-query |
| T4 | Cross-tenant data leak (confidentiality) | 1.x, 4.x | workspaceId scoping at the repository (structural), NOT_FOUND-not-403 for foreign ids, CI property test, search/realtime/queue per-workspace scoping |
| T5 | Vertical privilege escalation (authorization) | 4.x | server-side guard on every op, role floors + explicit-NONE veto, owner grant cap, decision log, no frontend trust |
| T6 | Stored XSS via content/comments/share (integrity) | 15.1, 15.5 | structured block content (no HTML fields), markdown html:false, CSP without prod unsafe-eval, X-Frame-Options DENY, output DTO whitelisting |
| T7 | SQL injection (integrity) | 1.1, 1.2 | Prisma parameterization only, no raw SQL in app code, search DSL built from typed params |
| T8 | Malicious file upload (integrity, availability) | 6.1–6.4, 15.x | presign-locked type/size, magic-byte sniff (declared ≠ sniffed → reject), executable/script refusal, ClamAV gate (VIRUS_DETECTED never served), private buckets, signed URLs, prefix isolation |
| T9 | SSRF via webhooks/embeds/AI endpoints (confidentiality, availability) | 13.3 | IP-blocklist on resolved + pinned DNS, https-only public, redirect re-validation, allowlisted custom AI endpoints |
| T10 | Malicious formula / JS execution (integrity, availability) | 15.x | safe AST evaluator (no eval — CI grep), step/output bounds, function whitelist |
| T11 | Prompt injection via workspace content (integrity) | 15.x (AI) | context wrapped as untrusted data, server-side tool validation, user-identity execution (no elevation), budget caps, bad-citation measurement |
| T12 | API key leak (confidentiality) | 2.x, 4.x | hashed at rest, shown once, scoped (READ/WRITE/ADMIN), per-key rate limit, instant revocation + denylist, lastUsedAt monitoring |
| T13 | Webhook payload forgery (integrity) | 13.x | HMAC-SHA256 timestamped signatures (out + in), constant-time verify, replay window 5 min |
| T14 | Denial of service (availability) | 4.x, 14.x | Redis sliding-window rate limits (all scopes), AI budgets + per-workspace concurrency, realtime per-connection backpressure + slow-consumer drop, queue dead-letters, HPA on queue depth, upload size caps |
| T15 | Realtime abuse: oversized/malformed Yjs updates, room flooding (availability) | 14.x | 256 KB message cap, per-socket token buckets, 3-strike socket ban + audit, room-size cap 500, connect rate limits |
| T16 | Secret exposure in logs/stacks (confidentiality) | 13.x, 6.x | pino redactor, requestId-only error bodies, secrets env-only, envelope encryption at rest, no secrets in env files committed (.env.example is the template, CI secret-scan) |
| T17 | Supply chain (integrity) | 13.x | pnpm lockfile committed, pnpm audit + Renovate in CI, dependency review gate, no install scripts (onlyBuiltDependencies allowlist) |
| T18 | Admin endpoint abuse (availability, confidentiality) | 4.x | admin routes behind ADMIN role + (enterprise) IP allowlist policy, all admin ops audited, search reindex is a job not a raw SQL surface |
| T19 | Information disclosure via errors/timing (confidentiality) | 14.x | stable error codes (no stack leak), 404-for-foreign-ids, constant-time bcrypt (dummy hash), constant response on reset request, no count-leaking pagination for cross-tenant |
| T20 | Backup/restore mishandling (confidentiality) | 13.x | PITR + S3 versioning, backups encrypted (infra-layer KMS), restore runbook with least-privilege drill, backup bucket access = restore role only |
Known accepted risks (documented, reviewed each phase exit): (1) the ≤ 15 min window where a logged-out user's access JWT is valid on non-sensitive ops (closed for sensitive ops by the session double-check + JTI denylist on revocation); (2) plaintext internal pod traffic (NetworkPolicy-isolated, self-host assumption — mTLS tracked for Phase 15); (3) search permission staleness ≤ 5 min (over-surface only, open-time 403 is the hard gate); (4) client-side permission mirror can be stale (purely cosmetic).
11. Incident Response
11.1 Severity & response SLA
| Severity | Definition (examples) | Response |
|---|---|---|
| SEV-1 | active data breach / cross-tenant leak confirmed; auth bypass; RCE | immediate: war room, comms owner; ≤ 1 h mitigation (kill switch: revoke key class, disable endpoint, feature-flag off); 24 h postmortem draft |
| SEV-2 | vulnerability confirmed but not actively exploited (e.g. a leaked API key class, a vuln dep in a reachable path); sustained SLO breach with security angle | ≤ 4 h mitigation; 72 h postmortem |
| SEV-3 | suspicious-but-unconfirmed (abuse pattern, one malformed-event storm), single-user impact | next business day; ticket + monitor |
11.2 Kill switches (built for this)
| Lever | Scope | How |
|---|---|---|
JWT denylist + jwt:kill flag |
all access tokens | Redis flag checked in JwtAuthGuard (one GET, 5 s cache) — instant global auth kill (emergency only; users re-login after clearing) |
| Refresh family kill (per user / per session) | one user's sessions | revokeAll(userId) — the standard path for credential compromise |
Feature flags (config/feature-flags) |
AI module, realtime, webhooks, forms, public API | per-flag off = clean 404/503 with stable code; no code deploy needed |
| Per-workspace | AI budget 0, rate limits 1, workspace SUSPENDED (all members) | workspace-level containment for tenant-level abuse |
| Provider circuit breakers | AI | open breaker = no external calls (data exfil path closed) |
| Worker pause (per queue) | side effects | BullMQ pause by queue name (stop webhook dispatch / automation runs while investigating) |
11.3 Playbook (the standard sequence)
1. DETECT alerts (audit anomaly, abuse metrics, CVE, SECURITY.md report) → on-call
triage: confirm severity, open incident channel + doc
2. CONTAIN kill switch sized to the blast radius (smallest first: user → workspace
→ tenant-class → global)
3. ASSESS timeline from audit_logs (actor, ip, resources — the primary evidence;
export to the incident bucket via `admin.audit.export`); scope the data
touched (tenant? rows? fields?)
4. REMEDIATE rotate the affected secret class (refresh family / API keys / master
key), patch, force affected users to re-auth, re-run the exact exploit
path to verify
5. NOTIFY legal/privacy assessment (cross-tenant or PII ⇒ within 48 h), affected-
user email per severity template; SEV-1 → platform status page
6. RECOVER un-kill in stages (flag on → 24 h watch → full), confirm metrics
7. LEARN postmortem (SEV-1 24 h / SEV-2 72 h): timeline, root cause, which
controls caught/missed, owned action items; update the ASVS table §10
11.4 Standing security operations
- CI (every PR): lint + typecheck + unit + integration + security scan
(
pnpm audithigh+, secret scan, dependency review, theeval-grep, mandatory-audit-events test, CSP/token snapshots). - Nightly: integrity scanner (plain-ref orphans, database §2.4), audit completeness (mutation-without-audit), search-lag metrics, failed-login streaks, abuse digest (429 storms, realtime bans, bad citations).
- Monthly: Renovate dependency PRs; backup restore drill (PITR to a scratch cluster, verify a known row, update the runbook); key-rotation review (JWT dual-secret hygiene, master-key age).
- Quarterly: ASVS L2 re-walk (the §10 table is the working sheet); load/security suite re-run; threat-model delta review (new features → new STRIDE rows).