CRM security model
CRM's security posture rests on five layers, each with its own audit finding category in the F0 sweep. Anything that bypasses one layer should still hit the next.
1. Token validation
VerifyOAuthToken is the first gate. It:
- Reads the
Authorization: Bearer ...header (no cookies — CRM is API-only). - Cache lookup:
identity.user:{sha256(token)}for 60s. - On cache miss: calls identity at
/api/userand caches the result. - Distinguishes between rejected token (401) and identity unreachable (503) so the SPA's 401 logout handler doesn't fire on a temporary outage —
IdentityService::wasLastFailureTransient()carries the classification.
2. Tenant binding
The CRM service is deployment-per-tenant:
TENANT_ID(env) is the source of truth for the tenant binding.auth_user['tenants'](JWT claim, post F-future-6) is cross-checked atCheckPermission. Tokens whosetenants[].iddoes not contain the bound tenant are rejected with 403tenant_not_a_member.- Legacy single-
tenant_idtokens have a hard cutoff at 2026-06-15 (30 days after F-future-6 rollout + token TTL); after that they're rejected with 401token_shape_legacy.
There is no tenant_id discriminator column on any CRM table —
dropped on 2026-05-12. Cross-tenant data access is an infrastructure
problem (a different deployment's DB), not an application bug.
What is explicitly NOT a tenant context source:
| Source | Reason |
|---|---|
X-Tenant-Id HTTP header | Would let any system-token caller pick which tenant's data to touch — CRM audit backend KRITIKUS #1 closed this on SmsSystemController. |
| URL path | No /tenants/{id}/... routes; URLs assume the deployment. |
| Cookies | API-only service. |
3. Permission gate
See permissions.md for the FQN syntax and the full
permission catalogue. CheckPermission middleware runs after
VerifyOAuthToken and:
- System admin bypass —
is_system_admin: trueshort-circuits the gate. - Tenant membership check — see above.
- Service-account scope gate — for tokens with
scopes[], the requested permission must match at least one scope. Matching is segment-by-segment glob (tenant.*.crm.tasks.*matchestenant.acme.crm.tasks.view; cross-namespace doesn't match). - Policy evaluation — fetched from identity, cached under
policies:{tenantId}:{userId}:{sha256(token)[:12]}with 1h TTL and tag-based flush (crm.policies,tenant:{tid}).
403 responses include structured payloads:
{
"message": "Insufficient permissions.",
"error": "permission_denied",
"required_permission": "tenant.acme.crm.tasks.update",
"resource_type": "tasks",
"resource_id": "abc-123",
"user_id": "42"
}
4. Resource-level guards
Some endpoints need policy beyond "do you have the verb permission?". These run inside controllers:
- Project membership —
TaskController::ensureProjectMember()for task CRUD. - Folder ACL —
DocumentController::ensureFolderAccess($folder, $userId, $mode)uses an explicit-deny model: a missing folder always 403s, never falls through. Source + target folder both checked on document move (KRITIKUS #4, #8). - Channel membership —
ChatChannel::query()->forUser($userId)->find(...)404s when the user is not a participant.
5. Input + output hardening
| Surface | Hardening | Audit finding |
|---|---|---|
| File extensions on upload | Whitelisted to [a-z0-9]{1,8}, fallback bin — chat + media | MAGAS #49, #57 |
| MIME type on media upload | mimetypes:image/jpeg,png,gif,webp,avif,application/pdf (magic-byte check via finfo) | MAGAS #57 |
| File serve path | Prefix-pinned to tenants/{tid}/chat/{cid}/; .. rejected | MAGAS #49 |
| System token compare | hash_equals constant-time | cross-repo #40 |
| Bot token compare | Per-(IP, token-prefix) RateLimiter (30/min) | MAGAS #47 |
| Markdown bodies (KbArticle, comments) | sanitizeMarkdown() strips <script> / <iframe> / etc., on*=, javascript: URLs | MAGAS #68 |
Polymorphic related_type | Allow-list on the model (CommunicationLog::ALLOWED_RELATED_TYPES) | MAGAS #56 |
| Sort fields | Whitelisted per endpoint (TimeEntry export, etc.) | KRITIKUS #7 |
| Pagination per_page | Capped at 100 everywhere | MAGAS #13, #60 |
| Tag / label sync | `distinct | exists:table,id` on pivot ids |
| Polymorphic morph map | Limited to the 4 CRM entity classes that actually link from CommunicationLog | MAGAS #56 |
Audit trail
Compliance events write to ActivityLog rows. Currently instrumented:
- Blog post publish / unpublish
- Page publish / unpublish
The properties blob carries actor_user_id and the optional
impersonator_id (cross-repo #36) so a forensic review can answer
"who actually did this on whose behalf".
Rate limiting
| Surface | Bucket | Limit |
|---|---|---|
| Bot token validation | sha1(IP + token-prefix[:8]) | 30 failed attempts / 60s, then 429 |
| OAuth token validation | (none on CRM side — identity is the bottleneck) | — |
Identity event consumer (crm:consume-identity-events) is the only
process that mutates state without going through CheckPermission.
Trust comes from the broker — the per-tenant queue is bound with a
tenant-filtered routing pattern at the identity side.
Cache invalidation patterns
- Policy cache — flushed by
IdentityEventHandler::onPolicyUpdatedon the matching tenant tag (cross-repo #30 / #39). - User payload cache — TTL-only (60s); revoked tokens get re-validated within a minute.
- CRM resource caches — none currently; reads always go to Postgres.
What CRM trusts
- Identity-issued JWTs (validated by calling identity).
- Identity-emitted RabbitMQ events on the per-tenant queue.
- The
TENANT_IDenv (deploy-time configuration). - The system token from
services.identity.system_api_token(constant-time compare).
What CRM does NOT trust:
- Any HTTP header that claims a tenant id.
- File names / extensions from upload payloads.
- Markdown bodies before passing through
sanitizeMarkdown(). - The
related_typemorph string before checking the allow-list. getClientOriginalExtension()onUploadedFile.
Related findings (closed)
Full lineage in tenant/tenant-backend-crm/AUDIT_FINDINGS.md. The
KRITIKUS slice was closed entirely in batch 1; MAGAS slice is ~80%
closed with the remaining items in F1+ scope.