package externaladmin import ( "context" "database/sql" "errors" "sort" "strings" "time" "hyapp-admin-server/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( maxHierarchyAccounts = 5000 maxParentCandidates = 500 // user-service bounds the trusted owner IN-list at 1000. Enforce the same // contract before issuing any team request instead of letting an oversized // hierarchy fail differently across list, invitation and mutation routes. maxExternalScopeOwners = 1000 ) type portalScope struct { RegionID int64 OwnerUserIDs []int64 ExternalSuperAdminAccounts []model.ExternalAdminAccount } func normalizeIdentity(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", model.ExternalAdminIdentityCountryManager: // Empty is accepted only for rolling-compatible create callers and historical // rows; the persisted/current wire value is always the explicit default. return model.ExternalAdminIdentityCountryManager case model.ExternalAdminIdentityLocal: return model.ExternalAdminIdentityLocal case model.ExternalAdminIdentityExternalSuperAdmin: return model.ExternalAdminIdentityExternalSuperAdmin default: return "" } } func requiredParentIdentity(identity string) (string, bool) { switch normalizeIdentity(identity) { case model.ExternalAdminIdentityExternalSuperAdmin: return model.ExternalAdminIdentityCountryManager, true case model.ExternalAdminIdentityCountryManager: return model.ExternalAdminIdentityLocal, false default: return "", false } } // activeUserRegion resolves the current canonical region instead of copying it into // the portal account. Joining active regions makes deleted/disabled mappings fail // closed while retaining an indexed users(app_code,user_id) point lookup. func (s *Service) activeUserRegion(ctx context.Context, appCode string, userID int64) (int64, error) { if s.userDB == nil || userID <= 0 { return 0, ErrInvalidInput } var regionID int64 err := s.userDB.QueryRowContext(ctx, ` SELECT u.region_id FROM users u JOIN regions rg ON rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' WHERE u.app_code = ? AND u.user_id = ? AND u.status = 'active' AND COALESCE(u.region_id, 0) > 0 LIMIT 1`, normalizeAppCode(appCode), userID).Scan(®ionID) if errors.Is(err, sql.ErrNoRows) { return 0, nil } return regionID, err } func (s *Service) activeUserRegions(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) { result := make(map[int64]int64, len(userIDs)) uniqueIDs := uniquePositiveInt64s(userIDs) if len(uniqueIDs) == 0 { return result, nil } if s.userDB == nil { return nil, errors.New("user mysql is not configured") } placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",") args := make([]any, 0, len(uniqueIDs)+1) args = append(args, normalizeAppCode(appCode)) for _, userID := range uniqueIDs { args = append(args, userID) } rows, err := s.userDB.QueryContext(ctx, ` SELECT u.user_id, u.region_id FROM users u JOIN regions rg ON rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' WHERE u.app_code = ? AND u.status = 'active' AND COALESCE(u.region_id, 0) > 0 AND u.user_id IN (`+placeholders+`)`, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var userID int64 var regionID int64 if err := rows.Scan(&userID, ®ionID); err != nil { return nil, err } result[userID] = regionID } return result, rows.Err() } // resolvePortalScope rebuilds the hierarchy on every authenticated request. Status, // parent changes and region changes therefore take effect without trusting stale // browser claims or persisting duplicated owner-service state in the admin database. func (s *Service) resolvePortalScope(ctx context.Context, account model.ExternalAdminAccount) (portalScope, error) { scope := portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}} account.IdentityType = normalizeIdentity(account.IdentityType) if account.IdentityType == "" { return scope, ErrInvalidHierarchy } regionID, err := s.activeUserRegion(ctx, account.AppCode, account.LinkedAppUserID) if err != nil { return scope, err } scope.RegionID = regionID if regionID == 0 { return scope, nil } valid, err := s.accountParentChainIsActive(ctx, account, regionID) if err != nil { return scope, err } if !valid { return scope, nil } scope.OwnerUserIDs = append(scope.OwnerUserIDs, account.LinkedAppUserID) descendants, superAdmins, err := s.loadPortalDescendants(ctx, account) if err != nil { return scope, err } if len(descendants) == 0 { return scope, nil } userIDs := make([]int64, 0, len(descendants)) for _, descendant := range descendants { userIDs = append(userIDs, descendant.LinkedAppUserID) } regions, err := s.activeUserRegions(ctx, account.AppCode, userIDs) if err != nil { return scope, err } validAccounts := make(map[uint64]struct{}, len(descendants)) switch account.IdentityType { case model.ExternalAdminIdentityCountryManager: for _, superAdmin := range superAdmins { if superAdmin.ParentAccountID == nil || *superAdmin.ParentAccountID != account.ID || regions[superAdmin.LinkedAppUserID] != regionID { continue } validAccounts[superAdmin.ID] = struct{}{} } case model.ExternalAdminIdentityLocal: validManagers := make(map[uint64]struct{}, len(descendants)) for _, descendant := range descendants { if normalizeIdentity(descendant.IdentityType) != model.ExternalAdminIdentityCountryManager || descendant.ParentAccountID == nil || *descendant.ParentAccountID != account.ID || regions[descendant.LinkedAppUserID] != regionID { continue } validManagers[descendant.ID] = struct{}{} validAccounts[descendant.ID] = struct{}{} } // A Local must not inherit a SuperAdmin through a Manager that moved to // another region. Filter by the already validated parent-account set before // adding either business scope roots or the SuperAdmin menu projection. for _, superAdmin := range superAdmins { if superAdmin.ParentAccountID == nil || regions[superAdmin.LinkedAppUserID] != regionID { continue } if _, parentValid := validManagers[*superAdmin.ParentAccountID]; !parentValid { continue } validAccounts[superAdmin.ID] = struct{}{} } } for _, descendant := range descendants { if _, valid := validAccounts[descendant.ID]; valid { scope.OwnerUserIDs = append(scope.OwnerUserIDs, descendant.LinkedAppUserID) } } for _, superAdmin := range superAdmins { if _, valid := validAccounts[superAdmin.ID]; valid { scope.ExternalSuperAdminAccounts = append(scope.ExternalSuperAdminAccounts, superAdmin) } } scope.OwnerUserIDs = uniquePositiveInt64s(scope.OwnerUserIDs) if len(scope.OwnerUserIDs) > maxExternalScopeOwners { return portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}}, ErrInvalidHierarchy } return scope, nil } func (s *Service) accountParentChainIsActive(ctx context.Context, account model.ExternalAdminAccount, regionID int64) (bool, error) { return s.accountParentChainIsActiveWithDB(ctx, s.db, account, regionID, false) } // accountParentChainIsActiveWithDB validates the complete bounded portal chain // using the caller's admin transaction when hierarchy rows are being created or // rebound. The identity order has a fixed maximum depth // SuperAdmin -> country manager -> Local, so this never becomes an unbounded // recursive query even if a manually corrupted row points back to a child. func (s *Service) accountParentChainIsActiveWithDB( ctx context.Context, db *gorm.DB, account model.ExternalAdminAccount, regionID int64, lockAncestors bool, ) (bool, error) { identity := normalizeIdentity(account.IdentityType) if identity == model.ExternalAdminIdentityLocal { return account.ParentAccountID == nil, nil } if account.ParentAccountID == nil { return identity == model.ExternalAdminIdentityCountryManager, nil } expectedIdentity, _ := requiredParentIdentity(identity) if expectedIdentity == "" { return false, nil } var parent model.ExternalAdminAccount query := db.WithContext(ctx) if lockAncestors { // Parent mutation and account creation already run in a transaction. Share // locking every ancestor prevents a concurrent disable/rebind from making a // child valid only for the instant between validation and commit. query = query.Clauses(clause.Locking{Strength: "SHARE"}) } err := query.Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?", *account.ParentAccountID, account.AppCode, expectedIdentity, model.ExternalAdminStatusActive). Take(&parent).Error if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } if err != nil { return false, err } parentRegionID, err := s.activeUserRegion(ctx, account.AppCode, parent.LinkedAppUserID) if err != nil { return false, err } if parentRegionID != regionID { return false, nil } // A SuperAdmin must not stay authorized through a Manager whose optional // Local binding has become invalid. Identity transitions strictly decrease // from SuperAdmin -> Manager -> Local, so this checks the complete bounded // chain without introducing an unbounded recursive authorization query. return s.accountParentChainIsActiveWithDB(ctx, db, parent, regionID, lockAncestors) } func (s *Service) loadPortalDescendants(ctx context.Context, account model.ExternalAdminAccount) ([]model.ExternalAdminAccount, []model.ExternalAdminAccount, error) { switch normalizeIdentity(account.IdentityType) { case model.ExternalAdminIdentityExternalSuperAdmin: return []model.ExternalAdminAccount{}, []model.ExternalAdminAccount{}, nil case model.ExternalAdminIdentityCountryManager: superAdmins, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityExternalSuperAdmin) return superAdmins, superAdmins, err case model.ExternalAdminIdentityLocal: managers, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityCountryManager) if err != nil { return nil, nil, err } managerIDs := make([]uint64, 0, len(managers)) for _, manager := range managers { managerIDs = append(managerIDs, manager.ID) } superAdmins, err := s.queryChildren(ctx, account.AppCode, managerIDs, model.ExternalAdminIdentityExternalSuperAdmin) if err != nil { return nil, nil, err } descendants := make([]model.ExternalAdminAccount, 0, len(managers)+len(superAdmins)) descendants = append(descendants, managers...) descendants = append(descendants, superAdmins...) return descendants, superAdmins, nil default: return nil, nil, ErrInvalidHierarchy } } func (s *Service) queryChildren(ctx context.Context, appCode string, parentIDs []uint64, identity string) ([]model.ExternalAdminAccount, error) { if len(parentIDs) == 0 { return []model.ExternalAdminAccount{}, nil } items := []model.ExternalAdminAccount{} err := s.db.WithContext(ctx). Where("app_code = ? AND parent_account_id IN ? AND identity_type = ? AND status = ?", normalizeAppCode(appCode), parentIDs, identity, model.ExternalAdminStatusActive). Order("id ASC"). Limit(maxHierarchyAccounts + 1). Find(&items).Error if err != nil { return nil, err } // Silent truncation would broaden/lose team authorization unpredictably. Fail the // request instead, so operators must split an oversized hierarchy deliberately. if len(items) > maxHierarchyAccounts { return nil, ErrInvalidHierarchy } return items, nil } func (s *Service) validateParentForChild(ctx context.Context, tx *gorm.DB, appCode string, identity string, parentID *uint64, childRegionID int64) (*model.ExternalAdminAccount, error) { identity = normalizeIdentity(identity) expectedIdentity, parentRequired := requiredParentIdentity(identity) if identity == "" || childRegionID <= 0 { return nil, ErrInvalidHierarchy } if parentID == nil { if parentRequired { return nil, ErrInvalidHierarchy } return nil, nil } if *parentID == 0 || expectedIdentity == "" { return nil, ErrInvalidHierarchy } var parent model.ExternalAdminAccount err := tx.Clauses(clause.Locking{Strength: "SHARE"}). Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?", *parentID, normalizeAppCode(appCode), expectedIdentity, model.ExternalAdminStatusActive). Take(&parent).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrInvalidHierarchy } if err != nil { return nil, err } parentRegionID, err := s.activeUserRegion(ctx, appCode, parent.LinkedAppUserID) if err != nil { return nil, err } if parentRegionID == 0 || parentRegionID != childRegionID { return nil, ErrInvalidHierarchy } // A country manager with a stale/disabled/cross-region Local parent is not a // valid SuperAdmin parent. Likewise, a manually corrupted Local row with its // own parent cannot authorize a manager. Validate the full chain inside the // same transaction instead of creating a child that immediately fails closed. valid, err := s.accountParentChainIsActiveWithDB(ctx, tx, parent, childRegionID, true) if err != nil { return nil, err } if !valid { return nil, ErrInvalidHierarchy } return &parent, nil } func (s *Service) ListParentCandidates(ctx context.Context, input ParentCandidateInput) (ParentCandidateResult, error) { if s.db == nil { return ParentCandidateResult{}, errors.New("admin mysql is not configured") } input.AppCode = normalizeAppCode(input.AppCode) identity := normalizeIdentity(input.IdentityType) if input.AppCode == "" || (strings.TrimSpace(input.IdentityType) != "" && identity == "") { return ParentCandidateResult{}, ErrInvalidInput } var child model.ExternalAdminAccount var regionID int64 if input.ChildAccountID > 0 { if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", input.ChildAccountID, input.AppCode).Take(&child).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ParentCandidateResult{}, ErrAccountNotFound } return ParentCandidateResult{}, err } identity = normalizeIdentity(child.IdentityType) if identity == "" { return ParentCandidateResult{}, ErrInvalidHierarchy } var err error regionID, err = s.activeUserRegion(ctx, input.AppCode, child.LinkedAppUserID) if err != nil { return ParentCandidateResult{}, err } } else { target, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID) if err != nil { return ParentCandidateResult{}, err } regionID, err = s.activeUserRegion(ctx, input.AppCode, target.UserID) if err != nil { return ParentCandidateResult{}, err } } expectedIdentity, _ := requiredParentIdentity(identity) if expectedIdentity == "" || regionID <= 0 { return ParentCandidateResult{Items: []AccountReference{}}, nil } query := s.db.WithContext(ctx). Where("app_code = ? AND identity_type = ? AND status = ?", input.AppCode, expectedIdentity, model.ExternalAdminStatusActive) if keyword := strings.TrimSpace(input.Keyword); keyword != "" { query = query.Where("username LIKE ?", "%"+keyword+"%") } candidates := []model.ExternalAdminAccount{} if err := query.Order("username ASC, id ASC").Limit(maxParentCandidates + 1).Find(&candidates).Error; err != nil { return ParentCandidateResult{}, err } truncated := len(candidates) > maxParentCandidates if truncated { candidates = candidates[:maxParentCandidates] } userIDs := make([]int64, 0, len(candidates)) for _, candidate := range candidates { userIDs = append(userIDs, candidate.LinkedAppUserID) } regions, err := s.activeUserRegions(ctx, input.AppCode, userIDs) if err != nil { return ParentCandidateResult{}, err } users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs) if err != nil { return ParentCandidateResult{}, err } items := make([]AccountReference, 0, len(candidates)) for _, candidate := range candidates { if regions[candidate.LinkedAppUserID] != regionID { continue } // Candidate visibility is authorization-sensitive: a country manager whose // optional Local chain has drifted must not be selectable for a new // SuperAdmin merely because the manager row itself is still active. valid, err := s.accountParentChainIsActive(ctx, candidate, regionID) if err != nil { return ParentCandidateResult{}, err } if !valid { continue } items = append(items, accountReference(candidate, users[candidate.LinkedAppUserID])) } return ParentCandidateResult{Items: items, Truncated: truncated}, nil } func (s *Service) UpdateAccountParent(ctx context.Context, appCode string, id uint64, input UpdateParentInput) (ParentUpdateResult, error) { appCode = normalizeAppCode(appCode) if s.db == nil { return ParentUpdateResult{}, errors.New("admin mysql is not configured") } if appCode == "" || id == 0 || input.ExpectedRevision == 0 { return ParentUpdateResult{}, ErrInvalidInput } result := ParentUpdateResult{} var account model.ExternalAdminAccount err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil { return err } if account.PermissionRevision != input.ExpectedRevision { return ErrParentConflict } regionID, err := s.activeUserRegion(ctx, appCode, account.LinkedAppUserID) if err != nil { return err } if _, err := s.validateParentForChild(ctx, tx, appCode, account.IdentityType, input.ParentAccountID, regionID); err != nil { return err } if equalUint64Pointers(account.ParentAccountID, input.ParentAccountID) { return nil } if err := tx.Model(&account).Updates(map[string]any{ "parent_account_id": input.ParentAccountID, "permission_revision": account.PermissionRevision + 1, }).Error; err != nil { return err } if err := revokeAllSessions(tx, account.ID, "hierarchy_changed", time.Now().UTC().UnixMilli()); err != nil { return err } result.Changed = true result.SessionsRevoked = true return nil }) if errors.Is(err, gorm.ErrRecordNotFound) { return ParentUpdateResult{}, ErrAccountNotFound } if err != nil { return ParentUpdateResult{}, err } account = model.ExternalAdminAccount{} if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil { return ParentUpdateResult{}, err } dto, err := s.accountDTOWithRelations(ctx, account) if err != nil { return ParentUpdateResult{}, err } result.Account = dto return result, nil } func (s *Service) ListExternalSuperAdmins(ctx context.Context, principal SessionPrincipal) (ListResult, error) { if normalizeIdentity(principal.IdentityType) == model.ExternalAdminIdentityExternalSuperAdmin { return ListResult{}, ErrInvalidHierarchy } var account model.ExternalAdminAccount if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).Take(&account).Error; err != nil { return ListResult{}, err } scope, err := s.resolvePortalScope(ctx, account) if err != nil { return ListResult{}, err } users, err := s.loadLinkedUsers(ctx, principal.AppCode, linkedUserIDs(scope.ExternalSuperAdminAccounts)) if err != nil { return ListResult{}, err } items := make([]AccountDTO, 0, len(scope.ExternalSuperAdminAccounts)) for _, item := range scope.ExternalSuperAdminAccounts { items = append(items, accountDTO(item, users[item.LinkedAppUserID])) } return ListResult{Items: items, Page: 1, PageSize: len(items), Total: int64(len(items))}, nil } func (s *Service) accountDTOWithRelations(ctx context.Context, account model.ExternalAdminAccount) (AccountDTO, error) { accountUsers, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID}) if err != nil { return AccountDTO{}, err } var parent *model.ExternalAdminAccount parentUsers := map[int64]LinkedUser{} if account.ParentAccountID != nil { var loaded model.ExternalAdminAccount if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", *account.ParentAccountID, account.AppCode).Take(&loaded).Error; err == nil { parent = &loaded parentUsers, err = s.loadLinkedUsers(ctx, account.AppCode, []int64{loaded.LinkedAppUserID}) if err != nil { return AccountDTO{}, err } } else if !errors.Is(err, gorm.ErrRecordNotFound) { return AccountDTO{}, err } } return accountDTOWithParent(account, accountUsers[account.LinkedAppUserID], parent, parentUsers), nil } func accountReference(account model.ExternalAdminAccount, user LinkedUser) AccountReference { if user.UserID == 0 { user.UserID = account.LinkedAppUserID } return AccountReference{ ID: account.ID, Username: account.Username, IdentityType: normalizeIdentity(account.IdentityType), Status: account.Status, LinkedUser: user, } } func accountDTOWithParent(account model.ExternalAdminAccount, linkedUser LinkedUser, parent *model.ExternalAdminAccount, users map[int64]LinkedUser) AccountDTO { dto := accountDTO(account, linkedUser) if parent != nil { reference := accountReference(*parent, users[parent.LinkedAppUserID]) dto.Parent = &reference } return dto } func linkedUserIDs(accounts []model.ExternalAdminAccount) []int64 { ids := make([]int64, 0, len(accounts)) for _, account := range accounts { ids = append(ids, account.LinkedAppUserID) } return ids } func uniquePositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} result = append(result, value) } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func equalUint64Pointers(left *uint64, right *uint64) bool { if left == nil || right == nil { return left == nil && right == nil } return *left == *right }