Skip to content

Commit 9b63133

Browse files
committed
feat: defer identity creation to post-provisioning hook for async tenant registration
When a user self-registers and the tenant requires async schema provisioning, the registration handler now stores the admin email and password hash in tenant metadata instead of attempting to create the identity immediately (which would fail because the tenant schema doesn't exist yet). A new post-provisioning hook reads the stored credentials after schema provisioning completes and creates the self-registered admin identity with tenant-owner role, then clears the credentials from metadata. This completes the end-to-end self-service sign-up flow: 1. User submits registration form 2. Tenant created with provisioning_pending status + credentials in metadata 3. Provisioning worker creates schemas, runs migrations, seeds reference data 4. Self-registered admin hook creates the admin identity 5. Platform admin hook provisions platform admin (existing behavior) 6. Tenant becomes active 7. User sees provisioning progress page, auto-redirects to login on completion
1 parent 007371e commit 9b63133

8 files changed

Lines changed: 492 additions & 51 deletions

File tree

cmd/meridian/gateway.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
platformgateway "github.com/meridianhub/meridian/shared/platform/gateway"
2020

2121
"google.golang.org/grpc"
22+
"google.golang.org/protobuf/types/known/structpb"
2223
"gorm.io/gorm"
2324
)
2425

@@ -208,26 +209,41 @@ type loopbackTenantCreator struct {
208209
logger *slog.Logger
209210
}
210211

