@@ -37,11 +37,21 @@ var (
3737 errEmailAlreadyRegistered = errors .New ("email already registered in this tenant" )
3838)
3939
40+ // CreateTenantResult holds the outcome of a tenant creation request.
41+ type CreateTenantResult struct {
42+ TenantID string
43+ // ProvisioningPending is true when the tenant requires async schema provisioning
44+ // before it becomes active. When true, identity creation must be deferred to
45+ // a post-provisioning hook and credentials stored in tenant metadata.
46+ ProvisioningPending bool
47+ }
48+
4049// TenantCreator abstracts tenant creation for the registration handler.
4150type TenantCreator interface {
4251 // CreateTenant creates a new tenant with the given ID, slug, and display name.
43- // Returns the tenant ID on success.
44- CreateTenant (ctx context.Context , tenantID , slug , displayName string ) (string , error )
52+ // The metadata map is stored on the tenant record (used for registration credentials
53+ // when provisioning is async).
54+ CreateTenant (ctx context.Context , tenantID , slug , displayName string , metadata map [string ]interface {}) (* CreateTenantResult , error )
4555 // DeleteTenant removes a tenant. Used as compensation when identity provisioning
4656 // fails after tenant creation, preventing orphaned tenants.
4757 DeleteTenant (ctx context.Context , tenantID string ) error
@@ -119,6 +129,7 @@ type registrationResponse struct {
119129 TenantID string `json:"tenant_id"`
120130 LoginURL string `json:"login_url"`
121131 VerificationRequired bool `json:"verification_required"`
132+ ProvisioningPending bool `json:"provisioning_pending"`
122133}
123134
124135// HandleRegister handles POST /api/v1/register.
@@ -155,9 +166,10 @@ func (h *RegistrationHandler) HandleRegister(w http.ResponseWriter, r *http.Requ
155166 return
156167 }
157168
158- h .logger .InfoContext (ctx , "registration: tenant and admin identity created" ,
169+ h .logger .InfoContext (ctx , "registration: tenant created" ,
159170 "tenant_id" , resp .TenantID ,
160171 "slug" , req .Slug ,
172+ "provisioning_pending" , resp .ProvisioningPending ,
161173 "client_ip" , clientIP )
162174
163175 writeJSON (w , http .StatusCreated , resp )
@@ -185,7 +197,19 @@ func (h *RegistrationHandler) parseAndValidateRequest(r *http.Request) (*registr
185197 return & req , nil
186198}
187199
200+ // Metadata keys used to store self-registered admin credentials on the tenant record.
201+ // These are read by the self-registered admin post-provisioning hook and cleared after
202+ // the identity is created, so they never persist beyond tenant activation.
203+ const (
204+ MetaKeyRegistrationEmail = "_registration_email"
205+ MetaKeyRegistrationPasswordHash = "_registration_password_hash"
206+ )
207+
188208// executeRegistration performs tenant creation and identity provisioning.
209+ // When the tenant requires async provisioning, admin credentials are stored in tenant
210+ // metadata and identity creation is deferred to a post-provisioning hook.
211+ // When provisioning is synchronous (tenant immediately active), the identity is
212+ // created inline as before.
189213// Returns (httpStatus, response, error). On success error is nil.
190214func (h * RegistrationHandler ) executeRegistration (ctx context.Context , req * registrationRequest ) (int , * registrationResponse , error ) {
191215 tenantID := strings .ReplaceAll (req .Slug , "-" , "_" )
@@ -194,7 +218,21 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
194218 displayName = req .Slug
195219 }
196220
197- createdTenantID , err := h .tenantCreator .CreateTenant (ctx , tenantID , req .Slug , displayName )
221+ // Hash password early so we can store it in metadata for async provisioning.
222+ passwordHash , err := credentials .HashPassword (req .Password )
223+ if err != nil {
224+ h .logger .ErrorContext (ctx , "registration: failed to hash password" , "error" , err )
225+ return http .StatusInternalServerError , nil , errIdentityCreationFailed
226+ }
227+
228+ // Include registration credentials in tenant metadata so the post-provisioning
229+ // hook can create the admin identity after schema provisioning completes.
230+ metadata := map [string ]interface {}{
231+ MetaKeyRegistrationEmail : req .Email ,
232+ MetaKeyRegistrationPasswordHash : passwordHash ,
233+ }
234+
235+ result , err := h .tenantCreator .CreateTenant (ctx , tenantID , req .Slug , displayName , metadata )
198236 if err != nil {
199237 if isAlreadyExistsError (err ) {
200238 return http .StatusConflict , nil , errSlugTaken
@@ -204,14 +242,23 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
204242 return http .StatusInternalServerError , nil , errTenantCreationFailed
205243 }
206244
207- regErr := h .provisionAdminIdentity (ctx , createdTenantID , req .Email , req .Password )
208- if regErr != nil {
209- // Compensate: delete orphaned tenant to allow the user to retry.
210- if delErr := h .tenantCreator .DeleteTenant (ctx , createdTenantID ); delErr != nil {
211- h .logger .ErrorContext (ctx , "registration: failed to compensate (delete tenant)" ,
212- "tenant_id" , createdTenantID , "error" , delErr )
245+ if result .ProvisioningPending {
246+ // Tenant requires async provisioning - identity will be created by the
247+ // self-registered admin post-provisioning hook after schemas are ready.
248+ h .logger .InfoContext (ctx , "registration: tenant created with async provisioning, identity deferred to post-provisioning hook" ,
249+ "tenant_id" , result .TenantID ,
250+ "slug" , req .Slug )
251+ } else {
252+ // Tenant is immediately active - create identity inline.
253+ regErr := h .provisionAdminIdentity (ctx , result .TenantID , req .Email , passwordHash )
254+ if regErr != nil {
255+ // Compensate: delete orphaned tenant to allow the user to retry.
256+ if delErr := h .tenantCreator .DeleteTenant (ctx , result .TenantID ); delErr != nil {
257+ h .logger .ErrorContext (ctx , "registration: failed to compensate (delete tenant)" ,
258+ "tenant_id" , result .TenantID , "error" , delErr )
259+ }
260+ return regErr .status , nil , regErr
213261 }
214- return regErr .status , nil , regErr
215262 }
216263
217264 loginURL := fmt .Sprintf ("https://%s.%s/login" , req .Slug , h .baseDomain )
@@ -220,9 +267,10 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
220267 }
221268
222269 return http .StatusCreated , & registrationResponse {
223- TenantID : createdTenantID ,
270+ TenantID : result . TenantID ,
224271 LoginURL : loginURL ,
225272 VerificationRequired : h .emailVerificationRequired ,
273+ ProvisioningPending : result .ProvisioningPending ,
226274 }, nil
227275}
228276
@@ -240,7 +288,8 @@ func newRegistrationError(status int, inner error) *registrationError {
240288}
241289
242290// provisionAdminIdentity creates the initial admin identity within the new tenant's scope.
243- func (h * RegistrationHandler ) provisionAdminIdentity (ctx context.Context , tenantIDStr , emailAddr , password string ) * registrationError {
291+ // The passwordHash parameter is a pre-computed bcrypt hash.
292+ func (h * RegistrationHandler ) provisionAdminIdentity (ctx context.Context , tenantIDStr , emailAddr , passwordHash string ) * registrationError {
244293 tid , err := tenant .NewTenantID (tenantIDStr )
245294 if err != nil {
246295 h .logger .ErrorContext (ctx , "registration: invalid tenant ID from creation" ,
@@ -250,7 +299,7 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant
250299
251300 tenantCtx := tenant .WithTenant (ctx , tid )
252301
253- identity , regErr := h .buildAdminIdentity (ctx , tid , emailAddr , password )
302+ identity , regErr := h .buildAdminIdentity (ctx , tid , emailAddr , passwordHash )
254303 if regErr != nil {
255304 return regErr
256305 }
@@ -273,9 +322,9 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant
273322 return nil
274323}
275324
276- // buildAdminIdentity creates a new identity with the given credentials and activates it
277- // if email verification is not required.
278- func (h * RegistrationHandler ) buildAdminIdentity (ctx context.Context , tid tenant.TenantID , emailAddr , password string ) (* identitydomain.Identity , * registrationError ) {
325+ // buildAdminIdentity creates a new identity with the given pre-computed password hash
326+ // and activates it if email verification is not required.
327+ func (h * RegistrationHandler ) buildAdminIdentity (ctx context.Context , tid tenant.TenantID , emailAddr , passwordHash string ) (* identitydomain.Identity , * registrationError ) {
279328 var identity * identitydomain.Identity
280329 var err error
281330 if h .emailVerificationRequired {
@@ -287,13 +336,7 @@ func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant
287336 return nil , newRegistrationError (http .StatusBadRequest , fmt .Errorf ("%w: %w" , errIdentityCreationFailed , err ))
288337 }
289338
290- hash , err := credentials .HashPassword (password )
291- if err != nil {
292- h .logger .ErrorContext (ctx , "registration: failed to hash password" , "error" , err )
293- return nil , newRegistrationError (http .StatusInternalServerError , errIdentityCreationFailed )
294- }
295-
296- if err := identity .SetPassword (hash ); err != nil {
339+ if err := identity .SetPassword (passwordHash ); err != nil {
297340 h .logger .ErrorContext (ctx , "registration: failed to set password" , "error" , err )
298341 return nil , newRegistrationError (http .StatusInternalServerError , errIdentityCreationFailed )
299342 }
0 commit comments