Remove platform tenancy from SXCHNGE customer BFF (#34)

This commit is contained in:
Stephan D committed 2026-09-12 14:00:14 +02:00
1 parent 7062672a87
commit 981e84a4b4
244 files changed
+651 -17130

No files matched your search

@@ -4,19 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"unicode"
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -26,18 +24,19 @@ import (
)
type service struct {
logger mlogger.Logger
accountDB account.DB
orgDB organization.DB
enforcer auth.Enforcer
roleManager management.Role
config *middleware.PasswordConfig
logger mlogger.Logger
accountDB account.DB
config *middleware.PasswordConfig
policyDB policy.DB
vdb verification.DB
vdb verification.DB
sessions refreshtokens.DB
transactions transaction.Factory
}
func validateUserRequest(u *model.Account) error {
if u == nil {
return merrors.InvalidArgument("account is required")
}
if u.Name == "" {
return merrors.InvalidArgument("Name must not be empty")
}
@@ -99,17 +98,17 @@ func (s *service) ValidatePassword(
func (s *service) ValidateAccount(acct *model.Account) error {
if err := validateUserRequest(acct); err != nil {
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Invalid signup account received", zap.Error(err))
return err
}
if err := s.ValidatePassword(acct.Password, nil); err != nil {
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct))
return err
}
if err := acct.HashPassword(); err != nil {
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct))
return err
}
@@ -118,45 +117,52 @@ func (s *service) ValidateAccount(acct *model.Account) error {
func (s *service) CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (string, error) {
if org == nil {
return "", merrors.InvalidArgument("Organization must not be nil")
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
if acct == nil || len(acct.Login) == 0 {
return "", merrors.InvalidArgument("Account must have a non-empty login")
if !acct.ID.IsZero() {
return "", merrors.InvalidArgument("new account must not have an ID")
}
if roleDescID == bson.NilObjectID {
return "", merrors.InvalidArgument("Role description must be provided")
candidate := acct.Copy()
if err := s.ValidateAccount(candidate); err != nil {
return "", err
}
// 1) Create the account
acct.Status = model.AccountPendingVerification
if err := s.accountDB.Create(ctx, acct); err != nil {
candidate.Status = model.AccountPendingVerification
// The customer and activation token either both commit or neither does.
var saved *model.Account
result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
saved = candidate.Copy()
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
s.logger.Info("Customer login already exists")
} else {
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
s.logger.Warn("Customer creation failed", zap.Error(err))
}
return "", err
}
// 2) Add to organization
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
return "", err
token, ok := result.(string)
if !ok {
return "", merrors.Internal("unexpected account creation result")
}
// 3) Issue verification token
return s.VerifyAccount(ctx, acct)
*acct = *saved
s.logger.Info("Customer created pending verification", mzap.AccRef(acct.ID))
return token, nil
}
func (s *service) VerifyAccount(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
token, err := s.vdb.Create(
ctx,
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
@@ -173,62 +179,22 @@ func (s *service) VerifyAccount(
func (s *service) DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
// Check if this is the only member in the organization
if len(org.Members) <= 1 {
s.logger.Warn("Cannot delete account - it's the only member in the organization",
mzap.AccRef(accountRef), mzap.StorableRef(org))
return merrors.InvalidArgument("Cannot delete the only member of an organization")
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is required")
}
// 1) Remove from organization
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
return err
}
// 2) Delete the account document
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
return nil
}
func (s *service) RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
_, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
if err := s.sessions.RevokeAll(tx, accountRef, ""); err != nil {
return nil, err
}
return nil, s.accountDB.Delete(tx, accountRef)
})
if err != nil {
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
mzap.AccRef(accountRef))
s.logger.Warn("Customer deletion failed", zap.Error(err), mzap.AccRef(accountRef))
return err
}
for _, role := range roles {
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err),
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
return err
}
}
for i, member := range org.Members {
if member == accountRef {
// Remove the member by slicing it out
org.Members = append(org.Members[:i], org.Members[i+1:]...)
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
return err
}
break
}
}
s.logger.Info("Customer deleted and sessions revoked", mzap.AccRef(accountRef))
return nil
}
@@ -236,6 +202,9 @@ func (s *service) ResetPassword(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
@@ -248,6 +217,9 @@ func (s *service) UpdateLogin(
acct *model.Account,
newLogin string,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
@@ -255,196 +227,22 @@ func (s *service) UpdateLogin(
)
}
func (s *service) JoinOrganization(
ctx context.Context,
org *model.Organization,
account *model.Account,
roleDescID bson.ObjectID,
) error {
// Membership is decided by the database, not the in-memory copy: this method
// runs inside provisioning transactions whose retries replay the callback,
// and a stale in-memory list would silently skip the role assignment.
member, err := s.orgDB.IsMember(ctx, org.ID, account.ID)
if err != nil {
return err
}
if member {
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
return nil
}
role := &model.Role{
DescriptionRef: roleDescID,
OrganizationRef: org.ID,
AccountRef: account.ID,
}
if err := s.roleManager.Assign(ctx, role); err != nil {
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
return err
}
// A point patch on purpose: replacing the whole document would clobber
// concurrent writes, most notably the provisioning lease that guards
// finalization against attempts whose lease was taken over.
if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil {
s.logger.Warn("Failed to update organization members list",
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return err
}
if !slices.Contains(org.Members, account.ID) {
org.Members = append(org.Members, account.ID)
}
return nil
}
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
roles, err := s.roleManager.List(ctx, orgRef)
if err != nil {
return nil, err
}
roleIDs := make([]bson.ObjectID, 0, len(roles))
for _, role := range roles {
roleIDs = append(roleIDs, role.ID)
}
return roleIDs, nil
}, s.roleManager.Delete)
}
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
policies, err := s.policyDB.All(ctx, orgRef)
if err != nil {
return nil, err
}
policyIDs := make([]bson.ObjectID, 0, len(policies))
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
return policyIDs, nil
}, s.policyDB.Delete)
}
func (s *service) deleteOrganizationEntities(
ctx context.Context,
orgRef bson.ObjectID,
entityName string,
entityLabel string,
entityRefField string,
load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error),
deleteFn func(context.Context, bson.ObjectID) error,
) error {
s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef))
entityIDs, err := load(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef))
return err
}
for _, entityID := range entityIDs {
if err := deleteFn(ctx, entityID); err != nil {
s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID))
return err
}
}
s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef))
return nil
}
func (s *service) DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error {
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
// Use transaction to ensure atomicity
// 8. Delete all roles and role descriptions in the organization
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
return err
}
// 9. Delete all policies in the organization
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
return err
}
// 10. Finally, delete the organization itself
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return err
}
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
return nil
}
func (s *service) DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
s.logger.Info("Starting complete deletion (organization + account)",
mzap.StorableRef(org), mzap.AccRef(accountRef))
// 1. First delete the organization and all its data
if err := s.DeleteOrganization(ctx, org); err != nil {
return err
}
// 2. Then delete the account
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
return nil
}
// NewAccountService wires in your logger plus the three dependencies.
func NewAccountService(
l mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
ra management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (*service, error) {
logger := l.Named("account_service")
if config == nil {
return nil, merrors.Internal("Invalid account service configuration provides")
return nil, merrors.Internal("password configuration is required")
}
res := &service{
logger: logger,
enforcer: enforcer,
roleManager: ra,
config: config,
}
deps, err := edgestorage.ResolveDependencies(dbf)
stores, err := authstorage.OpenStores(logger, dbf, storageConfig)
if err != nil {
return nil, err
}
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
logger.Error("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil {
logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
// Initialize database dependencies for cascade deletion
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil {
logger.Error("Failed to create verification database", zap.Error(err))
return nil, err
}
return res, nil
return &service{logger: logger, config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
}