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), emitinitial_cabinet_admin_provisioned(idempotent_skip=true)and exit. - Create a Keycloak Organization in the
papillonrealm withid = tenantIdand 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
usersprojection 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_invitationsrow: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_provisioned—tenant_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
INCERTAINobligation is engaged by provisioning.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md(Spring Modulith@ApplicationModuleListener, events past-tense noEventsuffix).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=trueemitted. - 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)