Skip to main content

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:

  1. Reads the Authorization: Bearer ... header (no cookies — CRM is API-only).
  2. Cache lookup: identity.user:{sha256(token)} for 60s.
  3. On cache miss: calls identity at /api/user and caches the result.
  4. 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 at CheckPermission. Tokens whose tenants[].id does not contain the bound tenant are rejected with 403 tenant_not_a_member.
  • Legacy single-tenant_id tokens have a hard cutoff at 2026-06-15 (30 days after F-future-6 rollout + token TTL); after that they're rejected with 401 token_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:

SourceReason
X-Tenant-Id HTTP headerWould let any system-token caller pick which tenant's data to touch — CRM audit backend KRITIKUS #1 closed this on SmsSystemController.
URL pathNo /tenants/{id}/... routes; URLs assume the deployment.
CookiesAPI-only service.

3. Permission gate

See permissions.md for the FQN syntax and the full permission catalogue. CheckPermission middleware runs after VerifyOAuthToken and:

  1. System admin bypassis_system_admin: true short-circuits the gate.
  2. Tenant membership check — see above.
  3. 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.* matches tenant.acme.crm.tasks.view; cross-namespace doesn't match).
  4. 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 membershipTaskController::ensureProjectMember() for task CRUD.
  • Folder ACLDocumentController::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 membershipChatChannel::query()->forUser($userId)->find(...) 404s when the user is not a participant.

5. Input + output hardening

SurfaceHardeningAudit finding
File extensions on uploadWhitelisted to [a-z0-9]{1,8}, fallback bin — chat + mediaMAGAS #49, #57
MIME type on media uploadmimetypes:image/jpeg,png,gif,webp,avif,application/pdf (magic-byte check via finfo)MAGAS #57
File serve pathPrefix-pinned to tenants/{tid}/chat/{cid}/; .. rejectedMAGAS #49
System token comparehash_equals constant-timecross-repo #40
Bot token comparePer-(IP, token-prefix) RateLimiter (30/min)MAGAS #47
Markdown bodies (KbArticle, comments)sanitizeMarkdown() strips <script> / <iframe> / etc., on*=, javascript: URLsMAGAS #68
Polymorphic related_typeAllow-list on the model (CommunicationLog::ALLOWED_RELATED_TYPES)MAGAS #56
Sort fieldsWhitelisted per endpoint (TimeEntry export, etc.)KRITIKUS #7
Pagination per_pageCapped at 100 everywhereMAGAS #13, #60
Tag / label sync`distinctexists:table,id` on pivot ids
Polymorphic morph mapLimited to the 4 CRM entity classes that actually link from CommunicationLogMAGAS #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

SurfaceBucketLimit
Bot token validationsha1(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::onPolicyUpdated on 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_ID env (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_type morph string before checking the allow-list.
  • getClientOriginalExtension() on UploadedFile.

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.