Identity ↔ CRM event contract
The CRM consumes cross-cluster events that the identity service
publishes onto the RabbitMQ vecton.events topic exchange (via the
identity-side outbox publisher). The CRM is only a consumer on the
identity event stream — it does not emit any identity-direction
events. CRM-side events (chat.*, blog_post.*, etc.) are published
to the webhook + ws-proxy fan-outs and are documented separately in
webhook-events.md.
Transport
| Exchange | vecton.events (topic) |
| Per-tenant queue | crm.identity-events.{tenant_id} |
| Worker command | php artisan crm:consume-identity-events |
| Routing key shape | identity.{aggregate}.{event} |
| Envelope | { id, type, service, occurred_at, payload } (see identity's EventPublisher::envelope()) |
The queue is per tenant because each CRM deployment is bound to one tenant — there's no fan-out to siblings. Identity binds each queue with a tenant-filtered routing pattern so a tenant-foreign event is dropped at the broker, not at the handler.
All handlers are idempotent. RabbitMQ retries on handler exception are accepted and the broker will redeliver until ack'd.
Consumed events
| Type | Handler | Side effect |
|---|---|---|
identity.user.scheduled_for_deletion | IdentityEventHandler::onUserScheduledForDeletion | TaskUser pivot rows for user_id dropped; tasks where assigned_to == user_id rehomed to NULL; TenantUser row → is_active=false |
identity.user.updated | onUserUpdated | TenantUser row's name/email/locale/timezone mirrored from payload (avoid /api/user re-fetch on every page render) |
identity.tenant.member_added | onTenantMemberAdded | Log only — TenantUser rows are lazy-created by IdentityService::getUserFromToken on first authenticated call |
identity.tenant.member_removed | onTenantMemberRemoved | When the payload's tenant_id == config('app.tenant_id'): TenantUser row → is_active=false. Foreign tenants: noop. |
identity.policy.updated | onPolicyUpdated | Cache flush — Cache::tags(['crm.policies', "tenant:{$tenantId}"])->flush(). Falls back to TTL expiry (1h) on file/database cache drivers without tagging. |
TenantUser uses id (string) as primary key — same value as the
identity user id. The is_active=false row stays in place as a
soft-removal tombstone so historical TaskHistory + ActivityLog
references still resolve via TenantUser::find($id). The active()
scope filters it out of typeaheads and pickers.
Envelope contract
Every event arrives with this shape:
{
"id": "01J6...",
"type": "identity.user.updated",
"service": "identity",
"occurred_at": "2026-05-12T11:45:30Z",
"payload": {
"user_id": "123",
"tenant_id": "abc-uuid",
"name": "...",
"email": "...",
"locale": "hu",
"timezone": "Europe/Budapest"
}
}
The dispatch happens in IdentityEventHandler::dispatch() with a
match ($type) { ... } on the type string. Unknown types log a
Log::info line and are ack'd (don't NACK on unknown — would loop
forever as identity adds new event types).
Idempotency expectations
| Handler | How |
|---|---|
onUserScheduledForDeletion | Wrapped in DB::transaction; deletes are filtered by user_id so a re-delivery is a no-op |
onUserUpdated | update($fields) is idempotent — same values land in the same column |
onTenantMemberAdded | Log-only; safe to re-deliver |
onTenantMemberRemoved | update(['is_active' => false]) is idempotent |
onPolicyUpdated | Cache::flush() is idempotent |
Identity-side audit pattern (impersonation)
When an admin impersonates a user, the identity backend mints a token
that carries impersonator_id alongside the regular id. CRM
controllers reach for BaseController::auditActor($authUser) which
returns ['actor_user_id' => ..., 'impersonator_id' => ...?] for
inclusion in ActivityLog.properties. The acting user remains the
impersonated one (so ownership checks behave correctly), but the
forensic record carries both ids. CRM audit cross-repo #36.
Future events (not yet consumed)
The following are documented in
documentations/docs/identity/events.md but the CRM has not yet
subscribed. Open IdentityEventHandler::dispatch and add a handler
case to enable:
identity.tenant.renamed— would let CRM update local cached tenant display names without an /api/user fetchidentity.api_key.revoked— would let CRM proactively flush service-account-policy caches keyed by scopes
Operational notes
- Idempotency does NOT guarantee ordering. If
user.updatedarrives beforeuser.scheduled_for_deletion(unlikely but possible under broker retry storms), the deletion still wins because the cleanup handler doesn't read the user payload — it just nukes joins by id. - Dead-letter queue: NOT configured for this consumer in the current scaffold. Add a DLX binding when production traffic warrants it.
- See
tenant/tenant-backend-crm/app/Services/IdentityEventHandler.phpandapp/Console/Commands/ConsumeIdentityEvents.phpfor the live code.