Skip to content

Commit 1f58792

Browse files
committed
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
1 parent 9b63133 commit 1f58792

6 files changed

Lines changed: 67 additions & 50 deletions

File tree

cmd/meridian/gateway.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ 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"
2223
"google.golang.org/protobuf/types/known/structpb"
@@ -205,6 +206,7 @@ var errNilTenantResponse = fmt.Errorf("InitiateTenant returned nil tenant")
205206
// gateway.TenantCreator interface used by RegistrationHandler.
206207
type loopbackTenantCreator struct {
207208
client tenantv1.TenantServiceClient
209+
tenantRepo *tenantpersistence.Repository
208210
baseDomain string
209211
logger *slog.Logger
210212
}
@@ -224,13 +226,14 @@ func (a *loopbackTenantCreator) CreateTenant(ctx context.Context, tenantID, slug
224226
}
225227

226228
// 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.
227231
if len(metadata) > 0 {
228232
pbMeta, err := structpb.NewStruct(metadata)
229233
if err != nil {
230-
a.logger.Warn("failed to convert registration metadata to protobuf struct", "error", err)
231-
} else {
232-
req.Metadata = pbMeta
234+
return nil, fmt.Errorf("failed to convert registration metadata: %w", err)
233235
}
236+
req.Metadata = pbMeta
234237
}
235238

