package externaladmin import ( "context" "errors" "fmt" "reflect" "strings" "time" "hyapp-admin-server/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) // PermissionCatalogItem is the only assignable permission source for external accounts. // Keeping route permissions, capability aliases and form metadata in one catalog prevents // a client from persisting arbitrary main-admin permissions into an external session. type PermissionCatalogItem struct { Code string `json:"code"` Label string `json:"label"` Group string `json:"group"` Dependencies []string `json:"dependencies"` Capabilities []string `json:"capabilities"` DefaultGranted bool `json:"defaultGranted"` } type AccountPermissionView struct { AccountID uint64 `json:"accountId,string"` Permissions []string `json:"permissions"` Revision uint64 `json:"revision"` } type UpdatePermissionsInput struct { Permissions []string ExpectedRevision uint64 } type PermissionUpdateResult struct { Account AccountDTO PreviousPermissions []string Changed bool SessionsRevoked bool } type PermissionValidationError struct { Permission string Dependency string } func (err *PermissionValidationError) Error() string { if err.Dependency != "" { return fmt.Sprintf("%s: %s requires %s", ErrInvalidPermissions, err.Permission, err.Dependency) } return fmt.Sprintf("%s: unsupported permission %s", ErrInvalidPermissions, err.Permission) } func (err *PermissionValidationError) Unwrap() error { return ErrInvalidPermissions } var permissionCatalog = []PermissionCatalogItem{ {Code: "user:list", Label: "用户列表", Group: "用户管理", Capabilities: []string{"user:list"}, DefaultGranted: true}, {Code: "user:update", Label: "用户信息编辑", Group: "用户管理", Capabilities: []string{"user:update"}, DefaultGranted: true}, {Code: "user-ban:list", Label: "账号封禁列表", Group: "用户管理", Capabilities: []string{"user-ban:list"}, DefaultGranted: true}, {Code: "user:ban", Label: "执行封禁", Group: "用户管理", Capabilities: []string{"user:ban"}, DefaultGranted: true}, {Code: "user:unban", Label: "解除封禁", Group: "用户管理", Capabilities: []string{"user:unban"}, DefaultGranted: true}, {Code: "host:list", Label: "主播列表", Group: "组织管理", Capabilities: []string{"host:list"}, DefaultGranted: true}, {Code: "agency:list", Label: "公会列表", Group: "组织管理", Capabilities: []string{"agency:list"}, DefaultGranted: true}, {Code: "bd:list", Label: "BD 列表", Group: "组织管理", Capabilities: []string{"bd:list"}, DefaultGranted: true}, {Code: "bd-manager:list", Label: "BD Manager 列表", Group: "组织管理", Capabilities: []string{"bd-manager:list"}, DefaultGranted: true}, {Code: "super-admin:list", Label: "Super Admin 列表", Group: "组织管理", Capabilities: []string{"super-admin:list"}, DefaultGranted: true}, {Code: "team:view", Label: "我的团队", Group: "组织管理", Capabilities: []string{"team:view"}, DefaultGranted: true}, {Code: "room:list", Label: "房间管理", Group: "房间管理", Capabilities: []string{"room:list"}, DefaultGranted: true}, {Code: "room:update", Label: "房间编辑", Group: "房间管理", Capabilities: []string{"room:update"}, DefaultGranted: true}, {Code: "privilege:list", Label: "用户特权道具列表", Group: "资源与发放", Capabilities: []string{"privilege:list"}, DefaultGranted: true}, {Code: "privilege:grant", Label: "特权道具发放", Group: "资源与发放", Dependencies: []string{"user-title:grant", "room-background:grant"}, Capabilities: []string{"privilege:grant"}, DefaultGranted: true}, {Code: "pretty-id:grant", Label: "靓号下发", Group: "资源与发放", Capabilities: []string{"pretty-id:grant"}, DefaultGranted: true}, // Wallet resources currently have no authoritative user-title/room-background semantic type. // Keep the three labels requested by the product, but make them one fail-closed assignment // bundle until the owner model can classify them without guessing from names or URLs. {Code: "user-title:grant", Label: "用户称号下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "room-background:grant"}, Capabilities: []string{"user-title:grant"}, DefaultGranted: true}, {Code: "room-background:grant", Label: "房间背景图下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "user-title:grant"}, Capabilities: []string{"room-background:grant"}, DefaultGranted: true}, {Code: "user-level:grant", Label: "财富/VIP等级下发", Group: "资源与发放", Capabilities: []string{"user-level:grant"}, DefaultGranted: true}, {Code: "banner:create", Label: "Banner创建", Group: "运营管理", Capabilities: []string{"banner:create"}, DefaultGranted: true}, } // effectivePermissions upgrades the original 18 route-oriented snapshot into the // 20 independently displayed business capabilities. New snapshots already contain // catalog codes and pass through unchanged. Conditional legacy closures avoid turning // a manually reduced old snapshot into broader Banner, VIP or grant access. func effectivePermissions(stored []string) []string { stored = normalizePermissions(stored) storedSet := make(map[string]struct{}, len(stored)) for _, permission := range stored { storedSet[permission] = struct{}{} } assignable := make(map[string]struct{}, len(permissionCatalog)) for _, item := range permissionCatalog { assignable[item.Code] = struct{}{} } effective := make([]string, 0, len(permissionCatalog)) for _, permission := range stored { if _, ok := assignable[permission]; ok { effective = append(effective, permission) } } legacyAliases := map[string][]string{ "app-user:view": {"user:list", "user-ban:list"}, "app-user:update": {"user:update"}, "app-user:status": {"user:ban", "user:unban"}, "host:view": {"host:list"}, "agency:view": {"agency:list"}, "bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"}, "room:view": {"room:list"}, "resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"}, } for legacy, aliases := range legacyAliases { if _, ok := storedSet[legacy]; ok { effective = append(effective, aliases...) } } if containsPermission(storedSet, "resource:view") && containsPermission(storedSet, "resource-grant:view") { effective = append(effective, "privilege:list") } if containsPermission(storedSet, "app-user:level") && containsPermission(storedSet, "vip-config:grant") { effective = append(effective, "user-level:grant") } if containsPermission(storedSet, "app-config:view") && containsPermission(storedSet, "app-config:update") && containsPermission(storedSet, "upload:create") { effective = append(effective, "banner:create") } effective = normalizePermissions(effective) // A hand-edited/corrupt new snapshot must not bypass the atomic grant bundle. // Unknown codes and wildcard-looking values were already discarded above. effectiveSet := make(map[string]struct{}, len(effective)) for _, permission := range effective { effectiveSet[permission] = struct{}{} } grantBundle := []string{"privilege:grant", "user-title:grant", "room-background:grant"} completeGrantBundle := true for _, permission := range grantBundle { if !containsPermission(effectiveSet, permission) { completeGrantBundle = false } } if !completeGrantBundle { filtered := effective[:0] for _, permission := range effective { if permission != "privilege:grant" && permission != "user-title:grant" && permission != "room-background:grant" { filtered = append(filtered, permission) } } effective = filtered } return effective } func containsPermission(permissions map[string]struct{}, code string) bool { _, ok := permissions[code] return ok } func PermissionCatalog() []PermissionCatalogItem { items := make([]PermissionCatalogItem, 0, len(permissionCatalog)) for _, item := range permissionCatalog { item.Dependencies = append([]string(nil), item.Dependencies...) item.Capabilities = append([]string(nil), item.Capabilities...) items = append(items, item) } return items } func permissionCapabilityAliases() map[string][]string { aliases := make(map[string][]string, len(permissionCatalog)) for _, item := range permissionCatalog { aliases[item.Code] = item.Capabilities } return aliases } // validatePermissionSelection normalizes duplicates/order only after checking every // code against the fixed catalog and every write dependency against the same snapshot. // An explicit empty list is valid and represents an account with no business access. func validatePermissionSelection(permissions []string) ([]string, error) { normalized := normalizePermissions(permissions) selected := make(map[string]struct{}, len(normalized)) assignable := make(map[string]PermissionCatalogItem, len(permissionCatalog)) for _, item := range permissionCatalog { assignable[item.Code] = item } for _, permission := range normalized { if _, ok := assignable[permission]; !ok { return nil, &PermissionValidationError{Permission: permission} } selected[permission] = struct{}{} } for _, permission := range normalized { for _, dependency := range assignable[permission].Dependencies { if _, ok := selected[dependency]; !ok { return nil, &PermissionValidationError{Permission: permission, Dependency: dependency} } } } return normalized, nil } // createPermissionSnapshot intentionally keeps omitted and explicit-empty requests // distinct. Existing credential-creation clients omit the field and retain the full // legacy snapshot; a permission-aware client may submit [] to create a locked-down // account that can authenticate but cannot enter any business page. func createPermissionSnapshot(input CreateInput) ([]string, error) { if !input.PermissionsSet { return DefaultPermissionSnapshot(), nil } return validatePermissionSelection(input.Permissions) } func (s *Service) GetAccountPermissions(ctx context.Context, appCode string, id uint64) (AccountPermissionView, error) { appCode = normalizeAppCode(appCode) if s.db == nil { return AccountPermissionView{}, errors.New("admin mysql is not configured") } if appCode == "" || id == 0 { return AccountPermissionView{}, ErrInvalidInput } var account model.ExternalAdminAccount err := s.db.WithContext(ctx).Select("id", "permissions_json", "permission_revision").Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error if errors.Is(err, gorm.ErrRecordNotFound) { return AccountPermissionView{}, ErrAccountNotFound } if err != nil { return AccountPermissionView{}, err } permissions, err := decodePermissions(account.PermissionsJSON) if err != nil { return AccountPermissionView{}, err } return AccountPermissionView{AccountID: account.ID, Permissions: permissions, Revision: account.PermissionRevision}, nil } func (s *Service) UpdateAccountPermissions(ctx context.Context, appCode string, id uint64, input UpdatePermissionsInput) (PermissionUpdateResult, error) { appCode = normalizeAppCode(appCode) if s.db == nil { return PermissionUpdateResult{}, errors.New("admin mysql is not configured") } if appCode == "" || id == 0 || input.ExpectedRevision == 0 { return PermissionUpdateResult{}, ErrInvalidInput } normalized, err := validatePermissionSelection(input.Permissions) if err != nil { return PermissionUpdateResult{}, err } encoded, err := encodePermissions(normalized) if err != nil { return PermissionUpdateResult{}, err } result := PermissionUpdateResult{} var account model.ExternalAdminAccount err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // The account row lock serializes concurrent permission writers and makes the // old/new audit snapshot match the exact value replaced by this transaction. if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil { return err } if account.PermissionRevision != input.ExpectedRevision { return ErrPermissionConflict } previous, err := decodePermissions(account.PermissionsJSON) if err != nil { return err } result.PreviousPermissions = previous if reflect.DeepEqual(previous, normalized) { return nil } if err := tx.Model(&account).Updates(map[string]any{ "permissions_json": encoded, "permission_revision": account.PermissionRevision + 1, }).Error; err != nil { return err } // Revocation is committed atomically with the snapshot. No browser can keep a // still-valid session after a permission reduction or silently retain a broader UI. if err := revokeAllSessions(tx, account.ID, "permissions_changed", time.Now().UTC().UnixMilli()); err != nil { return err } result.Changed = true result.SessionsRevoked = true return nil }) if errors.Is(err, gorm.ErrRecordNotFound) { return PermissionUpdateResult{}, ErrAccountNotFound } if errors.Is(err, ErrPermissionConflict) { return PermissionUpdateResult{}, ErrPermissionConflict } if err != nil { return PermissionUpdateResult{}, err } // Clear the transaction-loaded primary key before reloading. Otherwise GORM adds a // redundant implicit id predicate, which hides the intended App-scope contract and // makes future key changes harder to reason about. account = model.ExternalAdminAccount{} if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil { return PermissionUpdateResult{}, err } users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID}) if err != nil { return PermissionUpdateResult{}, err } result.Account = accountDTO(account, users[account.LinkedAppUserID]) return result, nil } func permissionValidationMessage(err error) string { var validationErr *PermissionValidationError if !errors.As(err, &validationErr) { return "外管权限配置不正确" } permissionLabel, permissionKnown := permissionCatalogLabel(validationErr.Permission) if !permissionKnown { // Unsupported raw codes are implementation details and may contain attacker- // controlled input. Keep the client error stable without reflecting that value. return "外管权限中包含不支持的权限" } if validationErr.Dependency != "" { dependencyLabel, dependencyKnown := permissionCatalogLabel(validationErr.Dependency) if !dependencyKnown { return "外管权限配置不正确" } return fmt.Sprintf("权限“%s”需同时选择“%s”", permissionLabel, dependencyLabel) } return "外管权限配置不正确" } func permissionCatalogLabel(code string) (string, bool) { code = strings.TrimSpace(code) for _, item := range permissionCatalog { if item.Code == code { return item.Label, true } } return "", false }