Aller au contenu

Story AUTH-003: Tenant onboarding — Keycloak Organization + bootstrap CABINET_ADMIN

Module: identity Slice: S6d (arch §11) — V0 envelope AUTH-001 (arch §0.1 / §0.3 Keycloak Organizations) Side: [BACKEND] Version target: V0 — v0.0.0 Priority: 3 Depends on: AUTH-001; consumes TENANT TenantOnboarded; calls NOTIF dispatch Can develop concurrently with: AUTH-002, AUTH-005 Merge order: After AUTH-001 Estimated complexity: M PRD User Stories: AUTH-ADM-04 (bootstrap initial CABINET_ADMIN), AUTH-PA-01 reinvite (cabinet-admin reinvite endpoint) Wireframe: N/A — backend only


Objective

When the platform onboards a new tenant (cabinet), AUTH must automatically create that tenant's Keycloak Organization and its first CABINET_ADMIN user, then send a 7-day activation invitation. This is how a brand-new cabinet gets its first login. The handler must be idempotent because the event can be re-delivered, and there is a fallback endpoint so a platform admin can re-send the invitation if the first attempt failed.


Backend Scope

Entities

Writes users (the CABINET_ADMIN projection row) and user_invitations (the activation invitation). No new tables.

Service Layer

  • @ApplicationModuleListener void on(TenantOnboarded e) — consumes the event asynchronously (AFTER_COMMIT delivery, arch §1.2). The event carries {tenantId, countryCode, groupId?, primaryContact, defaultAgencyId}.
  • Handler steps (R-ADM-007, arch §6.6):
  • Idempotency check first: SELECT users WHERE tenant_id=? AND role='CABINET_ADMIN'. If a row exists (event re-delivery), emit initial_cabinet_admin_provisioned(idempotent_skip=true) and exit.
  • Create a Keycloak Organization in the papillon realm with id = tenantId and attributes {country_code, group_id} (idempotent if it already exists). This is the per-tenant Organization mechanism locked in arch §0.3 / D-11.
  • Create a Keycloak user as a member of that Organization, login = primaryContact.email, with a randomized one-time password.
  • Assign the realm role CABINET_ADMIN.
  • Insert the users projection row: tenant_id, keycloak_user_id, role=CABINET_ADMIN, agency_scope=NULL (tenant-wide per R-OP-022), mfa_phone=primaryContact.phone, state=PENDING_ACTIVATION.
  • Insert a user_invitations row: channel=email, token_hash (SHA-256 of the invitation token), expires_at = issued_at + 7 days, state=PENDING.
  • Call NOTIF to dispatch the activation invitation on the email channel (template operator.invitation.link) — email only for the bootstrap in V1.
  • Emit initial_cabinet_admin_provisioned(idempotent_skip=false, targetUserId, invitationId).
  • If NOTIF dispatch fails: leave the user in PENDING_ACTIVATION, audit the failure, and rely on the reinvite endpoint below. No automatic retry beyond NOTIF's own retries (arch §1.2, brief gap window documented in the OPS runbook).
  • AuthProvisioningService.retriggerCabinetAdminInvitation(tenantId) exposed to TENANT (arch §10.3) — re-issues a fresh invitation when the first dispatch failed.

API Endpoints

Method Path Request Response Auth
POST /api/control/auth/tenants/{tenantId}/cabinet-admin/reinvite 200 {invitationId} Bearer PLATFORM_ADMIN

The reinvite endpoint requires step-up MFA in V1 (TENANT_PROVISION_RETRY); in v0.0.0 there is no MFA, so it is a plain PLATFORM_ADMIN-guarded call. Keep the step-up hook point for V1.

Validation Rules

  • The handler must be idempotent on re-delivery (matched by tenant_id + role / tenant_id+username).
  • The Keycloak Organization id equals tenantId.
  • Invitation TTL is exactly 7 days.

Multi-Tenant Considerations

One Keycloak Organization per tenant (arch §0.3). The tenant_id is carried both by the JWT custom mapper and by Organization membership. The users row lives on the platform datasource. country_code from the event is stored as an Organization attribute for later per-country routing. Works identically for shared-DB and BYO-DB tenants (AUTH always uses the platform DB).