236239
resp, err := a.client.InitiateTenant(ctx, req)
@@ -254,6 +257,17 @@ func (a *loopbackTenantCreator) DeleteTenant(ctx context.Context, tenantID strin
254257
return err
255258
}
256259

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+
257271
// loopbackSlugChecker adapts the tenant persistence repository to the
258272
// gateway.SlugChecker interface used by RegistrationHandler.
259273
type loopbackSlugChecker struct {
@@ -307,8 +321,9 @@ func wireRegistration(identityDB, tenantDB *gorm.DB, rawConn *grpc.ClientConn, b
307321
identityRepo := identitypersistence.NewRepository(identityDB)
308322
tenantClient := tenantv1.NewTenantServiceClient(rawConn)
309323

310-
creator := &loopbackTenantCreator{client: tenantClient, baseDomain: baseDomain, logger: logger}
311-
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}
312327

313328
emailVerificationRequired := env.GetEnvAsBool("EMAIL_VERIFICATION_REQUIRED", false)
314329

cmd/meridian/wire_services.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,7 @@ func startProvisioningWorker(ctx context.Context, prov *tenantprovisioner.Postgr
246246
// Self-registered admin: creates the admin identity from registration metadata
247247
// stored by the registration handler. Must run after all reference data hooks
248248
// 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)
249+
selfRegHook, hookErr := identitybootstrap.NewSelfRegisteredAdminHook(identityRepo, repo, logger)
251250
if hookErr != nil {
252251
_ = prov.Close()
253252
return nil, nil, fmt.Errorf("self-registered admin hook: %w", hookErr)

services/api-gateway/registration_handler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ type TenantCreator interface {
5555
// DeleteTenant removes a tenant. Used as compensation when identity provisioning
5656
// fails after tenant creation, preventing orphaned tenants.
5757
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
5861
}
5962

6063
// SlugChecker abstracts slug availability checks for the registration handler.
@@ -259,6 +262,11 @@ func (h *RegistrationHandler) executeRegistration(ctx context.Context, req *regi
259262
}
260263
return regErr.status, nil, regErr
261264
}
265+
// Clear registration credentials from tenant metadata (best-effort).
266+
if clearErr := h.tenantCreator.ClearTenantMetadata(ctx, result.TenantID); clearErr != nil {
267+
h.logger.WarnContext(ctx, "registration: failed to clear credentials from tenant metadata",
268+
"tenant_id", result.TenantID, "error", clearErr)
269+
}
262270
}
263271

264272
loginURL := fmt.Sprintf("https://%s.%s/login", req.Slug, h.baseDomain)

services/api-gateway/registration_handler_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ import (
2424
// --- Stub implementations ---
2525

2626
type stubTenantCreator struct {
27-
createFn func(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*gateway.CreateTenantResult, error)
28-
deleteFn func(ctx context.Context, tenantID string) error
27+
createFn func(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*gateway.CreateTenantResult, error)
28+
deleteFn func(ctx context.Context, tenantID string) error
29+
clearMetadataFn func(ctx context.Context, tenantID string) error
2930
}
3031

3132
func (s *stubTenantCreator) CreateTenant(ctx context.Context, tenantID, slug, displayName string, metadata map[string]interface{}) (*gateway.CreateTenantResult, error) {
@@ -39,6 +40,13 @@ func (s *stubTenantCreator) DeleteTenant(ctx context.Context, tenantID string) e
3940
return nil
4041
}
4142

43+
func (s *stubTenantCreator) ClearTenantMetadata(ctx context.Context, tenantID string) error {
44+
if s.clearMetadataFn != nil {
45+
return s.clearMetadataFn(ctx, tenantID)
46+
}
47+
return nil
48+
}
49+
4250
type stubIdentityRepo struct {
4351
saveIdentityWithRolesFn func(ctx context.Context, identity *identitydomain.Identity, roles []*identitydomain.RoleAssignment) error
4452
findByIDFn func(ctx context.Context, id uuid.UUID) (*identitydomain.Identity, error)

services/identity/bootstrap/self_registered_admin.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import (
1919
"github.com/meridianhub/meridian/shared/platform/tenant"
2020
)
2121

22-
// Metadata keys matching the registration handler constants.
22+
// Metadata keys for self-registered admin credentials stored on the tenant record.
23+
// These MUST match the exported constants in services/api-gateway/registration_handler.go
24+
// (MetaKeyRegistrationEmail, MetaKeyRegistrationPasswordHash). The test file verifies
25+
// they stay in sync.
2326
const (
24-
metaKeyRegistrationEmail = "_registration_email"
25-
metaKeyRegistrationPasswordHash = "_registration_password_hash"
27+
MetaKeyRegistrationEmail = "_registration_email"
28+
MetaKeyRegistrationPasswordHash = "_registration_password_hash"
2629
)
2730

2831
// ErrNilTenantRepo is returned when a nil tenant repository is passed to NewSelfRegisteredAdminHook.
@@ -77,8 +80,8 @@ func (h *SelfRegisteredAdminHook) Provision(ctx context.Context, tenantID tenant
7780
return fmt.Errorf("reading tenant %s: %w", tenantID, err)
7881
}
7982

80-
emailRaw, hasEmail := t.Metadata[metaKeyRegistrationEmail]
81-
hashRaw, hasHash := t.Metadata[metaKeyRegistrationPasswordHash]
83+
emailRaw, hasEmail := t.Metadata[MetaKeyRegistrationEmail]
84+
hashRaw, hasHash := t.Metadata[MetaKeyRegistrationPasswordHash]
8285

8386
if !hasEmail || !hasHash {
8487
h.logger.InfoContext(ctx, "self-registered admin hook: no registration metadata, skipping",
@@ -106,11 +109,9 @@ func (h *SelfRegisteredAdminHook) Provision(ctx context.Context, tenantID tenant
106109
}
107110

108111
// Clear registration credentials from tenant metadata.
112+
// This is fatal: leaving a bcrypt hash in metadata violates minimal credential retention.
109113
if err := h.clearRegistrationMetadata(ctx, tenantID, t.Metadata); err != nil {
110-
// Non-fatal: identity was created successfully. Log and continue.
111-
h.logger.WarnContext(ctx, "self-registered admin hook: failed to clear registration metadata",
112-
"tenant_id", tenantID,
113-
"error", err)
114+
return fmt.Errorf("clearing registration metadata for tenant %s: %w", tenantID, err)
114115
}
115116

116117
h.logger.InfoContext(ctx, "self-registered admin identity provisioned",
@@ -171,7 +172,7 @@ func (h *SelfRegisteredAdminHook) clearRegistrationMetadata(ctx context.Context,
171172
// Copy metadata without registration keys.
172173
cleaned := make(map[string]interface{}, len(metadata))
173174
for k, v := range metadata {
174-
if k == metaKeyRegistrationEmail || k == metaKeyRegistrationPasswordHash {
175+
if k == MetaKeyRegistrationEmail || k == MetaKeyRegistrationPasswordHash {
175176
continue
176177
}
177178
cleaned[k] = v

services/identity/bootstrap/self_registered_admin_test.go

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,56 +5,42 @@ import (
55
"log/slog"
66
"testing"
77

8+
gateway "github.com/meridianhub/meridian/services/api-gateway"
89
"github.com/meridianhub/meridian/services/identity/domain"
910
tenantpersistence "github.com/meridianhub/meridian/services/tenant/adapters/persistence"
1011
"github.com/meridianhub/meridian/shared/platform/tenant"
1112
"github.com/stretchr/testify/assert"
12-
"github.com/stretchr/testify/require"
1313
)
1414

15-
func TestNewSelfRegisteredAdminHook_Validation(t *testing.T) {
16-
logger := slog.Default()
17-
18-
_, err := NewSelfRegisteredAdminHook(nil, &tenantpersistence.Repository{}, logger)
15+
func TestNewSelfRegisteredAdminHook_NilIdentityRepo(t *testing.T) {
16+
_, err := NewSelfRegisteredAdminHook(nil, &tenantpersistence.Repository{}, slog.Default())
1917
assert.ErrorIs(t, err, ErrNilRepository)
18+
}
2019

21-
// Can't easily test with a real identity repo since it requires a DB,
22-
// but we verify that nil tenant repo is rejected.
23-
// Note: We can't construct a non-nil domain.Repository without a DB connection,
24-
// so we test the nil-identity-repo case only.
20+
// mockIdentityRepo satisfies domain.Repository for testing nil tenant repo validation.
21+
type mockIdentityRepo struct {
22+
domain.Repository
2523
}
2624

27-
func TestSelfRegisteredAdminHook_NoMetadata_IsNoop(t *testing.T) {
28-
// This tests the core logic: when tenant has no registration metadata,
29-
// the hook should be a no-op.
30-
//
31-
// Full integration test would require a DB. Unit test verifies the
32-
// metadata extraction logic by checking that Provision returns nil
33-
// for tenants without registration metadata.
34-
//
35-
// This is tested indirectly through the provisioning worker integration tests.
36-
t.Log("self-registered admin hook with no metadata is a no-op - tested via integration")
25+
func TestNewSelfRegisteredAdminHook_NilTenantRepo(t *testing.T) {
26+
_, err := NewSelfRegisteredAdminHook(&mockIdentityRepo{}, nil, slog.Default())
27+
assert.ErrorIs(t, err, ErrNilTenantRepo)
3728
}
3829

39-
func TestMetadataKeyConstants(t *testing.T) {
40-
// Verify the metadata keys match between the registration handler and hook.
41-
// These must stay in sync.
42-
assert.Equal(t, "_registration_email", metaKeyRegistrationEmail)
43-
assert.Equal(t, "_registration_password_hash", metaKeyRegistrationPasswordHash)
30+
func TestMetadataKeyConstants_InSyncWithGateway(t *testing.T) {
31+
// These constants are duplicated across packages (identity/bootstrap and api-gateway)
32+
// because a circular import prevents direct sharing. This test ensures they stay in sync.
33+
assert.Equal(t, gateway.MetaKeyRegistrationEmail, MetaKeyRegistrationEmail,
34+
"bootstrap.MetaKeyRegistrationEmail must match gateway.MetaKeyRegistrationEmail")
35+
assert.Equal(t, gateway.MetaKeyRegistrationPasswordHash, MetaKeyRegistrationPasswordHash,
36+
"bootstrap.MetaKeyRegistrationPasswordHash must match gateway.MetaKeyRegistrationPasswordHash")
4437
}
4538

4639
func TestSelfRegisteredAdminHook_AsPostProvisioningHook(t *testing.T) {
4740
// Verify the hook function signature is compatible with the provisioning worker.
48-
// We can't create a real hook without DB connections, but we verify the
49-
// function type matches what the worker expects.
5041
var hookFn func(ctx context.Context, tenantID tenant.TenantID) error
5142
_ = hookFn // proves the type signature
5243

53-
// Also verify the Identity creation logic with known constants.
54-
tid, err := tenant.NewTenantID("test_tenant")
55-
require.NoError(t, err)
56-
assert.Equal(t, "test_tenant", tid.String())
57-
5844
// Verify RoleTenantOwner is the correct role for self-registered admins.
5945
assert.Equal(t, domain.Role("TENANT_OWNER"), domain.RoleTenantOwner)
6046
}

0 commit comments

Comments
 (0)