Skip to content

Commit 08cdb8a

Browse files
authored
feat: self-service sign-up to tenant creation end-to-end flow (#2126)
* 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 * fix: address review feedback - credential cleanup and metadata safety - Make metadata conversion failure fatal in loopbackTenantCreator (prevents silent registration failure for async provisioning) - Add ClearTenantMetadata to TenantCreator interface and clear credentials from tenant metadata after sync identity provisioning - Make clearRegistrationMetadata failure fatal in post-provisioning hook (enforces minimal credential retention) - Export metadata key constants from bootstrap package and add cross-package sync test against gateway constants - Reuse existing tenant repo in wire_services.go - Add nil tenant repo validation test - Remove placeholder test with no coverage * refactor: replace concrete tenant repo with TenantMetadataStore interface Replace direct dependency on *tenantpersistence.Repository with a TenantMetadataStore interface defined in the identity bootstrap package. This fixes the cross-service domain import architecture violation (identity -> tenant/adapters/persistence). Add GetMetadata convenience method on tenant repository to satisfy the new interface without exposing full domain objects. * fix: async path respects email verification, harden metadata handling - Store emailVerificationRequired in tenant metadata so the post-provisioning hook creates PENDING_VERIFICATION identities when email verification is enabled (fixes bypass on async path) - Fail hook on partial/malformed registration metadata instead of silently succeeding (prevents orphaned tenants without admin) - Remove raw email addresses from hook log messages (PII) - Add MetaKeyRegistrationEmailVerifyRequired constant with sync test --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 007371e commit 08cdb8a

8 files changed

Lines changed: 556 additions & 54 deletions

File tree

cmd/meridian/gateway.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import (
1717
platformauth "github.com/meridianhub/meridian/shared/platform/auth"
1818
"github.com/meridianhub/meridian/shared/platform/env"
1919
platformgateway "github.com/meridianhub/meridian/shared/platform/gateway"
20+
"github.com/meridianhub/meridian/shared/platform/tenant"
2021

2122
"google.golang.org/grpc"
23+
"google.golang.org/protobuf/types/known/structpb"
2224
"gorm.io/gorm"
2325
)
2426

@@ -204,30 +206,47 @@ var errNilTenantResponse = fmt.Errorf("InitiateTenant returned nil tenant")
204206
// gateway.TenantCreator interface used by RegistrationHandler.
205207
type loopbackTenantCreator struct {
206208
client tenantv1.TenantServiceClient
209+
tenantRepo *tenantpersistence.Repository
207210
baseDomain string
208211
logger *slog.Logger
209212
}
210213

211-
func (a *loopbackTenantCreator) CreateTenant(ctx context.Context, tenantID, slug, displayName string) (string, error) {
214+
func (a *loopbackTenantCreator) CreateTenant(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*gateway.CreateTenantResult, error) {
212215
subdomain := slug
213216
if a.baseDomain != "" {
214217
subdomain = slug + "." + a.baseDomain
215218
}
216219

217-
resp, err := a.client.InitiateTenant(ctx, &tenantv1.InitiateTenantRequest{
220+
req := &tenantv1.InitiateTenantRequest{
218221
TenantId: tenantID,
219222
DisplayName: displayName,
220223
Slug: slug,
221224
Subdomain: subdomain,
222225
SettlementAsset: "USD",
223-
})
226+
}
227+
228+
// Pass registration metadata through to the tenant record.
229+
// Metadata conversion must succeed - async provisioning relies on credentials
230+
// being present in the tenant record for the post-provisioning hook.
231+
if len(metadata) > 0 {
232+
pbMeta, err := structpb.NewStruct(metadata)
233+
if err != nil {
234+
return nil, fmt.Errorf("failed to convert registration metadata: %w", err)
235+
}
236+
req.Metadata = pbMeta
237+
}
238+
239+
resp, err := a.client.InitiateTenant(ctx, req)
224240
if err != nil {
225-
return "", err
241+
return nil, err
226242
}
227243
if resp.Tenant == nil {
228-
return "", errNilTenantResponse
244+
return nil, errNilTenantResponse
229245
}
230-
return resp.Tenant.TenantId, nil
246+
return &gateway.CreateTenantResult{
247+
TenantID: resp.Tenant.TenantId,
248+
ProvisioningPending: resp.Tenant.Status == tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
249+
}, nil
231250
}
232251

233252
func (a *loopbackTenantCreator) DeleteTenant(ctx context.Context, tenantID string) error {
@@ -238,6 +257,17 @@ func (a *loopbackTenantCreator) DeleteTenant(ctx context.Context, tenantID strin
238257
return err
239258
}
240259

260+
func (a *loopbackTenantCreator) ClearTenantMetadata(ctx context.Context, tenantID string) error {
261+
if a.tenantRepo == nil {
262+
return nil
263+
}
264+
tid, err := tenant.NewTenantID(tenantID)
265+
if err != nil {
266+
return fmt.Errorf("invalid tenant ID: %w", err)
267+
}
268+
return a.tenantRepo.UpdateMetadata(ctx, tid, map[string]interface{}{})
269+
}
270+
241271
// loopbackSlugChecker adapts the tenant persistence repository to the
242272
// gateway.SlugChecker interface used by RegistrationHandler.
243273
type loopbackSlugChecker struct {
@@ -291,8 +321,9 @@ func wireRegistration(identityDB, tenantDB *gorm.DB, rawConn *grpc.ClientConn, b
291321
identityRepo := identitypersistence.NewRepository(identityDB)
292322
tenantClient := tenantv1.NewTenantServiceClient(rawConn)
293323

294-
creator := &loopbackTenantCreator{client: tenantClient, baseDomain: baseDomain, logger: logger}
295-
slugChecker := &loopbackSlugChecker{repo: tenantpersistence.NewRepository(tenantDB)}
324+
tenantRepo := tenantpersistence.NewRepository(tenantDB)
325+
creator := &loopbackTenantCreator{client: tenantClient, tenantRepo: tenantRepo, baseDomain: baseDomain, logger: logger}
326+
slugChecker := &loopbackSlugChecker{repo: tenantRepo}
296327

297328
emailVerificationRequired := env.GetEnvAsBool("EMAIL_VERIFICATION_REQUIRED", false)
298329

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,16 @@ 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+
selfRegHook, hookErr := identitybootstrap.NewSelfRegisteredAdminHook(identityRepo, repo, logger)
250+
if hookErr != nil {
251+
_ = prov.Close()
252+
return nil, nil, fmt.Errorf("self-registered admin hook: %w", hookErr)
253+
}
254+
w.RegisterPostProvisioningHook("self-registered-admin", selfRegHook.AsPostProvisioningHook())
255+
246256
go w.Start(ctx)
247257
logger.Info("provisioning worker started")
248258

services/api-gateway/registration_handler.go

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,27 @@ 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
58+
// ClearTenantMetadata removes all metadata from a tenant record.
59+
// Used to clean up registration credentials after sync identity provisioning.
60+
ClearTenantMetadata(ctx context.Context, tenantID string) error
4861
}
4962

5063
// SlugChecker abstracts slug availability checks for the registration handler.
@@ -119,6 +132,7 @@ type registrationResponse struct {
119132
TenantID string `json:"tenant_id"`
120133
LoginURL string `json:"login_url"`
121134
VerificationRequired bool `json:"verification_required"`
135+
ProvisioningPending bool `json:"provisioning_pending"`
122136
}
123137

124138
// HandleRegister handles POST /api/v1/register.
@@ -155,9 +169,10 @@ func (h *RegistrationHandler) HandleRegister(w http.ResponseWriter, r *http.Requ
155169
return
156170
}
157171

158-
h.logger.InfoContext(ctx, "registration: tenant and admin identity created",
172+
h.logger.InfoContext(ctx, "registration: tenant created",
159173
"tenant_id", resp.TenantID,
160174
"slug", req.Slug,
175+
"provisioning_pending", resp.ProvisioningPending,
161176
"client_ip", clientIP)
162177

163178
writeJSON(w, http.StatusCreated, resp)
@@ -185,7 +200,20 @@ func (h *RegistrationHandler) parseAndValidateRequest(r *http.Request) (*registr
185200
return &req, nil
186201
}
187202

203+
// Metadata keys used to store self-registered admin credentials on the tenant record.
204+
// These are read by the self-registered admin post-provisioning hook and cleared after
205+
// the identity is created, so they never persist beyond tenant activation.
206+
const (
207+
MetaKeyRegistrationEmail = "_registration_email"
208+
MetaKeyRegistrationPasswordHash = "_registration_password_hash"
209+
MetaKeyRegistrationEmailVerifyRequired = "_registration_email_verify_required"
210+
)
211+
188212
// executeRegistration performs tenant creation and identity provisioning.
213+
// When the tenant requires async provisioning, admin credentials are stored in tenant
214+
// metadata and identity creation is deferred to a post-provisioning hook.
215+
// When provisioning is synchronous (tenant immediately active), the identity is
216+
// created inline as before.
189217
// Returns (httpStatus, response, error). On success error is nil.
190218
func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *registrationRequest) (int, *registrationResponse, error) {
191219
tenantID := strings.ReplaceAll(req.Slug, "-", "_")
@@ -194,7 +222,22 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
194222
displayName = req.Slug
195223
}
196224

197-
createdTenantID, err := h.tenantCreator.CreateTenant(ctx, tenantID, req.Slug, displayName)
225+
// Hash password early so we can store it in metadata for async provisioning.
226+
passwordHash, err := credentials.HashPassword(req.Password)
227+
if err != nil {
228+
h.logger.ErrorContext(ctx, "registration: failed to hash password", "error", err)
229+
return http.StatusInternalServerError, nil, errIdentityCreationFailed
230+
}
231+
232+
// Include registration credentials in tenant metadata so the post-provisioning
233+
// hook can create the admin identity after schema provisioning completes.
234+
metadata := map[string]interface{}{
235+
MetaKeyRegistrationEmail: req.Email,
236+
MetaKeyRegistrationPasswordHash: passwordHash,
237+
MetaKeyRegistrationEmailVerifyRequired: h.emailVerificationRequired,
238+
}
239+
240+
result, err := h.tenantCreator.CreateTenant(ctx, tenantID, req.Slug, displayName, metadata)
198241
if err != nil {
199242
if isAlreadyExistsError(err) {
200243
return http.StatusConflict, nil, errSlugTaken
@@ -204,14 +247,28 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
204247
return http.StatusInternalServerError, nil, errTenantCreationFailed
205248
}
206249

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)
250+
if result.ProvisioningPending {
251+
// Tenant requires async provisioning - identity will be created by the
252+
// self-registered admin post-provisioning hook after schemas are ready.
253+
h.logger.InfoContext(ctx, "registration: tenant created with async provisioning, identity deferred to post-provisioning hook",
254+
"tenant_id", result.TenantID,
255+
"slug", req.Slug)
256+
} else {
257+
// Tenant is immediately active - create identity inline.
258+
regErr := h.provisionAdminIdentity(ctx, result.TenantID, req.Email, passwordHash)
259+
if regErr != nil {
260+
// Compensate: delete orphaned tenant to allow the user to retry.
261+
if delErr := h.tenantCreator.DeleteTenant(ctx, result.TenantID); delErr != nil {
262+
h.logger.ErrorContext(ctx, "registration: failed to compensate (delete tenant)",
263+
"tenant_id", result.TenantID, "error", delErr)
264+
}
265+
return regErr.status, nil, regErr
266+
}
267+
// Clear registration credentials from tenant metadata (best-effort).
268+
if clearErr := h.tenantCreator.ClearTenantMetadata(ctx, result.TenantID); clearErr != nil {
269+
h.logger.WarnContext(ctx, "registration: failed to clear credentials from tenant metadata",
270+
"tenant_id", result.TenantID, "error", clearErr)
213271
}
214-
return regErr.status, nil, regErr
215272
}
216273

217274
loginURL := fmt.Sprintf("https://%s.%s/login", req.Slug, h.baseDomain)
@@ -220,9 +277,10 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
220277
}
221278

222279
return http.StatusCreated, &registrationResponse{
223-
TenantID: createdTenantID,
280+
TenantID: result.TenantID,
224281
LoginURL: loginURL,
225282
VerificationRequired: h.emailVerificationRequired,
283+
ProvisioningPending: result.ProvisioningPending,
226284
}, nil
227285
}
228286

@@ -240,7 +298,8 @@ func newRegistrationError(status int, inner error) *registrationError {
240298
}
241299

242300
// 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 {
301+
// The passwordHash parameter is a pre-computed bcrypt hash.
302+
func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenantIDStr, emailAddr, passwordHash string) *registrationError {
244303
tid, err := tenant.NewTenantID(tenantIDStr)
245304
if err != nil {
246305
h.logger.ErrorContext(ctx, "registration: invalid tenant ID from creation",
@@ -250,7 +309,7 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant
250309

251310
tenantCtx := tenant.WithTenant(ctx, tid)
252311

253-
identity, regErr := h.buildAdminIdentity(ctx, tid, emailAddr, password)
312+
identity, regErr := h.buildAdminIdentity(ctx, tid, emailAddr, passwordHash)
254313
if regErr != nil {
255314
return regErr
256315
}
@@ -273,9 +332,9 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant
273332
return nil
274333
}
275334

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) {
335+
// buildAdminIdentity creates a new identity with the given pre-computed password hash
336+
// and activates it if email verification is not required.
337+
func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant.TenantID, emailAddr, passwordHash string) (*identitydomain.Identity, *registrationError) {
279338
var identity *identitydomain.Identity
280339
var err error
281340
if h.emailVerificationRequired {
@@ -287,13 +346,7 @@ func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant
287346
return nil, newRegistrationError(http.StatusBadRequest, fmt.Errorf("%w: %w", errIdentityCreationFailed, err))
288347
}
289348

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 {
349+
if err := identity.SetPassword(passwordHash); err != nil {
297350
h.logger.ErrorContext(ctx, "registration: failed to set password", "error", err)
298351
return nil, newRegistrationError(http.StatusInternalServerError, errIdentityCreationFailed)
299352
}

0 commit comments

Comments
 (0)