Audit & Logging

  • initial_cabinet_admin_provisionedtenant_id, target_user_id, idempotent_skip(bool), invitation_id.

Frontend Scope

N/A — backend only (the activation screen the invited admin then uses is AUTH-004; the reinvite button lives in the TENANT detail screen, owned by TENANT).

UI States (ALL REQUIRED)

N/A — backend only.


Acceptance Criteria

AC-001: TenantOnboarded provisions the first CABINET_ADMIN
Given TENANT publishes TenantOnboarded{tenantId, countryCode, primaryContact, defaultAgencyId}
When AUTH receives the event
Then a Keycloak Organization with id=tenantId and attributes {country_code, group_id} is created
And a Keycloak user member with role CABINET_ADMIN is created with login = primaryContact.email
And a users projection row is inserted with role=CABINET_ADMIN, agency_scope=NULL, state=PENDING_ACTIVATION
And a 7-day email activation invitation is dispatched via NOTIF
And initial_cabinet_admin_provisioned(idempotent_skip=false) is audit-logged
AC-002: Re-delivered event is idempotent
Given a CABINET_ADMIN already exists for the tenant
When TenantOnboarded is re-delivered
Then no duplicate Keycloak user or users row is created
And initial_cabinet_admin_provisioned(idempotent_skip=true) is audit-logged
AC-003: Failed invitation can be re-triggered
Given NOTIF dispatch failed and the CABINET_ADMIN is still PENDING_ACTIVATION
When a PLATFORM_ADMIN calls POST /api/control/auth/tenants/{tenantId}/cabinet-admin/reinvite
Then a fresh 7-day invitation is issued and dispatched
And the failure and the re-trigger are audit-logged

Compliance Rules

  • RBAC + traçabilité = VÉRIFIÉ (CIMA art. 41 + ARTCI 2023-0877). Provisioning event must be audit-logged.
  • No INCERTAIN obligation is engaged by provisioning.

Standards & Conventions

  • docs/standards/java-spring-guidelines.md (Spring Modulith @ApplicationModuleListener, events past-tense no Event suffix).
  • docs/standards/api-guidelines.md.
  • docs/standards/multi-tenant-model.md (Keycloak Organizations, realm strategy D-11).

Testing Requirements

Unit Tests

  • Idempotency: second processing detects the existing user and skips creation.
  • Invitation TTL = 7 days; token stored as SHA-256 hash, never cleartext.

Integration Tests

  • Testcontainers Keycloak: TenantOnboarded → Organization created (id=tenantId) + member user + role + projection row + invitation row + NOTIF mock called once.
  • Re-delivery → exactly one user, idempotent_skip=true emitted.
  • Reinvite endpoint issues a new invitation; rejected for non-PLATFORM_ADMIN (403).

What QA Will Validate

  • A new tenant ends up with a PENDING_ACTIVATION CABINET_ADMIN and a delivered email invitation; re-delivery does not duplicate; reinvite works after a simulated NOTIF failure.

Out of Scope

  • Self-service operator invitations / full user management (invite/role/deactivate beyond the bootstrap admin) — V1 (AUTH-ADM-01/02, AUTH-OP-07). NOTE: the V0 sub-version doc places "Cabinet Admin créer et affecter des utilisateurs" at v0.0.2; this conflicts with the approved arch/design which tag user management as V1 — see the index Open Questions.
  • The activation screen itself (AUTH-004).
  • IdP brokering on the Organization (AUTH-009, v0.0.5).
  • Tenant suspend/reactivate/soft-delete cascades — V1.

Definition of Done

  • [ ] TenantOnboarded creates Organization + CABINET_ADMIN + invitation
  • [ ] Handler is idempotent on re-delivery
  • [ ] Reinvite endpoint works and is PLATFORM_ADMIN-guarded
  • [ ] Invitation token stored as SHA-256 hash; 7-day TTL
  • [ ] Audit entry initial_cabinet_admin_provisioned with idempotent_skip flag
  • [ ] Unit + integration tests written and passing
  • [ ] Tenant isolation verified (platform DB; Organization id=tenantId)
  • [ ] No silent failures on NOTIF error (failure audited; reinvite path available)