Pulse/internal/hosted/provisioner.go
2026-05-04 22:52:00 +01:00

640 lines
17 KiB
Go

package hosted
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
const (
ProvisionStatusCreated = "created"
ProvisionStatusExisting = "existing"
maxHostedOrganizationIDLength = 64
maxHostedSignupEmailLength = 254
maxHostedSignupPasswordLength = 1024
)
type ProvisionStatus string
type OrgPersistence interface {
GetPersistence(orgID string) (*config.ConfigPersistence, error)
SaveOrganization(org *models.Organization) error
LoadOrganization(orgID string) (*models.Organization, error)
ListOrganizations() ([]*models.Organization, error)
}
type AuthProvider interface {
GetManager(orgID string) (AuthManager, error)
}
type AuthManager interface {
UpdateUserRoles(userID string, roles []string) error
}
type orgRollbackDeleter interface {
DeleteOrganization(orgID string) error
}
type authRollbackDeleter interface {
RemoveTenant(orgID string) error
}
type Provisioner struct {
persistence OrgPersistence
authProvider AuthProvider
newOrgID func() string
newUserID func() (string, error)
now func() time.Time
}
type ProvisionRequest struct {
Email string
Password string
OrgName string
}
type HostedSignupRequest struct {
Email string
OrgName string
}
type ProvisionResult struct {
OrgID string
UserID string
OwnerEmail string
Status ProvisionStatus
}
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
type SystemError struct {
Op string
Err error
}
func (e *SystemError) Error() string {
if e.Op == "" {
return fmt.Sprintf("system error: %v", e.Err)
}
return fmt.Sprintf("system error in %s: %v", e.Op, e.Err)
}
func (e *SystemError) Unwrap() error {
return e.Err
}
func IsValidationError(err error) bool {
var target *ValidationError
return errors.As(err, &target)
}
func IsSystemError(err error) bool {
var target *SystemError
return errors.As(err, &target)
}
func NewProvisioner(persistence OrgPersistence, authProvider AuthProvider) *Provisioner {
return &Provisioner{
persistence: persistence,
authProvider: authProvider,
newOrgID: uuid.NewString,
newUserID: registry.GenerateUserID,
now: time.Now,
}
}
func (p *Provisioner) ProvisionTenant(ctx context.Context, req ProvisionRequest) (*ProvisionResult, error) {
if err := p.ensureReady(); err != nil {
return nil, err
}
req = normalizeProvisionRequest(req)
if err := validateProvisionRequest(req); err != nil {
return nil, err
}
if err := contextErr(ctx); err != nil {
return nil, err
}
if existing, err := p.findExistingOrganizationByOwnerEmail(req.Email); err != nil {
return nil, err
} else if existing != nil {
return existing, nil
}
orgID := p.newOrgID()
return p.createOrganization(ctx, orgID, req.Email, req.OrgName)
}
func (p *Provisioner) ProvisionHostedSignup(ctx context.Context, req HostedSignupRequest) (*ProvisionResult, error) {
if err := p.ensureReady(); err != nil {
return nil, err
}
req = normalizeHostedSignupRequest(req)
if err := validateHostedSignupRequest(req); err != nil {
return nil, err
}
if err := contextErr(ctx); err != nil {
return nil, err
}
if existing, err := p.findExistingOrganizationByOwnerEmail(req.Email); err != nil {
return nil, err
} else if existing != nil {
return existing, nil
}
return p.createOrganization(ctx, p.newOrgID(), req.Email, req.OrgName)
}
func (p *Provisioner) findExistingOrganizationByOwnerEmail(email string) (*ProvisionResult, error) {
email = strings.ToLower(strings.TrimSpace(email))
orgs, err := p.persistence.ListOrganizations()
if err != nil {
return nil, &SystemError{Op: "list_organizations", Err: err}
}
for _, org := range orgs {
if org == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(org.OwnerEmail), email) || strings.EqualFold(strings.TrimSpace(org.OwnerUserID), email) {
userID, err := p.ensureExistingOwnerStablePrincipal(org, email)
if err != nil {
return nil, err
}
ownerEmail := strings.ToLower(strings.TrimSpace(org.OwnerEmail))
if ownerEmail == "" {
ownerEmail = email
}
return &ProvisionResult{
OrgID: org.ID,
UserID: userID,
OwnerEmail: ownerEmail,
Status: ProvisionStatusExisting,
}, nil
}
}
return nil, nil
}
func (p *Provisioner) ensureExistingOwnerStablePrincipal(org *models.Organization, email string) (string, error) {
if org == nil {
return "", &SystemError{Op: "canonicalize_existing_owner_identity", Err: errors.New("organization is nil")}
}
userID, _, ok := org.ResolvePrincipalByEmail(email)
userID = strings.TrimSpace(userID)
if ok && userID != "" && !strings.Contains(userID, "@") {
return userID, nil
}
return p.canonicalizeExistingOwnerPrincipal(org, email)
}
func (p *Provisioner) canonicalizeExistingOwnerPrincipal(org *models.Organization, email string) (string, error) {
orgID := strings.TrimSpace(org.ID)
if orgID == "" {
return "", &SystemError{Op: "canonicalize_existing_owner_identity", Err: errors.New("organization id is empty")}
}
userID, err := p.generateOwnerUserID()
if err != nil {
return "", &SystemError{Op: "canonicalize_existing_owner_user_id", Err: err}
}
oldOwnerUserID := org.OwnerUserID
oldOwnerEmail := org.OwnerEmail
oldMembers := append([]models.OrganizationMember(nil), org.Members...)
p.applyExistingOwnerPrincipal(org, userID, email)
if err := p.persistence.SaveOrganization(org); err != nil {
org.OwnerUserID = oldOwnerUserID
org.OwnerEmail = oldOwnerEmail
org.Members = oldMembers
return "", &SystemError{Op: "save_existing_owner_identity", Err: err}
}
if err := p.assignExistingOwnerAdminRole(orgID, userID); err != nil {
org.OwnerUserID = oldOwnerUserID
org.OwnerEmail = oldOwnerEmail
org.Members = oldMembers
if rollbackErr := p.persistence.SaveOrganization(org); rollbackErr != nil {
return "", &SystemError{
Op: "rollback_existing_owner_identity",
Err: fmt.Errorf("%w; rollback failed: %v", err, rollbackErr),
}
}
return "", err
}
return userID, nil
}
func (p *Provisioner) applyExistingOwnerPrincipal(org *models.Organization, userID, email string) {
oldOwnerUserID := strings.TrimSpace(org.OwnerUserID)
email = strings.ToLower(strings.TrimSpace(email))
now := p.now().UTC()
org.OwnerUserID = userID
org.OwnerEmail = email
found := false
for i := range org.Members {
memberUserID := strings.TrimSpace(org.Members[i].UserID)
memberEmail := strings.ToLower(strings.TrimSpace(org.Members[i].Email))
if memberEmail == "" && strings.Contains(memberUserID, "@") {
memberEmail = strings.ToLower(memberUserID)
}
matchesOwner := memberUserID == userID ||
(oldOwnerUserID != "" && strings.EqualFold(memberUserID, oldOwnerUserID)) ||
strings.EqualFold(memberUserID, email) ||
memberEmail == email
if !matchesOwner {
continue
}
org.Members[i].UserID = userID
org.Members[i].Email = email
org.Members[i].Role = models.OrgRoleOwner
if org.Members[i].AddedAt.IsZero() {
org.Members[i].AddedAt = now
}
addedBy := strings.TrimSpace(org.Members[i].AddedBy)
if addedBy == "" || strings.EqualFold(addedBy, oldOwnerUserID) || strings.EqualFold(addedBy, email) {
org.Members[i].AddedBy = userID
}
found = true
}
if !found {
org.Members = append(org.Members, models.OrganizationMember{
UserID: userID,
Email: email,
Role: models.OrgRoleOwner,
AddedAt: now,
AddedBy: userID,
})
}
}
func (p *Provisioner) assignExistingOwnerAdminRole(orgID, userID string) error {
authManager, err := p.authProvider.GetManager(orgID)
if err != nil {
return &SystemError{Op: "get_existing_owner_auth_manager", Err: err}
}
if authManager == nil {
return &SystemError{Op: "get_existing_owner_auth_manager", Err: errors.New("auth manager is nil")}
}
if err := authManager.UpdateUserRoles(userID, []string{auth.RoleAdmin}); err != nil {
return &SystemError{Op: "assign_existing_owner_role", Err: err}
}
return nil
}
func (p *Provisioner) RollbackProvisioning(orgID string) {
if p == nil {
return
}
if remover, ok := p.authProvider.(authRollbackDeleter); ok && remover != nil {
if err := remover.RemoveTenant(orgID); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Warn().
Err(err).
Str("org_id", orgID).
Msg("Hosted tenant rollback: failed to remove auth tenant")
}
}
if deleter, ok := p.persistence.(orgRollbackDeleter); ok && deleter != nil {
p.cleanupOrgDirectory(orgID, "")
return
}
tenantPersistence, err := p.persistence.GetPersistence(orgID)
if err != nil || tenantPersistence == nil {
log.Warn().
Err(err).
Str("org_id", orgID).
Msg("Hosted tenant rollback: failed to resolve tenant persistence")
return
}
p.cleanupOrgDirectory(orgID, tenantPersistence.DataDir())
}
func (p *Provisioner) ensureReady() error {
if p == nil {
return &SystemError{Op: "initialize_provisioner", Err: errors.New("provisioner is nil")}
}
if p.persistence == nil {
return &SystemError{Op: "initialize_provisioner", Err: errors.New("org persistence is nil")}
}
if p.authProvider == nil {
return &SystemError{Op: "initialize_provisioner", Err: errors.New("auth provider is nil")}
}
return nil
}
func (p *Provisioner) createOrganization(ctx context.Context, orgID, ownerEmail, orgName string) (*ProvisionResult, error) {
tenantPersistence, err := p.persistence.GetPersistence(orgID)
if err != nil {
return nil, &SystemError{Op: "initialize_tenant_directory", Err: err}
}
if tenantPersistence == nil {
return nil, &SystemError{Op: "initialize_tenant_directory", Err: errors.New("tenant persistence is nil")}
}
if err := contextErr(ctx); err != nil {
p.RollbackProvisioning(orgID)
return nil, err
}
userID, err := p.generateOwnerUserID()
if err != nil {
p.RollbackProvisioning(orgID)
return nil, &SystemError{Op: "generate_owner_user_id", Err: err}
}
now := p.now().UTC()
ownerEmail = strings.ToLower(strings.TrimSpace(ownerEmail))
org := &models.Organization{
ID: orgID,
DisplayName: orgName,
CreatedAt: now,
OwnerUserID: userID,
OwnerEmail: ownerEmail,
Members: []models.OrganizationMember{
{
UserID: userID,
Email: ownerEmail,
Role: models.OrgRoleOwner,
AddedAt: now,
AddedBy: userID,
},
},
}
if err := p.persistence.SaveOrganization(org); err != nil {
p.RollbackProvisioning(orgID)
return nil, &SystemError{Op: "save_organization", Err: err}
}
if err := contextErr(ctx); err != nil {
p.RollbackProvisioning(orgID)
return nil, err
}
authManager, err := p.authProvider.GetManager(orgID)
if err != nil {
p.RollbackProvisioning(orgID)
return nil, &SystemError{Op: "get_auth_manager", Err: err}
}
if authManager == nil {
p.RollbackProvisioning(orgID)
return nil, &SystemError{Op: "get_auth_manager", Err: errors.New("auth manager is nil")}
}
if err := authManager.UpdateUserRoles(userID, []string{auth.RoleAdmin}); err != nil {
p.RollbackProvisioning(orgID)
return nil, &SystemError{Op: "create_admin_user", Err: err}
}
return &ProvisionResult{
OrgID: orgID,
UserID: userID,
OwnerEmail: ownerEmail,
Status: ProvisionStatusCreated,
}, nil
}
func (p *Provisioner) generateOwnerUserID() (string, error) {
if p == nil || p.newUserID == nil {
return "", errors.New("user id generator is nil")
}
userID, err := p.newUserID()
if err != nil {
return "", err
}
userID = strings.TrimSpace(userID)
if userID == "" {
return "", errors.New("generated user id is empty")
}
if strings.Contains(userID, "@") {
return "", errors.New("generated user id must not be an email")
}
return userID, nil
}
func (p *Provisioner) cleanupOrgDirectory(orgID, dataDir string) {
log.Warn().
Str("org_id", orgID).
Str("data_dir", dataDir).
Msg("Hosted tenant provisioning failed; attempting rollback cleanup")
if !isValidOrganizationID(orgID) || orgID == "default" {
log.Warn().
Str("org_id", orgID).
Msg("Skipping rollback cleanup because organization ID is invalid for deletion")
return
}
if deleter, ok := p.persistence.(orgRollbackDeleter); ok && deleter != nil {
if err := deleter.DeleteOrganization(orgID); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Error().
Err(err).
Str("org_id", orgID).
Msg("Rollback cleanup failed via organization deleter")
return
}
log.Info().
Str("org_id", orgID).
Msg("Rollback cleanup completed via organization deleter")
return
}
if !isSafeTenantDataDir(dataDir, orgID) {
log.Warn().
Str("org_id", orgID).
Str("data_dir", dataDir).
Msg("Skipping rollback cleanup because data directory does not match expected tenant path")
return
}
cleanDataDir := filepath.Clean(dataDir)
if err := os.RemoveAll(cleanDataDir); err != nil {
log.Error().
Err(err).
Str("org_id", orgID).
Str("data_dir", cleanDataDir).
Msg("Rollback cleanup failed")
return
}
log.Info().
Str("org_id", orgID).
Str("data_dir", cleanDataDir).
Msg("Rollback cleanup completed")
}
func isSafeTenantDataDir(dataDir, orgID string) bool {
if dataDir == "" || !isValidOrganizationID(orgID) || orgID == "default" {
return false
}
cleanDataDir := filepath.Clean(dataDir)
if cleanDataDir == "." || cleanDataDir == string(os.PathSeparator) {
return false
}
if filepath.Base(cleanDataDir) != orgID {
return false
}
if filepath.Base(filepath.Dir(cleanDataDir)) != "orgs" {
return false
}
return true
}
func validateProvisionRequest(req ProvisionRequest) error {
if err := validateHostedSignupRequest(HostedSignupRequest{
Email: req.Email,
OrgName: req.OrgName,
}); err != nil {
return err
}
if len(req.Password) < 8 {
return &ValidationError{Field: "password", Message: "password must be at least 8 characters"}
}
if len(req.Password) > maxHostedSignupPasswordLength {
return &ValidationError{Field: "password", Message: "password exceeds maximum length"}
}
return nil
}
func validateHostedSignupRequest(req HostedSignupRequest) error {
if !isValidSignupEmail(req.Email) {
return &ValidationError{Field: "email", Message: "invalid email format"}
}
if !isValidHostedOrgName(req.OrgName) {
return &ValidationError{Field: "org_name", Message: "invalid organization name"}
}
return nil
}
func normalizeProvisionRequest(req ProvisionRequest) ProvisionRequest {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.OrgName = strings.TrimSpace(req.OrgName)
return req
}
func normalizeHostedSignupRequest(req HostedSignupRequest) HostedSignupRequest {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.OrgName = strings.TrimSpace(req.OrgName)
return req
}
func isValidSignupEmail(email string) bool {
if email == "" || len(email) > maxHostedSignupEmailLength || strings.TrimSpace(email) != email {
return false
}
for _, r := range email {
if r < 0x20 || r == 0x7f {
return false
}
}
at := strings.Index(email, "@")
if at <= 0 || at >= len(email)-1 {
return false
}
domain := email[at+1:]
if strings.Contains(domain, "@") {
return false
}
dot := strings.Index(domain, ".")
if dot <= 0 || dot >= len(domain)-1 {
return false
}
return true
}
func isValidHostedOrgName(orgName string) bool {
if len(orgName) < 3 || len(orgName) > maxHostedOrganizationIDLength {
return false
}
return isValidOrganizationID(orgName)
}
func isValidOrganizationID(orgID string) bool {
if orgID == "" || orgID == "." || orgID == ".." {
return false
}
if len(orgID) > maxHostedOrganizationIDLength {
return false
}
if strings.TrimSpace(orgID) != orgID {
return false
}
if strings.ContainsAny(orgID, `/\`) {
return false
}
if filepath.Base(orgID) != orgID {
return false
}
for _, r := range orgID {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func contextErr(ctx context.Context) error {
if ctx == nil {
return nil
}
if err := ctx.Err(); err != nil {
return &SystemError{Op: "context", Err: err}
}
return nil
}
type tenantRBACProvider interface {
GetManager(orgID string) (auth.ExtendedManager, error)
}
type tenantRBACAdapter struct {
provider tenantRBACProvider
}
func NewTenantRBACAuthProvider(provider tenantRBACProvider) AuthProvider {
return &tenantRBACAdapter{provider: provider}
}
func (a *tenantRBACAdapter) GetManager(orgID string) (AuthManager, error) {
if a == nil || a.provider == nil {
return nil, errors.New("tenant RBAC provider is nil")
}
return a.provider.GetManager(orgID)
}
func (a *tenantRBACAdapter) RemoveTenant(orgID string) error {
if a == nil || a.provider == nil {
return errors.New("tenant RBAC provider is nil")
}
remover, ok := a.provider.(authRollbackDeleter)
if !ok || remover == nil {
return nil
}
return remover.RemoveTenant(orgID)
}