Security Architecture — AI Software Factory
Deep security design for the platform. Requirements: PS§24 (product-requirements.md PR-112…120, PR-158), NFR-SE1…SE8 (non-functional-requirements.md §4); coding-level rules in coding-standards.md §10; schema backing in database-architecture.md (identity, permissions, audit, api_keys, webhooks tables); release gates in testing.md §7 and deployment.md §6.
Prime directive (AGENTS.md rule 19): never trust frontend authorization. The UI enforces permissions for speed; the API enforces them for truth. Every statement below that says "the client may X" is UX; the enforceable fact is always the server-side rule.
1. Security context & trust boundaries
UNTRUSTED PARTIALLY TRUSTED TRUSTED
───────────── ────────────────── ───────
browsers (any JS) ──TLS──► nginx / ingress api / worker /
client JS bundles │ realtime / web (SSR)
uploaded file bytes ─────────► ▼ ┌──────────────────┐
import files (md/html) ────────► api (Zod at the door) │ Postgres (PG) │
webhook payloads (in) ────────► services (authz engine) │ Redis │
AI model output ─────────► repositories (tenant scope) │ S3/MinIO │
email links (tokens) ─────────► │ OpenSearch │
└──────────────────┘
Boundaries and their controls:
| Boundary | Threat | Control |
|---|---|---|
| Internet → app | eavesdrop, tamper, clickjacking, downgrade | TLS 1.2+ (1.3 preferred), HSTS, X-Frame-Options: DENY, CSP (§7) |
| Browser → API | injection, CSRF, brute force, abuse | Zod validation (§8), CSRF strategy (§10.2), rate limits (§10.1), auth (§2) |
| API → domain | authorization bypass, cross-tenant leak | permission engine on every op (§4), tenant-scoped repositories (database-architecture §10.1), cross-tenant test suite (CI gate) |
| API → stores | SQLi, unsafe deserialization | Prisma parameterization (coding-standards S3), typed JSONB via Zod (no JSON.parse into executable anything), no eval anywhere (S2) |
| Client → S3 | object guessing, unauthorized read | private buckets, presigned URLs with scoped keys, per-file permission re-check on download (§9) |
| Platform → external | SSRF, secret exfil, open redirect | URL validation + private-IP block (§10.4), secrets by reference (§11), redirect allow-list |
| AI → platform | prompt injection → data exfil / destructive tool calls | tool calls validated against Zod + executed with the user's permissions, no raw SQL, output is data (§8.5, §13 T9) |
| Operator → data | insider, leak | audit on every sensitive op (§12), secrets out of code (§11), least-privilege service accounts, admin actions audited |
2. Authentication lifecycle (AuthN)
2.1 Token model
| Token | Format | Lifetime | Storage | Rotation |
|---|---|---|---|---|
| Access | JWT (HS256, JWT_SECRET), claims {sub, ws?, role, jti, iat, exp} |
15 min (JWT_EXPIRES_IN) |
client memory (TanStack Query client); never localStorage | none — short-lived; re-issued via refresh |
| Refresh | opaque 256-bit random (URL-safe) | 30 days (REFRESH_TOKEN_TTL_DAYS) |
HttpOnly; Secure; SameSite=Strict; Path=/api cookie factory_rt (and mobile-safe fallback: returned in body for non-browser clients, hashed at rest in refresh_tokens) |
every use (rotation, §2.3) |
| Session | opaque id (groups a login episode) | sliding 30 d | sessions table (hashed) |
revoked via UI |
Why this shape: the access JWT is bearer-but-short (15 min exposure
window); the refresh credential is long but (a) cookie-scoped (no XSS read
for the cookie-auth path — HttpOnly), (b) hashed at rest (a DB leak does
not yield usable tokens), (c) single-use per rotation (theft is detected and
the family burned, §2.3).
2.2 Sequence: register → verify → login → use
sequenceDiagram
participant U as Browser
participant A as API (auth module)
participant P as Postgres
participant M as Mail worker
U->>A: POST /auth/register {email, password, name}
A->>A: Zod validation (shared schema); bcrypt(cost=12)
A->>P: INSERT user(UNVERIFIED) + personal workspace + OWNER member (1 txn)
A->>M: enqueue email:verify (token, 24h, hashed in email_verifications)
A-->>U: 201 (unverified session pinned with verify banner)
Note over U,M: user clicks link
U->>A: POST /auth/verify {token}
A->>P: single-use consume; user → VERIFIED
U->>A: POST /auth/login {email, password}
A->>A: constant-time bcrypt compare; check status (SUSPENDED→403)
A->>P: session row + refresh token row (tokenHash) + audit user.login.success
A-->>U: 200 {accessToken(15m)} + Set-Cookie factory_rt (HttpOnly, 30d)
loop every request
U->>A: Authorization: Bearer <accessToken>
A->>A: verify JWT (signature, exp); load ctx (user, ws membership, role)
end
U->>A: POST /auth/refresh (cookie)
A->>P: lookup tokenHash; must be ACTIVE & unexpired
A->>P: mark revoked + replacedBy=new; INSERT new token (same family)
A-->>U: new access JWT + new cookie
Rules:
- AN1: login is the only path that issues a refresh token; the token family id = the first refresh token id; rotations stay in the family.
- AN2: access tokens are verified statelessly (signature + exp +
jtinot in a revocation blocklist — revocation happens at the refresh layer; 15 min is the max staleness). Admin "revoke user" additionally bumps a per-usertokenVersionclaim check via a 60 s Redis cache. - AN3: suspended users (
UserStatus.SUSPENDED— SCIM deactivate, admin action) fail login with403and any in-flight refresh is rejected; their sessions are revoked. - AN4: password policy: ≥ 10 chars, ≥ 2 classes, not in the top-10k
breached list (offline check table, updated monthly; org policy can
tighten via
organization.policy.password.minLength).
2.3 Refresh rotation & replay (FR-PR-003/004)
present(token T):
row = lookup(tokenHash(T))
if !row: → 401 (unknown token) → client: clear cookie, redirect to login
if row.revokedAt: → REPLAY: revoke ALL tokens of row's family (UPDATE
refresh_tokens SET revokedAt=now() WHERE family = row.family)
audit auth.refresh.replayed {userId, ip, ua}
→ 401 (client clears cookie; user is signed out everywhere)
if row.expiresAt < now: → 401 (expired; client re-login)
new = create token (same family, +30d from ORIGINAL issue)
mark row revoked, replacedBy = new
issue new access JWT (new jti)
The replay branch is the theft detector: a stolen token used after the victim's legitimate rotation invalidates the thief's copy AND the victim's current family (the victim re-logins; the thief gets nothing). This is deliberately over-eager — one false positive costs a re-login; the alternative (undetected account takeover) is the incident.
2.4 Session revocation (FR-PR-005)
POST /auth/logout(cookie): revoke the presented token + its session.DELETE /auth/sessions/:id: revoke one session (its tokens).- "Sign out everywhere": revoke all tokens + sessions for the user; audit
session.revoked {count}. - Token
tokenVersionbump (AN2) is the fast path for admin/user-initiated global revocation of access tokens mid-window.
2.5 Enterprise SSO (PR-011/012 — Phase 14)
OIDC (auth code + PKCE, state + nonce, id_token signature verified
against JWKS, iss/aud pinned) and SAML 2.0 (assertion signature,
NameID → sso_identities.externalId, ACS endpoint). Identity linking:
email match → link; else JIT-create a passwordless verified user.
enforce=true → password login for the org returns 403 SSO_REQUIRED.
SCIM deactivate → SUSPENDED + full revocation (AN3). All SSO logins
audited (sso.login.*). The trust model and threat catalog below cover
the SAML/OIDC handshake; SCIM is TASK-0132.
Trust boundaries. The SP (this platform) places NO trust in: the
IdP's transport, the assertion/ID-token content, or the browser mid-
handshake (the ACS is session-less by design — the signature IS the
authentication). The SP pins trust to exactly three things: (a) the
org-configured IdP signing certificate (SAML) or the IdP's JWKS
(OIDC), (b) the Audience = the SP entityID, (c) the single-use
Response ID / nonce.
Threat → control (SAML).
| Threat | Control |
|---|---|
| Forged / tampered assertion | Enveloped RSA-SHA256 signature over the Assertion (exc-C14N); bad_signature |
| Assertion from a different IdP / attacker's IdP | Certificate binding — the KeyInfo X509Certificate must equal the org's current OR previous configured cert, checked before signature verification, so a foreign key is never even evaluated (cert_mismatch) |
| Replayed valid assertion (capture the form-POST) | Response ID single-use for 10 min (Redis SET NX EX 600); replayed. A forged assertion can't consume a replay slot (the replay check runs after full validation) |
| Assertion issued for another SP | Audience must include the SP entityID (audience_mismatch) — the entityID embeds the org slug, so cross-tenant reuse fails |
| Assertion delivered to the wrong endpoint | Destination === the ACS URL (destination_mismatch) |
| Expired session | Conditions.NotOnOrAfter with ±60 s skew (expired) |
| Wrong outcome | Status must be Success (status_not_success) |
| Issuer spoofing | Optional idpEntityId pin (issuer_mismatch) |
| XML bombs / XXE | <!DOCTYPE + external entities rejected before parsing (doctype_forbidden) |
| Malformed / non-XML body | Parse-failure reason before any crypto (malformed_xml) |
Threat → control (OIDC).
| Threat | Control |
|---|---|
| Authorization-code theft (open redirector) | PKCE S256 — the exchange requires the original verifier; mismatch → token_exchange_failed |
state CSRF / response injection |
state is 128-bit, Redis-bound, single-use, TTL 10 min (state_invalid / state_replayed) |
Forged / algorithm-confused id_token |
JWKS-fetched RSA key; alg must be RS256 with a matching kid — none is refused (unsupported_alg) |
| Token from another tenant/issuer | iss pinned to the configured issuer; aud must include the client id |
| Stale / future token | exp, nbf (+60 s skew) |
nonce reuse across sessions |
nonce must equal the per-authorization value (nonce_mismatch) |
Certificate rotation (SAML). PATCH /orgs/:id/sso with a new
certificate moves current → previous; the validator accepts either, so
an IdP key rollover has a grace window without a login blackout. The cert
PEM is AES-256-GCM encrypted at rest (org secrets store — same pattern as
TASK-0130 webhook secrets); the admin API returns only
certificateConfigured: true (reveal-once).
Enforce mode + lockout. ssoEnforced is mirrored from
SsoProvider.enforce into Organization.policy (single source of truth
plus a queryable mirror). Password login for a member of an enforcing org
is refused before bcrypt; unknown emails are rejected the same way (no
enumeration timing). Lockout runbook: the SSO path is never blocked
by enforcement. An owner whose IdP is down either (a) signs in via SSO
once the IdP recovers (/sso/start/<slug>), or (b) turns enforce off with
a pre-enforcement session. There is deliberately no elevated
service-account credential — the enforcement state is plain data, so
recovery needs no second trust path (tested in
tests/e2e/specs/sso.spec.ts, "enforce mode…").
Discovery (anti-enumeration). GET /auth/sso/options?email=… reveals
only that SSO exists for orgs the email already belongs to; an unknown
(or not-yet-JIT-provisioned) email gets { orgs: [] }. First-time SSO
users enter via the org-provided link /sso/start/<slug>, public by
design (it only initiates a redirect to the org's own IdP — the IdP's
login page is the first user-facing surface).
JIT provisioning boundary. A JIT/linked user gets membership ONLY in
the SSO org: role = policy.ssoDefaultRole capped at MEMBER (owner /
admin are never granted by SSO, existing roles are never demoted),
auto-joined to the org's non-personal workspaces; other orgs are
untouched. Assertion emails are treated as verified — the IdP vouches for
the address, which is exactly why discovery stays minimal-info.
3. Authorization engine (AuthZ)
3.1 The two layers
Layer 1 — RBAC (workspace roles): the base capability matrix (product-requirements §4) maps each role to a base level per resource kind:
| Resource kind | Owner | Admin | Member | Guest |
|---|---|---|---|---|
| workspace (settings/members) | FULL_ACCESS | FULL_ACCESS | NONE | NONE |
| teamspace (create/manage) | FULL_ACCESS | FULL_ACCESS | NONE | NONE |
| page / database (content) | FULL_ACCESS | FULL_ACCESS | EDIT | NONE |
| file (read) | FULL_ACCESS | FULL_ACCESS | VIEW | NONE |
| audit log (view) | FULL_ACCESS | VIEW | NONE | NONE |
| api keys / webhooks (manage) | FULL_ACCESS | FULL_ACCESS | NONE | NONE |
Layer 2 — resource grants (permissions table): explicit
(grantee, resource, level) rows, where grantee ∈ {user, team, role} and
level ∈ {NONE, VIEW, COMMENT, EDIT, FULL_ACCESS}. Grants live at:
workspace, teamspace, page, database, record, file.
3.2 Level semantics
| Level | Grants |
|---|---|
| NONE | nothing — and as an explicit grant, it is a deny override (beats inheritance at its level, §3.3) |
| VIEW | read the resource (page/blocks/properties/records as rendered to viewers) |
| COMMENT | VIEW + create/reply/resolve comments, mention |
| EDIT | COMMENT + create/modify/delete content (blocks, records, properties values, views) |
| FULL_ACCESS | EDIT + manage permissions on the resource, delete, share, export, manage database schema |
Required level per operation (the guard's input): read = VIEW; comment = COMMENT; any content write = EDIT; schema change / grant change / delete / share / export = FULL_ACCESS (Owner/Admin baseline may lower the baseline requirement per §3.1 but never bypass an explicit NONE — deny always wins).
3.3 Evaluation algorithm
can(ctx, resource, requiredLevel) -> bool:
# ctx: {userId, workspaceRole, teamIds, workspaceId}
# resource: {kind, id, workspaceId, pagePath?} (pagePath = [root..resource] for pages)
base = roleBaseLevel(ctx.workspaceRole, resource.kind) # §3.1 table
grants = collectGrants(ctx, resource)
# rows where granteeType/granteeId ∈ {("user", uid), ("team", t) for t in teamIds,
# ("role", ctx.workspaceRole)}
# AND resource matches:
# - exact (resourceType, resourceId) = resource, OR
# - (for pages) an ANCESTOR in pagePath, OR
# - (for records/files) the parent page/database ancestor chain, OR
# - workspace/team grants (kind ∈ {workspace, teamspace}) apply to ALL
# content inside that team/workspace unless a closer grant says otherwise
explicit = max(level of grants matching this resource exactly) # NONE counts as "present"
inherited = max(level of ancestor grants) # walk DOWN the page tree
if explicit is present:
effective = explicit # an explicit grant on the resource wins (including NONE = deny)
else:
effective = max(base, inherited)
# deny-override from a closer ancestor beats a farther ancestor:
# the walk keeps the NEAREST ancestor grant that is NONE → effective = NONE
return effective >= requiredLevel # NONE < VIEW < COMMENT < EDIT < FULL_ACCESS
Worked examples (all unit-tested in the decision matrix — testing.md §3.2):
- Member, no grants, any page → base( Member, page ) = EDIT → can edit.
- Guest, explicit VIEW grant on page P → effective VIEW → can read P, cannot comment.
- Member with an explicit NONE on team T → everything under T: effective NONE → the member is removed from their own team's space (deny beats the role baseline). This is the "lock a team away from most members" tool.
- Page Q under page P (P has explicit EDIT for user U); Q has no grant → Q inherits EDIT from P.
- Q under P (P: EDIT for U), Q has explicit VIEW for U → Q's explicit VIEW wins → U can read Q but not edit it (explicit narrows inheritance).
- Record R in database D under page P; grant EDIT on P → R inherits EDIT (records inherit through their page; the database's own grants also apply — max of the two paths).
3.4 Guard integration (NestJS)
// controller
@Post()
@RequirePermission("page", "EDIT") // declares (resourceType, requiredLevel)
async update(@Body() dto: UpdatePageDto, @CurrentUser() ctx: RequestContext) {
return this.pagesService.update(ctx, dto); // service re-checks with the RESOLVED id
}
PermissionGuard(inpackages/permissions, wired inapps/api/common/guards): runs after auth, before the handler. It resolves the resource id from the route params / DTO after the tenant-scoped existence check (so a missing resource is 404, not 403 — no existence oracle), then callsengine.can(ctx, resource, level). Failure →ApiError(PERMISSION_DENIED, 403)+ audit hook (denied attempts are sampled-logged atwarn, never the full grant table — L4).- The service re-checks (defense in depth, coding-standards S5): the guard proves "this request may touch this kind of resource"; the service proves it for the specific row(s) it is about to write (a batch endpoint checks every id in the batch, and a 403 aborts the whole batch).
- Permission writes (grant management) require FULL_ACCESS on the target and the "can't grant what you don't have" rule: a grantee's new level ≤ the granter's effective level on the resource (Owner/Admin are exempt — they hold FULL_ACCESS by baseline).
- Deny is silent by default: a 403 never states which check failed, which level is missing, or whether the resource exists for other users (404 for cross-tenant, 403 for in-tenant — database-architecture §10.1).
3.5 Decision cache & invalidation
- Engine results cached in Redis: key
perm:{ws}:{granteeRef}:{resourceType}:{resourceId}:{level}→ bool, TTL 60 s.granteeRefnormalizes user→(user + teamIds + role) so team grants are covered by the user-keyed entry. - Invalidation (all synchronous, before the response returns): any
permission write (
permission.changed) flushes the workspace'sperm:{ws}:*keys (scoped SCAN-DEL, workspaces have < 10k grant keys — measured < 5 ms); a role change flushes the role-keyed + affected user's entries; a page move (reparent) flushes the grant entries for the affected page path. - Cache is an optimization only — a cache miss recomputes (the full walk is < 2 ms for a 500-grant workspace; property-tested, testing.md §3.2).
- Cache correctness test: the integration suite includes "grant change → immediate denial/grant on the next request" assertions (no 60 s window for permission changes — the flush is the contract).
4. Multi-tenancy (the isolation contract)
- Scope at the repository (database-architecture §10.1): no
tenant-scoped table is queryable by bare id; every read is
findFirst({where: {id, workspaceId}}). The cross-tenant matrix (50 random cross-workspace pairs, 0 leaks) is a CI gate on every PR (NFR-P1). - No client-controlled tenant switch: the workspace is bound to the
access token (
wsclaim) or resolved from the route (/w/<slug>→ membership check) — a user can only act in workspaces they belong to. - Search isolation at query time (permission snapshot per document, functional §11) — a leaked index document cannot be read by the query filter; the snapshot is refreshed on every permission change event.
- Realtime isolation: rooms are
(workspace, docId); join validates token workspace == room workspace ANDcan(view)on the doc (edit level required to send updates — VIEW peers receive but their updates are rejected with a typed frame error). - Queue isolation: every domain event payload carries
workspaceId; consumers re-scope on the row they touch (worker code uses the same tenant-scoped repositories — the worker is not a back door). - File isolation: object keys embed the workspace id
(
<ws>/<file>/<version>); the presigner only mints URLs for keys it verified belong to the caller's workspace (per-file check, §9).
5. Transport & headers
TLS: 1.2+ (1.3 preferred) at the edge (nginx §deployment.5); internal service traffic in k8s is plaintext-on-trusted-network (mTLS is a Phase-15 enterprise option, not v1). The exact header set (nginx + API agree; the API sets CSP, nginx sets the rest — see the split rationale in deployment §5):
| Header | Value | Set by |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
nginx (always) |
X-Content-Type-Options |
nosniff |
nginx |
X-Frame-Options |
DENY |
nginx (no embeddable surface — public shares are full pages) |
Referrer-Policy |
strict-origin-when-cross-origin |
nginx |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
nginx |
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: <s3-host>; connect-src 'self' ws: wss: <s3-host>; frame-src https://www.youtube.com https://player.vimeo.com https://*.loom.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self' |
API/Next (per-route; share/public routes drop style-src 'unsafe-inline' is kept for the editor's inline styles — inline scripts are never allowed; unsafe-inline for style is the documented exception: the editor sets per-block color inline, and a nonce-based policy is a Phase-15 refinement) |
Cache-Control (API JSON) |
no-store (authed), public, max-age=31536000, immutable (hashed static assets) |
API / nginx |
X-Request-Id |
echo/generate (correlation, coding-standards L2) | API |
Set-Cookie |
per §2.1 (refresh: HttpOnly; Secure; SameSite=Strict; Path=/api; CSRF: Secure; SameSite=Lax; Path=/; session display cookies: HttpOnly where possible) |
API |
Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy |
not set (they break embeds + some CDNs; revisited if we ship SharedArrayBuffer features) | — |
CORS: single allow-list (WEB_URL + the API docs origin), credentials
allowed only for that origin, methods limited per route, Vary: Origin.
No * anywhere. WS origin check: the gateway validates the Origin
header against WEB_URL on connect (rejects cross-origin sockets before
auth).
6. Cookies, sessions & CSRF
- Cookie map:
factory_rt(refresh,HttpOnly; Secure; SameSite=Strict; Path=/api— Strict because refresh is a same-origin flow),factory_csrf(readable,Secure; SameSite=Lax; Path=/),factory_theme(UX-only, non-sensitive). No other cookies; no tracking. - CSRF strategy (state-changing requests): double-submit token.
factory_csrfholds an opaque value; state-changing browser requests (POST/PUT/PATCH/DELETE to the API) must send the same value in theX-CSRF-Tokenheader. The guard compares (constant-time) and rejects with 403 on mismatch. Why this works: a cross-origin page can set the cookie (SameSite=Lax still allows top-level GETs, but the header can only be set by same-origin JS — that's the whole game) and can read the cookie, but it cannot make the API accept a header it can't control. - Exempt from CSRF: API-key-authenticated requests (bearer header is itself the cross-origin barrier — an attacker with the key already has the account), OAuth token requests (code/PKCE), the public form submission endpoint (idempotent-by-design: it creates data owned by the workspace, rate-limited per IP §10.1, and the workspace is the one that subscribed to it — the documented trade-off, NFR-SE5 load-tested).
- Login/logout themselves: login is a GET-able target only via the password (brute-force limited); logout is CSRF-protected (a logout CSRF is nuisance, not breach — but we protect it anyway).
v1 cookie flags (TASK-0134 — single source). Every
Set-Cookiein the API is emitted from one place,apps/api/src/common/cookies.ts: the refresh cookiefactory_refreshcarriesPath=/; HttpOnly; SameSite=Lax(+Securein production only, since dev runs plain-HTTP on :4000) plus aMax-Age.tests/security/auth-paths.test.tsasserts these flags on everySet-Cookiein the live auth flow (the "single source" is what makes that assertion meaningful). Thefactory_rt/factory_csrfnames in the table above are the design target; v1 usesfactory_refresh.
7. Validation & sanitization
7.1 The Zod pipeline (coding-standards §5)
Every untrusted byte passes a named Zod schema at the boundary before any logic runs:
| Boundary | Schema home | Notes |
|---|---|---|
| HTTP bodies/params/query | packages/shared/schemas/*.ts (per endpoint) |
.strict() on all external input; 400 with field map on failure |
| WS frames | packages/shared/schemas/realtime.ts (frame union) |
bad frame → typed error frame + connection counter; 10 bad frames = disconnect |
| Queue payloads | per-event schema in shared/schemas/events.ts |
a malformed event is dead-lettered, never partially applied |
| Webhook inbound bodies | shared/schemas/webhook-inbound.ts |
signature check first, then schema |
| Import files | parser-specific (md/html/csv/json/notion-zip) → normalized block-tree schema | hostile inputs are a first-class test category (testing.md §3.6) |
| AI tool-call results | shared/schemas/ai-tools.ts |
a rejected tool result is fed back, max 3 retries, then the op fails cleanly |
| Env | apps/api/src/config/env.ts |
boot fails on invalid env (no half-configured service) |
7.2 Block content sanitization
Block content is structured JSON (Zod per type) — the primary defense is
there is no HTML field. The residual HTML surfaces:
| Surface | Sanitization |
|---|---|
Rich text (RICH_TEXT property, block text) |
stored as markdown source; rendered client-side by the markdown pipeline → DOMPurify allow-list (tags: `p,br,strong,em,u,s,code,pre,a[href^=https |
| Search snippets | server-side <mark> highlighting → client strips all tags except mark → re-renders (no raw HTML path) |
| Import HTML | full pipeline: parse (readability-style extraction) → strip (all scripts, styles, iframes, event attrs, javascript:/data: (except data:image for images ≤ 5 MB base64) URLs) → normalize to blocks; the output is the block schema (Zod) — anything non-conformant is dropped, not escaped-and-kept |
| Markdown import | the markdown parser is configured with no raw-HTML pass-through (raw HTML blocks in the source are dropped with a report entry); link protocols allow-listed as above |
| Comments | same rich-text pipeline as RICH_TEXT |
| User-generated URLs (bookmark, URL property, embed) | protocol allow-list (http/https only; embed additionally provider-allow-listed); stored as-is (display-only), rendered via <a>/iframe sandbox (functional §4.2 embed sandbox attrs) |
| Filenames / titles / names | length-capped, control chars stripped (log-safe + render-safe); display-only (never executed, never in paths — object keys are server-generated) |
7.3 Output encoding
React escapes by default; the only dangerouslySetInnerHTML uses are the
sanitized-markdown render and KaTeX output (KaTeX's own trust config is
left false). The CI security suite (testing.md §7) feeds XSS payloads into
every string surface (title, comment, block text, property value, filename,
share token, URL) and asserts no script executes / no raw tag renders.
7.4 AI output (the new untrusted surface)
AI responses are data: chat text renders through the markdown pipeline
(same sanitizer); generation (pages/databases) arrives as tool calls
validated by Zod before any write; the executor runs with the invoking
user's permissions (the AI cannot grant itself access — §13 T9). No model
output ever reaches a prompt-injection-sensitive sink (no "ignore previous
instructions" surface: the platform's own prompts are static templates with
a # User content (untrusted) delimiter, and retrieval results are
injected as data blocks with the same marking).
8. File pipeline security
prepare (API: size/mime/workspace checks) ─► presigned PUT (15 min, exact key)
─► complete (API) ─► worker: [1] ClamAV scan → [2] magic-byte MIME sniff
→ [3] metadata (sha256, dimensions, duration) → [4] thumbnail
→ [5] status=READY (+ file.uploaded event)
| Control | Rule |
|---|---|
| Size limits | per-file max 500 MB (plan-configurable); images 25 MB; form files 25 MB; avatar 5 MB; total per workspace from plan (usageLimits.storageMb, NFR-S6). Limits enforced twice: at prepare (metadata) and at complete (actual object size from S3 HEAD — a client can PUT more than it declared). |
| MIME | magic-byte sniff is authoritative (never the extension): the pipeline re-derives mimeType from bytes; extension/sniff mismatch → FAILED + report (spoofed .png that is actually .exe is caught here even before ClamAV). Per-plan mime allow-list; executables (.exe, .msi, .bat, .cmd, .sh, .app, mach-o, pe32+) blocked platform-wide. |
| Virus scan | ClamAV worker stage, synchronous in the pipeline (file is not READY until clean); verdict VIRUS_DETECTED → object moved to a quarantine prefix, never served, admin report shows signature + hash; scan failures (engine down) → FAILED (fail-closed, not fail-open). EICAR test in CI (NFR-SE6). |
| Signed URLs | downloads/preview via presigned GET, 15 min TTL, scoped to the exact key; uploads via presigned PUT, 15 min, exact key + content-length range; URLs are single-object (no prefix wildcards). |
| Private buckets | the uploads bucket has no public ACLs, no anonymous access, no public bucket policy (MinIO: mc anonymous set none); the only read paths are presigned URLs and the API download endpoint (which re-checks permission, §4.4 below). |
| Per-file permission check | a download is: (1) can(file.page/page, VIEW) via the engine, (2) the file's own override (files.permissions) may restrict further (a file can be hidden from editors), never grant more, (3) then the signed URL is minted. Public shares: only files whose page is publicly shared (and allowComments-irrelevant) are downloadable by share visitors — the share route mints URLs only for files passing the share scope check. |
| Keys | server-generated <wsId>/<fileId>/<version> (user filenames are display-only, sanitized for logs); no user input in any path segment. |
| Rendering | image previews served as image/* (browser sandboxed by content-type); PDFs via the native viewer (X-Content-Type-Options: nosniff + correct content-type); HTML-type files are never served as HTML (served as attachment) — stored HTML files download, they don't render (the XSS-surface kill switch). |
| Upload abuse | presign rate limit (20/min/user); orphaned uploads (prepared, never completed) are reaped after 24 h (object + row); concurrent uploads per user capped at 4. |
9. Abuse protection
9.1 Rate-limit tiers (Redis sliding window)
Per (user, tier) and per (IP, tier); the stricter of the two applies.
Headers: X-RateLimit-Limit/Remaining/Reset; on limit → 429 RATE_LIMITED
Retry-After.
| Tier | Endpoints | Limit (default, plan-tunable) |
|---|---|---|
auth-strict |
login, forgot-password, verify, reset | 5/min/IP; 3/day/IP registration; 3/hour email-send |
auth |
refresh, logout, sessions | 60/min |
write |
all state-changing API routes | 300/min/user (RATE_LIMIT_MAX) |
read |
all other API routes | 600/min/user |
search |
/search, suggestions |
60/min/user |
ai |
AI endpoints | 20/min/user + workspace token budget (functional §16) |
upload |
file prepare/complete | 20/min/user |
public-form |
public form submissions | 10/15 min/IP |
public-share |
/share/:token reads |
120/min/IP/token (throttle link sharing abuse) |
api-key |
public API v1 | plan default (workspace.usageLimits.apiRps) or per-key rateLimit |
ws |
realtime messages | 60 msg/s/conn sustained (burst 100); join: 30/min/conn |
Implementation: one Redis Lua script (sliding window, atomic), keys
rl:{tier}:{scope}; the guard caches the "not limited" verdict 1 s to
avoid a Redis round-trip per request at scale (NFR-S3 headroom). NFR-SE5
load test: 5k credential-stuffing attempts → 0 bypasses.
v1 implemented buckets (TASK-0134 — the authoritative, tested set). The tier table above is the design target. v1 ships a smaller subset; the exact defaults below are pinned in
tests/security/rate-limits.test.ts(a change without a finding + justification fails CI). The two new buckets (incoming-webhook, public-form) were added by TASK-0134 because those public third-party endpoints were previously unbounded:
Bucket Key v1 default Notes API (per credential/IP) rl:api:<user|ip>100/min ( RATE_LIMIT_API)generic window Auth (login/register/reset) rl:auth:<user|ip>10/min ( RATE_LIMIT_AUTH)credential endpoints Per API key window per key 600/min ( RATE_LIMIT_API_KEY) orkey.rateLimitthe key's value wins Incoming webhook (NEW) auto:webhook-rate:<webhookId>60/min ( WEBHOOK_RATE_PER_MINUTE)checked before signature verification Public form (NEW) form:rl:{slug}:{ip}5/min ( FORM_SUBMIT_LIMIT, 60 s)per IP+form; stricter than the spec's 20/min example (delta recorded, AGENTS rule 29) The design tiers not yet implemented in v1 (per-tier
write/read/search/ai/upload/wslimits) are tracked as follow-ups, not audit findings.
9.2 CSRF
§6 (double-submit X-CSRF-Token for browser state changes; API keys and
public forms exempt by design, documented).
v1 implemented model (TASK-0134, finding F-107 — recorded deviation, AGENTS rule 29). v1 does not ship the double-submit
factory_csrftoken. Instead: every state-changing web request is authenticated with an in-memory Bearer access token (never a cookie), and the only cookie-authenticated route isPOST /api/v1/auth/refresh(the 401 retry), whose cookiefactory_refreshisHttpOnly; SameSite=Lax. This is CSRF-safe: a cross-origin page cannot read the in-memory Bearer, and aSameSite=Laxcookie is not sent on cross-site POST. The double-submit design in §6 remains the target model; the 100% coverage of the state-changing surface (all routes through the Bearer client, refresh exempt, OAuth/SAML callbacks using state/nonce) is proven by the route-walk intests/security/auth-paths.test.ts.
9.3 Secure cookies & CORS
Cookie attributes per §2.1/§6 (Secure always in prod — the cookie is
simply not set over http in production; HttpOnly on everything except
factory_csrf/factory_theme; SameSite Strict for auth, Lax for CSRF).
CORS allow-list per §5. No cookie is readable by JS except the two
non-sensitive ones.
9.4 External URLs & SSRF
Any URL the platform will request (webhook targets, integration calls, AI custom endpoints, bookmark OpenGraph fetch, embed validation):
- parse + scheme allow-list (
https:in prod;http:only for loopback in dev); - hostname: DNS-resolve before the request; reject if any resolved IP
is private/loopback/link-local/reserved (
10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, fc00::/7, 0.0.0.0) — re-resolve and re-check at dispatch time (DNS-rebinding window is still closed by connecting to the checked IP via a dial callback); - redirect policy: max 3 hops, every hop re-validated (a public URL that
302s to
http://169.254.169.254is rejected); - egress: in k8s, webhook/integration egress goes through the worker's NetworkPolicy-allowed CIDRs only (the api pod has no direct internet egress except the AI provider + S3 — egress allow-list is the second line). User-provided URLs that are only displayed (bookmark cards, URL properties) are not fetched server-side in v1 except bookmark OpenGraph (fetch is best-effort, failures render a plain link — the fetch path goes through the same SSRF guard).
10. Secrets management
10.1 Inventory & flow
| Secret | Source | Storage at rest | Rotation |
|---|---|---|---|
JWT_SECRET, REFRESH_TOKEN_SECRET, SESSION_SECRET |
env (Vault/SOPS) | never in DB | dual-key overlap window (*_PREV) for rolling restarts (deployment §3.1) |
MASTER_ENCRYPTION_KEY (envelope DEK) |
env (Vault/SOPS), 64 hex | never in DB | §10.3 |
| S3/SendGrid/SMTP/OpenSearch/AI provider creds | env / secret store | Vault KV / SOPS | per-provider SLA; app reads at boot (restart = new creds) |
| User passwords | user input | bcrypt hash in users.passwordHash |
n/a (hash) |
| Refresh/session/verify/reset/invite tokens | generated | SHA-256 hash only | single-use / TTL / rotation |
| API keys | generated | SHA-256 hash + display prefix (fk_live_ab12) |
raw shown once at creation; user self-rotates anytime |
| Webhook signing secrets | generated | raw kept envelope-encrypted (dispatcher must HMAC) + secretHash integrity column |
raw shown once; rotation re-keys deliveries |
| Integration credentials (Slack tokens etc.) | user input | envelope-encrypted in integrations.config |
user-managed; status=error on provider 401 |
| Share passwords | user input | bcrypt hash in shares.passwordHash |
n/a |
| SSO client secrets / SAML certs | admin input | envelope-encrypted (by reference in sso_providers.config) |
admin-managed |
10.2 Envelope encryption design
DEK (data key) = MASTER_ENCRYPTION_KEY (env) — AES-256-GCM
value → payload = { v: 1, alg: "A256GCM",
nonce: 12B-random,
ct: AES-GCM(DEK, plaintext),
tag: 16B }
- One DEK per deployment (v1); the
vfield allows an algorithm bump. - Where used:
integrations.config, webhook signing secrets, SSO secret refs, (Phase 14) org-level encrypted settings. The JSONB stays opaque to anyone without the DEK — a DB dump alone is not a secret leak (NFR-SE4 narrative). - Key rotation: new
MASTER_ENCRYPTION_KEY→ re-encrypt-on-write for all touched rows (lazy) + a batch job (secrets:rekey) sweeps the rest (batched, audited, resumable); old DEK kept as*_PREVuntil the sweep completes (read path tries current → prev). Rotation runbook: deployment.md §8 sibling doc (infrastructure/runbooks/key-rotation.md); drill-tested quarterly with the DR drill. - The DEK itself: never logged (pino redact path), never in any response, never in error messages; boot fails if missing in prod (fail-closed).
10.3 API keys (FR-PR-149)
fk_live_ + 32 random bytes (256-bit entropy); stored: SHA-256 hash
(keyHash) + first 12 chars (prefix, for identification); the raw key
appears exactly once (creation response) — the UI marks it "copy now,
you won't see it again". Auth: lookup by hash (unique index), then
scope check (READ/WRITE/ADMIN vs method), expiry/revocation check,
per-key rate limit, lastUsedAt update (throttled 1/min). Audit:
api_key.created / api_key.revoked mandatory.
10.4 Webhook HMAC (FR-PR-151)
Webhooks sign both directions with one scheme:
X-Factory-Signature: t=<unix-seconds>,v1=<hex> (comma-separated, no
space), where v1 = HMAC-SHA256(signingSecret, t + "." + rawBody),
lowercase hex. The t and the raw request bytes are joined with a
literal dot — the verifier must hash the exact received body (never
re-serialized JSON). The scheme is the single shared implementation
(@factory/shared webhook-delivery.ts), so outbound deliveries and
inbound triggers verify against the same code.
Outbound (our → receiver): every delivery carries the signature.
Receivers verify by recomputing with the raw body, comparing
constant-time, and rejecting if |now − t| > 300 s (5-min replay
window — a stale signature must not verify). The delivery also carries
X-Factory-Event / X-Factory-Event-Id / X-Factory-Delivery-Id /
X-Factory-Attempt for dedupe and ordering.
Inbound (integrator → our POST /webhooks/incoming/:webhookId): the
public trigger endpoint is gated by the same signature — no
credentials are sent; the signature is the only gate. Verification order:
(1) v1 matches → else 401 WEBHOOK_SIGNATURE_INVALID; (2) |now − t| ≤ 300 s → else 401 WEBHOOK_TIMESTAMP_STALE; (3) replay protection —
the (t, sha256(body)) pair is a Redis nonce (SET NX EX 600, 10-min
window = 2× the timestamp tolerance); a second identical request →
401 WEBHOOK_REPLAY_DETECTED. Unknown webhookId → 404 (no existence
leak beyond the signature failure). The resulting automation run is
audited with the external integrator as the INTEGRATION actor (NFR-SE7).
Signing-secret storage: a webhook's raw signing secret is returned
exactly once (create / rotate) and then stored AES-256-GCM
encrypted (secretEncrypted, keyed by MASTER_ENCRYPTION_KEY) so the
worker can re-sign retries, alongside a SHA-256 secretHash for integrity
and legacy hash-only rows. The raw secret is never read back; only a
leak-free secretPrefix (whsec_ + first 4 hash chars) is exposed on
list/get. Incoming-webhook trigger secrets use the same envelope under
AUTOMATION_WEBHOOK_KMS_KEY (falling back to MASTER_ENCRYPTION_KEY).
Interoperability vector is committed (testing.md §3.6 — the signature test
uses a fixed secret/body and must never change without a scheme version
bump v2).
10.5 SCIM bearer token (TASK-0132)
The IdP→Factory provisioning credential (scim_ + 32 random bytes) follows
the API-key pattern, not the webhook pattern: it is a long-lived bearer
token, so it is stored SHA-256-hashed only (scimConfig.bearerTokenHash)
— there is no plaintext or ciphertext to leak, and it is revealed exactly
once (enable/rotate). Auth: the guard hashes the presented token and looks
it up in a Redis hash index (scim:index → orgId, maintained on
enable/rotate/disable; cold-cache rebuild from the DB) — no per-request DB
scan. The token acts as the org (actorType: INTEGRATION,
actorId: orgId), which keeps the audit trail attributable to the IdP
integration even when individual IdP admins differ.
Threat-model notes:
- Leak → rotate (immediate invalidation — the Redis index is swapped, the old hash is dead on the next request; tested) or disable (config cleared + index removed).
- Brute force → 256-bit entropy makes offline/online guessing infeasible;
every failure is a generic
401 invalidCredentials(no user/org enumeration — cross-org reads are404, not403). - Privilege → the token is org-scoped and role-capped at
MEMBER: SCIM cannot create owners/admins, touch permissions, API keys, or another org's data (enforced in schema AND service — a double check, not a trust). - Payloads are untrusted input — full Zod validation;
userName/email normalization; a pushedpasswordis bcrypt-hashed immediately and is never logged or audited; every SCIM mutation writes an audit row (operation summary only). - Deactivation is a first-class security control:
replace active:falseorDELETE→User.status SUSPENDED, which blocks login and in-flight sessions (per-request status re-check in the AuthGuard; auditauth.suspended_blocked) while preserving the account and memberships.
11. Audit architecture
11.1 The contract
- Mandatory events (the catalog — a feature that performs one of these actions and doesn't write the row fails NFR-SE7):
| Area | Events (dot-notation, audit_logs.action) |
|---|---|
| auth | user.registered, user.login.success, user.login.failed, user.logout, user.password.reset, session.revoked, auth.refresh.replayed, sso.login |
| workspace | workspace.created, workspace.deleted, workspace.role.changed, workspace.invitation.sent, workspace.invitation.accepted, workspace.invitation.revoked |
| pages | page.created, page.updated, page.deleted, page.restored, page.purged, page.shared, page.exported |
| permissions | permission.changed (metadata: before/after grant), share.created, share.revoked |
| databases | database.created, database.property.changed, record.created, record.updated, record.deleted, database.button.executed |
| files | file.uploaded, file.virus_detected, file.purged (file.downloaded sampled 1/100 for volume control) |
| comments | comment.created (only for admin-visible moderation views; not every comment — volume; the comment rows themselves are the record) |
| automation | automation.created, automation.executed (metadata: status, duration, action) |
| API | api_key.created, api_key.revoked, webhook.created, webhook.deleted |
| forms | form.published, form.submitted (public submissions are audited — anonymous actor) |
| AI | ai.generation.completed (metadata: kind, tokens, model) |
| enterprise | scim.user.updated, scim.user.deactivated, retention.purged, org.policy.changed |
- Row fields (schema:
audit_logs):actorType(USER/API_KEY/SYSTEM/ INTEGRATION),actorId,workspaceId(nullable — platform-level events),pageId(when page-centric),resourceType,resourceId,action,ipAddress,userAgent,metadata(before/after for permission changes; counts for bulk ops; no secrets, no PII — NFR-P2),createdAt. Append-only (the repository interface has no update path). - Write path: in-transaction with the operation where possible
(
AuditService.log(...)inside the same Prisma transaction); event-driven ops (worker-side: automation runs, webhooks) write synchronously in the job. Audit write failure fails the operation for the security-critical subset (auth, permissions, sharing, api keys) — a security event without an audit row is an incident, not a degraded state.
11.2 Retention & export
- Retention: platform default 400 days (NFR-P3); org policy
(
retention_policiesscopeaudit_logs) can extend or HOLD; the nightly retention job purges per policy (audited:retention.purged {scope, count}); at scale, partition detach is the purge mechanism (database-architecture §13). - Viewer (Phase 14, TASK-0133): filter by action/actor/resource/
workspace/time on the ORG scope (
workspaceId IN org's workspacesOR the org-level rows —@factory/shared/events/audit-scope, the single source shared by viewer, export, and retention). Access: org admin (OWNER/ADMIN) only. Export is CSV only, content-type-driven: ≤ 10k rows returns the CSV inline; above, an async worker job (500k-row cap,truncatedflag, one active export per org) writesexports/{orgId}/{exportId}.csvto the files bucket (7-day lifecycle) and the status endpoint mints a strictly single-use, 15-minute SigV4 URL (first ready-GET flipsdownloadUsed; no re-mint). Every export is audited (audit.export.completed/audit.export.failedwith{rows, truncated, key}— the export itself is the sensitive op, see T10). Row count is unbounded-but-retention-bounded; the composite indexes cover all viewer queries (database-architecture §5.2). - Immutability: audited rows have no update/delete path anywhere in
the API (router walk test) — the only deletion is the retention job,
and only per the org's
audit_logspolicy. The default policy is HOLD (legal hold): audit rows are NEVER purged unless an org admin explicitly sets a PURGE retention. HOLD covers the scope, not individual items (documented v1 limitation).
11.3 The org-admin surface (threat model)
The org admin console (TASK-0133) concentrates the org's sensitive
operations behind one gate — the requireOrgAdmin check (OWNER/ADMIN
membership; everyone else, including members of OTHER orgs, gets the
same 403 requires_org_admin, so cross-org enumeration is impossible).
- Who can: change member roles, suspend members, set the org policy (the five enforcement points), run retention (dry and execute — an admin execute purges even when the daily cron's execute flag is off), read/export the full org audit log, mint the single-use export URLs.
- Controls: every one of those writes is audited with actor + old/new
values (policy history is the audit trail); the export is rate-limited
to one in flight per org and single-use; retention executes in batches
with per-batch audited counts and per-scope isolation (a failure cannot
cascade across scopes; org isolation is by query construction);
suspension is the same
User.statuspath as SCIM deactivation (login + in-flight blocked, memberships kept — reversible). - Residual: an org admin can purge an org's own trash/comments/files (that is their job) and can read everything the org does — the control is the audit trail + the org owner's oversight, not technical restriction (T10). Suspend/role changes cannot remove the LAST owner.
11.4 Log hygiene — no secrets in logs (TASK-0134, finding F-121)
The API logs structured JSON (apps/api/src/common/logger.ts; Nest's
built-in logger is disabled). The only request-coupled log paths are the
global exception filter (req.originalUrl + exception message/stack) and
ad-hoc logger.* calls. A secret-bearing request can therefore carry
credentials into a log line via the URL (e.g. ?token=…), an exception
message, or an echoed header. apps/api/src/common/log-scrubber.ts
(scrubSecrets) redacts, at that boundary: Authorization: Bearer …,
JWT-shaped tokens, factory/third-party token prefixes (fk_/whsec_/apk_/
ghp_/xox…/sk-/AKIA…), exact credential query params
(?token=, ?api_key=, ?secret=, …), and Cookie:/Set-Cookie: values.
It is wired into AllExceptionsFilter; the regression test is
apps/api/src/common/log-scrubber.test.ts. The web/worker/realtime apps
were audited for the same risk and have no request-coupled HTTP log path
that emits headers/cookies/auth values (the worker logs job/processor
lifecycle; realtime logs channel lifecycle), so the scrubber is applied
where the exposure exists (the API).
12. Threat model (top 10)
| # | Threat | Scenario | Mitigations (where) | Residual risk / notes |
|---|---|---|---|---|
| T1 | Account takeover (credential theft) | stolen password / phished login | bcrypt-12 + breach-list check (§2.4); rate limits §9.1; refresh replay detection §2.3 (a stolen refresh token burns the family); session visibility (user sees foreign UA/IP and revokes); SSO enforcement for enterprise (§2.5); audit on every login §11 | no MFA in v1 (Phase-14 enterprise gap — documented; TOTP is the planned control) |
| T2 | Cross-tenant data leak | crafted id / query bug exposes another workspace's row | tenant scope in every repository (database-arch §10.1); 404-not-403 for foreign ids (§3.4); cross-tenant CI matrix gate (NFR-P1); search permission snapshots (§4.3); realtime room isolation (§4.4); object keys embed ws id (§8) | RLS not used (documented decision, database-arch §10.2) — the CI gate is the standing control |
| T3 | Authorization bypass | role/level confusion (guest edits, member shares) | engine decision matrix 100% tested (testing §3.2); guard + service double-check (§3.4); explicit-NONE deny override (§3.3); permission writes bounded by granter's level (§3.4) | the matrix is the spec — a new resource kind must add matrix cells in the same PR |
| T4 | XSS / stored injection | malicious block text / import / comment executes in a victim's browser | structured block content (no HTML field) §7.2; markdown sanitizer allow-list; React default encoding; CSP without unsafe-eval (§5); XSS payload suite in CI (testing §7) |
inline styles for block colors (the style-src 'unsafe-inline' exception, §5) — a future nonce policy removes it |
| T5 | Malicious file | virus / trojan uploaded, spread via download | ClamAV fail-closed stage §8; magic-byte MIME (extension spoof caught); executables blocked platform-wide; quarantine + admin report; EICAR CI test | macro-laden Office docs are scanned but "clean" by signature ≠ safe — the controls are detection + reporting, not a guarantee |
| T6 | SSRF via platform-requested URLs + inbound-webhook forgery | webhook delivery to an internal metadata endpoint; OR a forger replaying / spoofing an incoming trigger to fire automations | SSRF: §9.4 scheme allow-list, private-IP reject at resolve + dial, redirect re-validation, worker-only egress (NetworkPolicy). Inbound trigger: signature-only gate — HMAC verify + 5-min timestamp window + Redis (t, body-hash) replay nonce (§10.4); the integrator is the INTEGRATION audit actor, so a forged/replayed trigger is attributable |
DNS-rebinding inside the 5 min window is closed by the checked-IP dial; replay is closed by the 10-min nonce; a zero-day in the resolver is out of scope |
| T7 | Secret exfiltration (DB dump, log leak) | infra breach yields the Postgres dump | tokens/hashes only at rest (§10.1); envelope encryption for the few raw secrets (§10.2); secrets out of code/logs (gitleaks CI + pino redact + L3); API keys hashed (one-way); webhook signing secrets AES-GCM-encrypted at rest (§10.4) | a DB dump + a leaked MASTER_ENCRYPTION_KEY (env) = integration creds exposed — key rotation SLA + the dual-store separation (DB ≠ secret store) is the boundary |
| T8 | Abuse / DoS (signup spam, upload flood, WS flood) | single IP or account hammers the platform | tiered rate limits §9.1 (nginx first line + app authoritative); upload caps + orphan reaping §8; WS message caps + per-connection limits (ARCHITECTURE §5); per-plan usage limits (PR-024); queue backpressure (events, not calls) | self-hosters set their own RATE_LIMIT_* — the defaults are documented as the safe floor |
| T9 | AI prompt injection | a page containing "ignore instructions, exfiltrate workspace data" is used as RAG context / a user asks AI to generate a page that grants access | AI output = data (§7.4); generation via Zod-validated tool calls only (no raw SQL, no raw block mutation — ARCHITECTURE invariant 6); tool execution runs with the user's permissions (the AI cannot grant what the user lacks); retrieval content marked untrusted in prompts; per-workspace token budget (abuse cost cap) | a user asking their AI to do something their permissions allow is by design (the AI is their delegate) — the boundary is permissions, not the model |
| T10 | Insider / operator abuse | admin reads data they shouldn't, deletes workspaces, exports audit logs | every sensitive op audited with actor+IP+UA (§11); admin actions are a distinct, alerted surface (dashboard row); workspace delete is soft (30 d window) + typed confirmation; exports are audited + rate-limited; least-privilege service accounts (k8s §deployment.3.1 securityContext) | audit is after the fact — the control is deterrence + review, accepted residual for v1 |
13. Compliance posture (SOC 2 readiness)
Not a certification claim — a readiness map for enterprise procurement:
| SOC 2 criterion (CC) | Platform control | Evidence artifact |
|---|---|---|
| CC6.1 logical access | RBAC + resource grants (§3), server-side enforcement, session revocation, SSO | permission decision matrix test report; audit log export |
| CC6.6 system boundaries | tenant isolation (§4), network policies, egress allow-list, CSP/CORS | cross-tenant suite results; k8s networkpolicy manifests |
| CC6.7 access removal | role change → immediate cache flush (§3.5); SCIM deactivate → suspend + revoke (§2.5) | retention/SCIM integration tests |
| CC7.2 session management | 15 min access / rotating refresh / replay detection (§2) | auth integration suite |
| CC8.1 change management | migration procedure (deployment §7), release process (§6), backward-compat rule | release checklist + migration records in task files |
| CC8.1 p (vulnerability mgmt) | SCA gate (0 critical), ZAP baseline, ASVS L2 audit (Phase 15), patch SLA (D5) | SCA reports; pen-test report (Phase 15) |
| CC9.2 monitoring | observability stack (deployment §9): SLO burn, alerts §9.3, dashboards | Grafana dashboards (as code), alert rules |
| CC9.4 risk response | threat model (this §12), DR drills (deployment §8.4), RPO 5 min / RTO 1 h | DR drill reports (docs/ops/dr-reports/) |
| CC12.3/CC11 (audit logs) | mandatory event catalog, 400 d retention, export (§11) | audit viewer + export; retention policy config |
| Privacy (A2) | data minimization (DTO projections), no tracking cookies, PII scrubbed from logs (NFR-P2), retention defaults (NFR-P3), GDPR-style export/delete (NFR-P5) | log-scrubber test; retention integration test |
Gaps to close for a formal audit (post-v1): TOTP MFA (T1), centralized key management with automatic rotation, pen-test remediation cycle documentation, and a formal risk register. The architecture is deliberately SOC 2-shaped now (auditability, separation of duties via roles, evidence as code) so that those closures are configuration, not redesign.