Skip to content

Commit 822dbc6

Browse files
authored
feat: implement platform admin bootstrap for identity service (#1352)
* feat: implement platform admin bootstrap for identity service Creates services/identity/bootstrap package with Run function that provisions the initial platform admin identity in the meridian_master tenant on first boot using PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD environment variables. The function is idempotent and assigns platform-admin, super-admin, and tenant-owner roles. * fix: make platform admin bootstrap atomic with role reconciliation Wrap identity creation and role assignment saves in a single GORM transaction to prevent partial bootstrap state. Add reconciliation logic that detects and creates any missing roles for existing admins. * fix: make platform admin bootstrap atomic and add role reconciliation - Run now accepts *gorm.DB to enable a single transaction wrapping both identity creation and all role assignments, preventing partial state where an admin exists without all required roles - Add reconcileRoles: when an existing admin is found, fetch active roles and create any missing ones in a separate atomic transaction - Add TestBootstrapPlatformAdmin_ReconcilesMissingRoles integration test verifying that revoked roles are re-created on next boot * fix: make platform admin bootstrap atomic via repository methods Add SaveIdentityWithRoles and SaveRoleAssignments to domain.Repository and implement them in the persistence layer using single transactions, following the SaveIdentityWithInvitation pattern. Run now uses SaveIdentityWithRoles (identity + all role assignments in one transaction) and SaveRoleAssignments (reconcile missing roles in one transaction), removing the *gorm.DB dependency from the bootstrap package and eliminating nested transaction complexity. * fix: add nil repository guard to bootstrap * fix: add missing repository methods to test mock * fix: add missing repository methods to connector test mock --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 183f7b4 commit 822dbc6

6 files changed

Lines changed: 498 additions & 0 deletions

File tree

services/identity/adapters/persistence/repository.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,45 @@ func (r *Repository) FindInvitationByTokenHash(ctx context.Context, tokenHash st
314314
return invitation, nil
315315
}
316316

317+
// SaveIdentityWithRoles atomically persists an identity and its role assignments
318+
// within a single transaction.
319+
func (r *Repository) SaveIdentityWithRoles(ctx context.Context, identity *domain.Identity, roles []*domain.RoleAssignment) error {
320+
identEntity := ToEntity(identity)
321+
322+
return r.withTenantTransaction(ctx, func(tx *gorm.DB) error {
323+
// Insert identity (bootstrap always creates a new one).
324+
if err := tx.Create(identEntity).Error; err != nil {
325+
if isDuplicateKeyError(err) {
326+
return domain.ErrEmailAlreadyExists
327+
}
328+
return err
329+
}
330+
331+
// Insert all role assignments.
332+
for _, ra := range roles {
333+
entity := toRoleAssignmentEntity(ra)
334+
if err := tx.Create(entity).Error; err != nil {
335+
return err
336+
}
337+
}
338+
return nil
339+
})
340+
}
341+
342+
// SaveRoleAssignments atomically persists multiple role assignments within a
343+
// single transaction.
344+
func (r *Repository) SaveRoleAssignments(ctx context.Context, assignments []*domain.RoleAssignment) error {
345+
return r.withTenantTransaction(ctx, func(tx *gorm.DB) error {
346+
for _, ra := range assignments {
347+
entity := toRoleAssignmentEntity(ra)
348+
if err := tx.Create(entity).Error; err != nil {
349+
return err
350+
}
351+
}
352+
return nil
353+
})
354+
}
355+
317356
// ErrVersionConflict is returned when an optimistic locking conflict is detected.
318357
var ErrVersionConflict = domain.ErrVersionConflict
319358

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Package bootstrap provides the platform admin bootstrap process for the identity service.
2+
//
3+
// On first boot, it creates the initial platform admin identity in the meridian_master
4+
// tenant using credentials from environment variables. The process is idempotent —
5+
// safe to call on every boot.
6+
package bootstrap
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"log/slog"
13+
"os"
14+
"time"
15+
16+
"github.com/google/uuid"
17+
"github.com/meridianhub/meridian/services/identity/domain"
18+
"github.com/meridianhub/meridian/shared/pkg/credentials"
19+
"github.com/meridianhub/meridian/shared/platform/auth"
20+
"github.com/meridianhub/meridian/shared/platform/tenant"
21+
)
22+
23+
// MasterTenantID is the well-known tenant ID for the master/platform tenant.
24+
const MasterTenantID = "meridian_master"
25+
26+
// ErrNilRepository is returned when a nil repository is passed to Run.
27+
var ErrNilRepository = errors.New("bootstrap: repository must not be nil")
28+
29+
// platformAdminRoles are the roles assigned to the bootstrapped platform admin.
30+
var platformAdminRoles = []auth.Role{
31+
auth.RolePlatformAdmin,
32+
auth.RoleSuperAdmin,
33+
auth.RoleTenantOwner,
34+
}
35+
36+
// Run creates the initial platform admin identity in the meridian_master tenant
37+
// from environment variables on first boot.
38+
//
39+
// Required environment variables:
40+
// - PLATFORM_ADMIN_EMAIL: Email address for the platform admin
41+
// - PLATFORM_ADMIN_PASSWORD: Initial password for the platform admin
42+
//
43+
// If either variable is empty, the function logs an info message and returns nil.
44+
// The function is idempotent:
45+
// - If an admin already exists, any missing roles are reconciled atomically.
46+
// - Identity creation and all role assignments are committed in a single transaction.
47+
func Run(ctx context.Context, repo domain.Repository) error {
48+
if repo == nil {
49+
return ErrNilRepository
50+
}
51+
52+
email := os.Getenv("PLATFORM_ADMIN_EMAIL")
53+
password := os.Getenv("PLATFORM_ADMIN_PASSWORD")
54+
55+
if email == "" || password == "" {
56+
slog.InfoContext(ctx, "platform admin bootstrap skipped: PLATFORM_ADMIN_EMAIL or PLATFORM_ADMIN_PASSWORD not set")
57+
return nil
58+
}
59+
60+
masterTenantID, err := tenant.NewTenantID(MasterTenantID)
61+
if err != nil {
62+
return fmt.Errorf("invalid master tenant ID: %w", err)
63+
}
64+
masterCtx := tenant.WithTenant(ctx, masterTenantID)
65+
66+
// Check if an admin already exists.
67+
existing, err := repo.FindByEmail(masterCtx, email)
68+
if err != nil && !errors.Is(err, domain.ErrIdentityNotFound) {
69+
return fmt.Errorf("checking for existing platform admin: %w", err)
70+
}
71+
72+
if existing != nil {
73+
// Admin already exists — reconcile any missing roles atomically.
74+
return reconcileRoles(masterCtx, repo, existing)
75+
}
76+
77+
// Hash the password before opening the transaction.
78+
hash, err := credentials.HashPassword(password)
79+
if err != nil {
80+
return fmt.Errorf("hashing platform admin password: %w", err)
81+
}
82+
83+
// Build domain objects before the transaction.
84+
identity, err := domain.NewIdentity(email)
85+
if err != nil {
86+
return fmt.Errorf("creating platform admin identity: %w", err)
87+
}
88+
if err := identity.SetPassword(hash); err != nil {
89+
return fmt.Errorf("setting platform admin password: %w", err)
90+
}
91+
if err := identity.Activate(); err != nil {
92+
return fmt.Errorf("activating platform admin identity: %w", err)
93+
}
94+
95+
roleAssignments := buildRoleAssignments(identity.ID(), platformAdminRoles)
96+
97+
// Persist identity and all role assignments in a single transaction.
98+
if err := repo.SaveIdentityWithRoles(masterCtx, identity, roleAssignments); err != nil {
99+
return fmt.Errorf("bootstrapping platform admin: %w", err)
100+
}
101+
102+
slog.InfoContext(masterCtx, "platform admin bootstrapped successfully",
103+
"email", email,
104+
"roles", len(platformAdminRoles))
105+
return nil
106+
}
107+
108+
// reconcileRoles ensures the existing platform admin has all required roles,
109+
// creating any that are missing in a single atomic transaction.
110+
func reconcileRoles(ctx context.Context, repo domain.Repository, identity *domain.Identity) error {
111+
existing, err := repo.FindRoleAssignments(ctx, identity.ID())
112+
if err != nil {
113+
return fmt.Errorf("fetching existing role assignments: %w", err)
114+
}
115+
116+
// Build a set of active roles already assigned.
117+
assigned := make(map[string]bool, len(existing))
118+
for _, ra := range existing {
119+
if ra.IsActive() {
120+
assigned[string(ra.Role())] = true
121+
}
122+
}
123+
124+
// Determine which roles are missing.
125+
var missing []auth.Role
126+
for _, role := range platformAdminRoles {
127+
if !assigned[role.String()] {
128+
missing = append(missing, role)
129+
}
130+
}
131+
132+
if len(missing) == 0 {
133+
slog.InfoContext(ctx, "platform admin already exists with all required roles, skipping bootstrap",
134+
"email", identity.Email())
135+
return nil
136+
}
137+
138+
slog.InfoContext(ctx, "platform admin exists but is missing roles, reconciling",
139+
"email", identity.Email(),
140+
"missing_roles", len(missing))
141+
142+
roleAssignments := buildRoleAssignments(identity.ID(), missing)
143+
144+
// Persist all missing role assignments in a single transaction.
145+
if err := repo.SaveRoleAssignments(ctx, roleAssignments); err != nil {
146+
return fmt.Errorf("reconciling platform admin roles: %w", err)
147+
}
148+
return nil
149+
}
150+
151+
// buildRoleAssignments constructs RoleAssignment domain objects for each role.
152+
// Bootstrap is a trusted system operation; ReconstructRoleAssignment is used to
153+
// bypass the privilege hierarchy check that would otherwise require a granting identity.
154+
func buildRoleAssignments(identityID uuid.UUID, roles []auth.Role) []*domain.RoleAssignment {
155+
now := time.Now()
156+
assignments := make([]*domain.RoleAssignment, 0, len(roles))
157+
for _, role := range roles {
158+
ra := domain.ReconstructRoleAssignment(
159+
uuid.New(),
160+
identityID,
161+
identityID, // self-granted by the system identity
162+
domain.Role(role.String()),
163+
nil,
164+
nil,
165+
nil,
166+
now,
167+
now,
168+
)
169+
assignments = append(assignments, ra)
170+
}
171+
return assignments
172+
}

0 commit comments

Comments
 (0)