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_IDset 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
| Source | Used by | Why |
|---|---|---|
config('app.tenant_id') | All controllers, IdentityEventHandler, CheckPermission, Sms* controllers | Source of truth; per-deployment env-driven |
auth_user['tenant_id'] (JWT) | Backwards-compat for older controller code | Always 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-check | Array 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-Idrequest 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 —
$fillableminustenant_id,scopeForTenantquery 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: stringremoved 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
_idhappens to end withtenantbut isn't a multi-tenancy discriminator (e.g.parent_tenant_idontenant_usersif 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
| Key | TTL | Tags | Invalidation |
|---|---|---|---|
policies:{tenantId}:{userId}:{sha256(token)[:12]} | 1h | crm.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
- Spin a new postgres database + the dependent infra (Redis, RabbitMQ).
- Provision a new CRM deployment with
TENANT_ID,TENANT_NAME,SERVICE_NAME=crmset (see environment-reference.md). - Run
php artisan migrate --forceagainst the fresh database. - Configure the identity backend with the tenant + policy mappings.
- 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.