Skip to main content

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

Exchangevecton.events (topic)
Per-tenant queuecrm.identity-events.{tenant_id}
Worker commandphp artisan crm:consume-identity-events
Routing key shapeidentity.{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

TypeHandlerSide effect
identity.user.scheduled_for_deletionIdentityEventHandler::onUserScheduledForDeletionTaskUser pivot rows for user_id dropped; tasks where assigned_to == user_id rehomed to NULL; TenantUser row → is_active=false
identity.user.updatedonUserUpdatedTenantUser row's name/email/locale/timezone mirrored from payload (avoid /api/user re-fetch on every page render)
identity.tenant.member_addedonTenantMemberAddedLog only — TenantUser rows are lazy-created by IdentityService::getUserFromToken on first authenticated call
identity.tenant.member_removedonTenantMemberRemovedWhen the payload's tenant_id == config('app.tenant_id'): TenantUser row → is_active=false. Foreign tenants: noop.
identity.policy.updatedonPolicyUpdatedCache 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

HandlerHow
onUserScheduledForDeletionWrapped in DB::transaction; deletes are filtered by user_id so a re-delivery is a no-op
onUserUpdatedupdate($fields) is idempotent — same values land in the same column
onTenantMemberAddedLog-only; safe to re-deliver
onTenantMemberRemovedupdate(['is_active' => false]) is idempotent
onPolicyUpdatedCache::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 fetch
  • identity.api_key.revoked — would let CRM proactively flush service-account-policy caches keyed by scopes

Operational notes

  • Idempotency does NOT guarantee ordering. If user.updated arrives before user.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.php and app/Console/Commands/ConsumeIdentityEvents.php for the live code.