Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions cmd/meridian/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
platformgateway "github.com/meridianhub/meridian/shared/platform/gateway"

"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/structpb"
"gorm.io/gorm"
)

Expand Down Expand Up @@ -208,26 +209,41 @@ type loopbackTenantCreator struct {
logger *slog.Logger
}

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

resp, err := a.client.InitiateTenant(ctx, &tenantv1.InitiateTenantRequest{
req := &tenantv1.InitiateTenantRequest{
TenantId: tenantID,
DisplayName: displayName,
Slug: slug,
Subdomain: subdomain,
SettlementAsset: "USD",
})
}

// Pass registration metadata through to the tenant record.
if len(metadata) > 0 {
pbMeta, err := structpb.NewStruct(metadata)
if err != nil {
a.logger.Warn("failed to convert registration metadata to protobuf struct", "error", err)
} else {
req.Metadata = pbMeta
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

resp, err := a.client.InitiateTenant(ctx, req)
if err != nil {
return "", err
return nil, err
}
if resp.Tenant == nil {
return "", errNilTenantResponse
return nil, errNilTenantResponse
}
return resp.Tenant.TenantId, nil
return &gateway.CreateTenantResult{
TenantID: resp.Tenant.TenantId,
ProvisioningPending: resp.Tenant.Status == tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
}, nil
}

func (a *loopbackTenantCreator) DeleteTenant(ctx context.Context, tenantID string) error {
Expand Down
45 changes: 39 additions & 6 deletions cmd/meridian/registration_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ func (s *stubTenantServiceClient) UpdateTenantStatus(_ context.Context, req *ten
func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
stub := &stubTenantServiceClient{
initiateResp: &tenantv1.InitiateTenantResponse{
Tenant: &tenantv1.Tenant{TenantId: "acme_corp"},
Tenant: &tenantv1.Tenant{
TenantId: "acme_corp",
Status: tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
},
},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
Expand All @@ -69,10 +72,11 @@ func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
logger: logger,
}

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

require.NoError(t, err)
assert.Equal(t, "acme_corp", tenantID)
assert.Equal(t, "acme_corp", result.TenantID)
assert.True(t, result.ProvisioningPending)
assert.True(t, stub.initiateCalled)
assert.Equal(t, "acme_corp", stub.initiateReq.TenantId)
assert.Equal(t, "Acme Corp", stub.initiateReq.DisplayName)
Expand All @@ -85,7 +89,10 @@ func TestLoopbackTenantCreator_CreateTenant(t *testing.T) {
func TestLoopbackTenantCreator_CreateTenant_EmptyBaseDomain(t *testing.T) {
stub := &stubTenantServiceClient{
initiateResp: &tenantv1.InitiateTenantResponse{
Tenant: &tenantv1.Tenant{TenantId: "acme_corp"},
Tenant: &tenantv1.Tenant{
TenantId: "acme_corp",
Status: tenantv1.TenantStatus_TENANT_STATUS_ACTIVE,
},
},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
Expand All @@ -96,9 +103,10 @@ func TestLoopbackTenantCreator_CreateTenant_EmptyBaseDomain(t *testing.T) {
logger: logger,
}

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

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

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

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

require.Error(t, err)
assert.Contains(t, err.Error(), "nil tenant")
}

func TestLoopbackTenantCreator_CreateTenant_PassesMetadata(t *testing.T) {
stub := &stubTenantServiceClient{
initiateResp: &tenantv1.InitiateTenantResponse{
Tenant: &tenantv1.Tenant{
TenantId: "acme_corp",
Status: tenantv1.TenantStatus_TENANT_STATUS_PROVISIONING_PENDING,
},
},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

creator := &loopbackTenantCreator{client: stub, baseDomain: "test.local", logger: logger}

metadata := map[string]interface{}{
"_registration_email": "admin@acme.com",
"_registration_password_hash": "$2a$10$fakehash",
}
_, err := creator.CreateTenant(context.Background(), "acme_corp", "acme-corp", "Acme Corp", metadata)

require.NoError(t, err)
require.NotNil(t, stub.initiateReq.Metadata)
assert.Equal(t, "admin@acme.com", stub.initiateReq.Metadata.Fields["_registration_email"].GetStringValue())
assert.Equal(t, "$2a$10$fakehash", stub.initiateReq.Metadata.Fields["_registration_password_hash"].GetStringValue())
}

func TestLoopbackTenantCreator_DeleteTenant(t *testing.T) {
stub := &stubTenantServiceClient{
updateStatusResp: &tenantv1.UpdateTenantStatusResponse{},
Expand Down
11 changes: 11 additions & 0 deletions cmd/meridian/wire_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ func startProvisioningWorker(ctx context.Context, prov *tenantprovisioner.Postgr
valuationSeeder := refvaluation.NewSeeder(refDataPool)
w.RegisterPostProvisioningHook("valuation-defaults", valuationSeeder.AsPostProvisioningHook())

// Self-registered admin: creates the admin identity from registration metadata
// stored by the registration handler. Must run after all reference data hooks
// so the identity schema has instruments, account types, etc. available.
tenantRepo := tenantpersistence.NewRepository(conns.gormDB("tenant"))
selfRegHook, hookErr := identitybootstrap.NewSelfRegisteredAdminHook(identityRepo, tenantRepo, logger)
if hookErr != nil {
_ = prov.Close()
return nil, nil, fmt.Errorf("self-registered admin hook: %w", hookErr)
}
w.RegisterPostProvisioningHook("self-registered-admin", selfRegHook.AsPostProvisioningHook())

go w.Start(ctx)
logger.Info("provisioning worker started")

Expand Down
91 changes: 67 additions & 24 deletions services/api-gateway/registration_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,21 @@ var (
errEmailAlreadyRegistered = errors.New("email already registered in this tenant")
)

// CreateTenantResult holds the outcome of a tenant creation request.
type CreateTenantResult struct {
TenantID string
// ProvisioningPending is true when the tenant requires async schema provisioning
// before it becomes active. When true, identity creation must be deferred to
// a post-provisioning hook and credentials stored in tenant metadata.
ProvisioningPending bool
}

// TenantCreator abstracts tenant creation for the registration handler.
type TenantCreator interface {
// CreateTenant creates a new tenant with the given ID, slug, and display name.
// Returns the tenant ID on success.
CreateTenant(ctx context.Context, tenantID, slug, displayName string) (string, error)
// The metadata map is stored on the tenant record (used for registration credentials
// when provisioning is async).
CreateTenant(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*CreateTenantResult, error)
// DeleteTenant removes a tenant. Used as compensation when identity provisioning
// fails after tenant creation, preventing orphaned tenants.
DeleteTenant(ctx context.Context, tenantID string) error
Expand Down Expand Up @@ -119,6 +129,7 @@ type registrationResponse struct {
TenantID string `json:"tenant_id"`
LoginURL string `json:"login_url"`
VerificationRequired bool `json:"verification_required"`
ProvisioningPending bool `json:"provisioning_pending"`
}

// HandleRegister handles POST /api/v1/register.
Expand Down Expand Up @@ -155,9 +166,10 @@ func (h *RegistrationHandler) HandleRegister(w http.ResponseWriter, r *http.Requ
return
}

h.logger.InfoContext(ctx, "registration: tenant and admin identity created",
h.logger.InfoContext(ctx, "registration: tenant created",
"tenant_id", resp.TenantID,
"slug", req.Slug,
"provisioning_pending", resp.ProvisioningPending,
"client_ip", clientIP)

writeJSON(w, http.StatusCreated, resp)
Expand Down Expand Up @@ -185,7 +197,19 @@ func (h *RegistrationHandler) parseAndValidateRequest(r *http.Request) (*registr
return &req, nil
}

// Metadata keys used to store self-registered admin credentials on the tenant record.
// These are read by the self-registered admin post-provisioning hook and cleared after
// the identity is created, so they never persist beyond tenant activation.
const (
MetaKeyRegistrationEmail = "_registration_email"
MetaKeyRegistrationPasswordHash = "_registration_password_hash"
)

// executeRegistration performs tenant creation and identity provisioning.
// When the tenant requires async provisioning, admin credentials are stored in tenant
// metadata and identity creation is deferred to a post-provisioning hook.
// When provisioning is synchronous (tenant immediately active), the identity is
// created inline as before.
// Returns (httpStatus, response, error). On success error is nil.
func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *registrationRequest) (int, *registrationResponse, error) {
tenantID := strings.ReplaceAll(req.Slug, "-", "_")
Expand All @@ -194,7 +218,21 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
displayName = req.Slug
}

createdTenantID, err := h.tenantCreator.CreateTenant(ctx, tenantID, req.Slug, displayName)
// Hash password early so we can store it in metadata for async provisioning.
passwordHash, err := credentials.HashPassword(req.Password)
if err != nil {
h.logger.ErrorContext(ctx, "registration: failed to hash password", "error", err)
return http.StatusInternalServerError, nil, errIdentityCreationFailed
}

// Include registration credentials in tenant metadata so the post-provisioning
// hook can create the admin identity after schema provisioning completes.
metadata := map[string]interface{}{
MetaKeyRegistrationEmail: req.Email,
MetaKeyRegistrationPasswordHash: passwordHash,
}

result, err := h.tenantCreator.CreateTenant(ctx, tenantID, req.Slug, displayName, metadata)
if err != nil {
if isAlreadyExistsError(err) {
return http.StatusConflict, nil, errSlugTaken
Expand All @@ -204,14 +242,23 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
return http.StatusInternalServerError, nil, errTenantCreationFailed
}

regErr := h.provisionAdminIdentity(ctx, createdTenantID, req.Email, req.Password)
if regErr != nil {
// Compensate: delete orphaned tenant to allow the user to retry.
if delErr := h.tenantCreator.DeleteTenant(ctx, createdTenantID); delErr != nil {
h.logger.ErrorContext(ctx, "registration: failed to compensate (delete tenant)",
"tenant_id", createdTenantID, "error", delErr)
if result.ProvisioningPending {
// Tenant requires async provisioning - identity will be created by the
// self-registered admin post-provisioning hook after schemas are ready.
h.logger.InfoContext(ctx, "registration: tenant created with async provisioning, identity deferred to post-provisioning hook",
"tenant_id", result.TenantID,
"slug", req.Slug)
} else {
// Tenant is immediately active - create identity inline.
regErr := h.provisionAdminIdentity(ctx, result.TenantID, req.Email, passwordHash)
if regErr != nil {
// Compensate: delete orphaned tenant to allow the user to retry.
if delErr := h.tenantCreator.DeleteTenant(ctx, result.TenantID); delErr != nil {
h.logger.ErrorContext(ctx, "registration: failed to compensate (delete tenant)",
Comment thread
claude[bot] marked this conversation as resolved.
"tenant_id", result.TenantID, "error", delErr)
}
return regErr.status, nil, regErr
}
return regErr.status, nil, regErr
}

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

return http.StatusCreated, &registrationResponse{
TenantID: createdTenantID,
TenantID: result.TenantID,
LoginURL: loginURL,
VerificationRequired: h.emailVerificationRequired,
ProvisioningPending: result.ProvisioningPending,
}, nil
}

Expand All @@ -240,7 +288,8 @@ func newRegistrationError(status int, inner error) *registrationError {
}

// provisionAdminIdentity creates the initial admin identity within the new tenant's scope.
func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenantIDStr, emailAddr, password string) *registrationError {
// The passwordHash parameter is a pre-computed bcrypt hash.
func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenantIDStr, emailAddr, passwordHash string) *registrationError {
tid, err := tenant.NewTenantID(tenantIDStr)
if err != nil {
h.logger.ErrorContext(ctx, "registration: invalid tenant ID from creation",
Expand All @@ -250,7 +299,7 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant

tenantCtx := tenant.WithTenant(ctx, tid)

identity, regErr := h.buildAdminIdentity(ctx, tid, emailAddr, password)
identity, regErr := h.buildAdminIdentity(ctx, tid, emailAddr, passwordHash)
if regErr != nil {
return regErr
}
Expand All @@ -273,9 +322,9 @@ func (h *RegistrationHandler) provisionAdminIdentity(ctx context.Context, tenant
return nil
}

// buildAdminIdentity creates a new identity with the given credentials and activates it
// if email verification is not required.
func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant.TenantID, emailAddr, password string) (*identitydomain.Identity, *registrationError) {
// buildAdminIdentity creates a new identity with the given pre-computed password hash
// and activates it if email verification is not required.
func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant.TenantID, emailAddr, passwordHash string) (*identitydomain.Identity, *registrationError) {
var identity *identitydomain.Identity
var err error
if h.emailVerificationRequired {
Expand All @@ -287,13 +336,7 @@ func (h *RegistrationHandler) buildAdminIdentity(ctx context.Context, tid tenant
return nil, newRegistrationError(http.StatusBadRequest, fmt.Errorf("%w: %w", errIdentityCreationFailed, err))
}

hash, err := credentials.HashPassword(password)
if err != nil {
h.logger.ErrorContext(ctx, "registration: failed to hash password", "error", err)
return nil, newRegistrationError(http.StatusInternalServerError, errIdentityCreationFailed)
}

if err := identity.SetPassword(hash); err != nil {
if err := identity.SetPassword(passwordHash); err != nil {
h.logger.ErrorContext(ctx, "registration: failed to set password", "error", err)
return nil, newRegistrationError(http.StatusInternalServerError, errIdentityCreationFailed)
}
Expand Down
Loading
Loading