345 lines
15 KiB
Go
345 lines
15 KiB
Go
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
|
|
}
|