Skip to main content

Multi-tenant model

Vecton's CRM uses deployment-per-tenant isolation. Each CRM instance:

  • Runs in its own pod/process group
  • Connects to its own PostgreSQL database
  • Has TENANT_ID set in its env (never derived from request data)
  • Subscribes to a per-tenant RabbitMQ queue
  • Caches under per-tenant Redis namespaces (when Redis is the driver)

There is no tenant_id discriminator column on CRM tables. The column was dropped on 2026-05-12 — under the per-deployment model it was redundant (always equal to config('app.tenant_id')) and just amounted to defense-in-depth noise that controllers had to remember to write into every query.

Where tenant context comes from

SourceUsed byWhy
config('app.tenant_id')All controllers, IdentityEventHandler, CheckPermission, Sms* controllersSource of truth; per-deployment env-driven
auth_user['tenant_id'] (JWT)Backwards-compat for older controller codeAlways equals the config value; will be removed once the legacy tenant_id claim path is retired (post 2026-06-15 cutoff, see intro)
auth_user['tenants'] (JWT, post F-future-6)CheckPermission membership cross-checkArray of {id, name, role} — CRM checks tenant_id ∈ tenants[].id and 403s tenant_not_a_member otherwise

What's explicitly never a tenant source:

  • X-Tenant-Id request header — would let any system-token caller pick which tenant's data they touch. CRM audit backend KRITIKUS #1 closed this earlier; SmsSystemController is the canonical example.
  • The URL path — there are no /tenants/{id}/... URLs in CRM; the routes assume the deployment.
  • Cookies — CRM is API-only and doesn't read cookies.

Cross-tenant traffic stops at the broker

Identity events are routed with a tenant-filtered binding so events for foreign tenants never reach this deployment's queue. IdentityEventHandler::onTenantMemberRemoved defensively checks $payload['tenant_id'] === config('app.tenant_id') anyway, in case the broker config drifts. Both layers must agree before state mutates.

Where the tenant_id drop happened

The 2026-05-12 architectural migration removed tenant_id from 46 CRM tables:

  • 2026_05_12_000001_drop_tenant_id_from_crm_tables.php — drops the column on every table
  • 21 Eloquent models — $fillable minus tenant_id, scopeForTenant query scopes removed
  • 15 API Resources — the 'tenant_id' field dropped from the JSON payload
  • 47 controllers + services + jobs — 175 where('tenant_id', $tid) chain links removed
  • 15 frontend stores — tenant_id: string removed from the TypeScript interface

What survived:

  • $auth_user['tenant_id'] JWT claim (auth-layer; never queried)
  • config('app.tenant_id') env binding (deployment level)
  • Any column whose _id happens to end with tenant but isn't a multi-tenancy discriminator (e.g. parent_tenant_id on tenant_users if it existed — it doesn't currently)

Service-account scope gate

Service accounts present a token with a scopes[] claim, e.g. ["tenant.acme.crm.tasks.*", "tenant.acme.crm.contacts.view"]. CheckPermission validates the scope segment by segment with glob support (tenant.*.crm.*.view matches with single-segment * wildcards). Implementation: App\Http\Middleware\CheckPermission::scopeAllowsPermission and segmentsMatch.

The wildcard is one segment unless it's the trailing *, which becomes a prefix wildcard covering any deeper segments. The legacy "tenant-wide admin" of tenant.acme.crm.* therefore covers tenant.acme.crm.tasks.delete but does NOT cover tenant.acme.identity.* (different prefix).

Policy cache

KeyTTLTagsInvalidation
policies:{tenantId}:{userId}:{sha256(token)[:12]}1hcrm.policies, tenant:{tid}identity.policy.updated event flushes both tags

The sha256(token)[:12] suffix means token rotation lands on a fresh slot — the old slot orphans and TTL-clears. Without it, a refresh token swap would inherit stale policy from the prior token (CRM audit cross-repo #39).

Adding a new tenant

  1. Spin a new postgres database + the dependent infra (Redis, RabbitMQ).
  2. Provision a new CRM deployment with TENANT_ID, TENANT_NAME, SERVICE_NAME=crm set (see environment-reference.md).
  3. Run php artisan migrate --force against the fresh database.
  4. Configure the identity backend with the tenant + policy mappings.
  5. Start the identity event consumer worker (crm:consume-identity-events).

No tenants table to seed; no per-tenant subquery to add; no shared-schema row-level security. The per-deployment model trades container density for operational simplicity — each tenant is just "another deployment".

If the new tenant should start with content from a template tenant, the provisioner calls POST /api/system/clone-from after step 3 — see tenant-clone.md. The clone helper is the only code path in CRM that legitimately spans tenants, and it does so through the system-token boundary, not the user-token middleware chain.

Audit-trail notes

The dropping of tenant_id means that historical rows written before the migration that had a tenant_id column will read fine (the column is gone, all rows are now scoped by deployment). Cross-tenant joins or exports are no longer expressible at the database level, which is the intended outcome — exfiltration of foreign tenant data would have to reach a different deployment's database, which is an infrastructure boundary problem, not an application bug.