211-
func (a *loopbackTenantCreator) CreateTenant(ctx context.Context, tenantID, slug, displayName string) (string, error) {
212+
func (a *loopbackTenantCreator) CreateTenant(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*gateway.CreateTenantResult, error) {
212213
subdomain := slug
213214
if a.baseDomain != "" {
214215
subdomain = slug + "." + a.baseDomain
215216
}
216217

217-
resp, err := a.client.InitiateTenant(ctx, &tenantv1.InitiateTenantRequest{
218+
req := &tenantv1.InitiateTenantRequest{
218219
TenantId: tenantID,
219220
DisplayName: displayName,
220221
Slug: slug,
221222
Subdomain: subdomain,
222223
SettlementAsset: "USD",
223-
})
224+
}
225+
226+
// Pass registration metadata through to the tenant record.
227+
if len(metadata) > 0 {
228+
pbMeta, err := structpb.NewStruct(metadata)
229+
if err != nil {
230+
a.logger.Warn("failed to convert registration metadata to protobuf struct", "error", err)
231+
} else {
232+
req.Metadata = pbMeta
233+
}
234+
}
235+
236+
resp, err := a.client.InitiateTenant(ctx, req)
224237
if err != nil {
225-
return "", err
238+
return nil, err
226239
}
227240
if resp.Tenant == nil {
228-
return "", errNilTenantResponse
241+
return nil, errNilTenantResponse
229242
}
230-
return resp.Tenant.TenantId, nil
243+
return &gateway.CreateTenantResult{
244+
TenantID: resp.Tenant.TenantId,
245+
ProvisioningPending: resp.Tenant.Status == tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
246+
}, nil
231247
}
232248

233249
func (a *loopbackTenantCreator) DeleteTenant(ctx context.Context, tenantID string) error {

cmd/meridian/registration_wiring_test.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ func (s *stubTenantServiceClient) UpdateTenantStatus(_ context.Context, req *ten
5858
func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
5959
stub := &stubTenantServiceClient{
6060
initiateResp: &tenantv1.InitiateTenantResponse{
61-
Tenant: &tenantv1.Tenant{TenantId: "acme_corp"},
61+
Tenant: &tenantv1.Tenant{
62+
TenantId: "acme_corp",
63+
Status: tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
64+
},
6265
},
6366
}
6467
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -69,10 +72,11 @@ func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
6972
logger: logger,
7073
}
7174

72-
tenantID, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp")
75+
result, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp", nil)
7376

7477
require.NoError(t, err)
75-
assert.Equal(t, "acme_corp", tenantID)
78+
assert.Equal(t, "acme_corp", result.TenantID)
79+
assert.True(t, result.ProvisioningPending)
7680
assert.True(t, stub.initiateCalled)
7781
assert.Equal(t, "acme_corp", stub.initiateReq.TenantId)
7882
assert.Equal(t, "Acme Corp", stub.initiateReq.DisplayName)
@@ -85,7 +89,10 @@ func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
8589
func TestLoopbackTenantCreator_CreateTenant_EmptyBaseDomain(t *testing.T) {
8690
stub := &stubTenantServiceClient{
8791
initiateResp: &tenantv1.InitiateTenantResponse{
88-
Tenant: &tenantv1.Tenant{TenantId: "acme_corp"},
92+
Tenant: &tenantv1.Tenant{
93+
TenantId: "acme_corp",
94+
Status: tenantv1.TenantStatus_TENANT_STATUS_ACTIVE,
95+
},
8996
},
9097
}
9198
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -96,9 +103,10 @@ func TestLoopbackTenantCreator_CreateTenant_EmptyBaseDomain(t *testing.T) {
96103
logger: logger,
97104
}
98105

99-
_, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp")
106+
result, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp", nil)
100107

101108
require.NoError(t, err)
109+
assert.False(t, result.ProvisioningPending)
102110
assert.Equal(t, "acme-corp", stub.initiateReq.Subdomain,
103111
"when baseDomain is empty, subdomain should be just the slug")
104112
}
@@ -111,12 +119,37 @@ func TestLoopbackTenantCreator_CreateTenant_NilTenantInResponse(t *testing.T) {
111119

112120
creator := &loopbackTenantCreator{client: stub, logger: logger}
113121

114-
_, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp")
122+
_, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp", nil)
115123

116124
require.Error(t, err)
117125
assert.Contains(t, err.Error(), "nil tenant")
118126
}
119127

128+
func TestLoopbackTenantCreator_CreateTenant_PassesMetadata(t *testing.T) {
129+
stub := &stubTenantServiceClient{
130+
initiateResp: &tenantv1.InitiateTenantResponse{
131+
Tenant: &tenantv1.Tenant{
132+
TenantId: "acme_corp",
133+
Status: tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
134+
},
135+
},
136+
}
137+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
138+
139+
creator := &loopbackTenantCreator{client: stub, baseDomain: "test.local", logger: logger}
140+
141+
metadata := map[string]interface{}{
142+
"_registration_email": "admin@acme.com",
143+
"_registration_password_hash": "$2a$10$fakehash",
144+
}
145+
_, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp", metadata)
146+
147+
require.NoError(t, err)
148+
require.NotNil(t, stub.initiateReq.Metadata)
149+
assert.Equal(t, "admin@acme.com", stub.initiateReq.Metadata.Fields["_registration_email"].GetStringValue())
150+
assert.Equal(t, "$2a$10$fakehash", stub.initiateReq.Metadata.Fields["_registration_password_hash"].GetStringValue())
151+
}
152+
120153
func TestLoopbackTenantCreator_DeleteTenant(t *testing.T) {
121154
stub := &stubTenantServiceClient{
122155
updateStatusResp: &tenantv1.UpdateTenantStatusResponse{},

cmd/meridian/wire_services.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ func startProvisioningWorker(ctx context.Context, prov *tenantprovisioner.Postgr
243243
valuationSeeder := refvaluation.NewSeeder(refDataPool)
244244
w.RegisterPostProvisioningHook("valuation-defaults", valuationSeeder.AsPostProvisioningHook())
245245

246+
// Self-registered admin: creates the admin identity from registration metadata
247+
// stored by the registration handler. Must run after all reference data hooks
248+
// so the identity schema has instruments, account types, etc. available.
249+
tenantRepo := tenantpersistence.NewRepository(conns.gormDB("tenant"))
250+
selfRegHook, hookErr := identitybootstrap.NewSelfRegisteredAdminHook(identityRepo, tenantRepo, logger)
251+
if hookErr != nil {
252+
_ = prov.Close()
253+
return nil, nil, fmt.Errorf("self-registered admin hook: %w", hookErr)
254+
}
255+
w.RegisterPostProvisioningHook("self-registered-admin", selfRegHook.AsPostProvisioningHook())
256+
246257
go w.Start(ctx)
247258
logger.Info("provisioning worker started")
248259

services/api-gateway/registration_handler.go

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4150
type 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.
190214
func (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

Comments
 (0)