diff --git a/server/admin/docs/外管后台.md b/server/admin/docs/外管后台.md index 3efc8d41..3d34c899 100644 --- a/server/admin/docs/外管后台.md +++ b/server/admin/docs/外管后台.md @@ -4,7 +4,7 @@ 外管后台是独立于主后台、Databi 和 Finance 的浏览器入口: -- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,只负责查找 App 用户、签发外管账号、启停账号和重置密码。 +- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,负责查找 App 用户、签发外管账号、配置权限、启停账号和重置密码。 - 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session、CSRF token 和首次改密流程,不读取主后台 token 或 localStorage。 - 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts`、`external_admin_sessions`、`external_admin_login_logs`、`external_admin_operation_logs`。 - 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一,服务端通过账号行自动确定 `app_code` 并固化到会话;Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。 @@ -13,11 +13,19 @@ 创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。 -- `platform-admin`:查看、创建、启停和重置密码。创建和重置等同于签发一组固定的高权限外管能力,因此只允许平台管理员执行。 +- `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create` 和 `external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。 - `ops-admin`、`operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。 - 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。 - 创建、重置后的临时密码必须首次修改;停用或重置会撤销该账号的全部活跃会话。 +## 权限配置 + +- `GET /api/v1/admin/external-admin-users/permission-catalog` 返回固定的 20 项业务权限目录;服务端只接受目录内 code,不接受通配符或主后台 RBAC code。 +- `GET /api/v1/admin/external-admin-users/:id/permissions` 按当前 App 返回权限快照和 `revision`;`PUT` 必须提交 `permissions` 与 `expectedRevision`。版本不一致返回 409,防止两个管理员互相覆盖。 +- 创建请求省略 `permissions` 时保留兼容行为,使用完整默认目录;显式 `[]` 表示零业务权限。无论是否省略字段,调用方都必须具备权限委派能力。 +- 权限实际变化与撤销该账号全部活跃会话在同一事务提交,并递增 `permission_revision`;相同快照重复提交不递增版本、不重复撤销会话。 +- 特权道具、用户称号和房间背景图在 owner 资源模型可可靠分类前按一个原子权限包校验,不能只选择其中一项。外管资源与 VIP 发放使用 `manager_center` 来源,由 Wallet 在写事务内再次检查经理可发放规则;主后台仍使用原有来源。 + ## 外管能力 外管只注册显式白名单路由,不提供主后台的通用代理能力: @@ -41,7 +49,7 @@ ## 上线步骤 -1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql`、`099_external_admin_credential_delegation.sql` 和 `100_external_admin_global_username.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划,再执行同一查询确认结果为空。该检查和 100 的唯一索引构建只扫描小型凭据表,不访问用户、房间或钱包表;100 使用 `INPLACE + LOCK=NONE`,但仍建议避开凭据集中创建时段。若存在重复,DDL 会 fail-closed,不会自动改名或删除账号;先由业务确认保留的登录名并处理冲突,再清理迁移器 dirty 记录并重跑。 +1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql`、`099_external_admin_credential_delegation.sql`、`100_external_admin_global_username.sql` 和 `101_external_admin_permission_assignment.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划,再执行同一查询确认结果为空。该检查和 100 的唯一索引构建只扫描小型凭据表,不访问用户、房间或钱包表;100、101 使用 `INPLACE + LOCK=NONE`,但仍建议避开凭据集中创建时段。101 只为小型凭据表增加定长版本列并写入一项平台权限,不扫描或更新业务数据。若 100 检测到重复,DDL 会 fail-closed,不会自动改名或删除账号;先由业务确认保留的登录名并处理冲突,再清理迁移器 dirty 记录并重跑。 2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。 3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback;不要信任 `0.0.0.0/0`、`::/0` 或整段未知私网。 4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。 @@ -52,6 +60,7 @@ - 登录请求不带 App 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403,省略 header 时仍只能看到 Fami 数据。 - 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。 - 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403;改密后才可进入授权页面。 +- 用旧 `revision` 更新权限必须返回 409;成功减少权限后旧会话必须立即返回 401,原权限接口必须返回 403。重复提交相同快照时版本保持不变。 - 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。 - Redis 临时不可用时新登录应在约 200 ms 后返回 503;恢复后不需要重启服务。 - `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML,不能回退到主后台入口。 diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 237d7ed4..9c68a347 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -51,6 +51,7 @@ type ExternalAdminAccount struct { Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_app_username;not null" json:"username"` PasswordHash string `gorm:"size:255;not null" json:"-"` PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"` + PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"` Status string `gorm:"size:24;index:idx_external_admin_accounts_app_status_updated,priority:2;not null;default:active" json:"status"` PasswordChangeRequired bool `gorm:"not null;default:true" json:"passwordChangeRequired"` FailedLoginCount uint `gorm:"not null;default:0" json:"-"` diff --git a/server/admin/internal/modules/externaladmin/change_password_test.go b/server/admin/internal/modules/externaladmin/change_password_test.go index 9bba7698..e412b933 100644 --- a/server/admin/internal/modules/externaladmin/change_password_test.go +++ b/server/admin/internal/modules/externaladmin/change_password_test.go @@ -125,6 +125,7 @@ func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T) register: func(engine *gin.Engine, handler *Handler) { engine.POST("/create", func(c *gin.Context) { c.Set(adminmiddleware.ContextUserID, uint(1)) + c.Set(adminmiddleware.ContextPermissions, []string{"external-admin-user:permissions"}) c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) c.Next() }, handler.CreateAccount) diff --git a/server/admin/internal/modules/externaladmin/handler.go b/server/admin/internal/modules/externaladmin/handler.go index b9ffe03b..2d5f24a6 100644 --- a/server/admin/internal/modules/externaladmin/handler.go +++ b/server/admin/internal/modules/externaladmin/handler.go @@ -62,9 +62,10 @@ const ( ) type createAccountRequest struct { - TargetUserID FlexibleString `json:"targetUserId" binding:"required"` - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` + TargetUserID FlexibleString `json:"targetUserId" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Permissions optionalPermissionSelection `json:"permissions"` } type statusRequest struct { @@ -137,9 +138,17 @@ func (h *Handler) CreateAccount(c *gin.Context) { response.BadRequest(c, "创建参数不正确") return } + // Every created account receives a permission snapshot, including the legacy + // omitted-field shape which expands to the full default. Recheck delegation here + // as defense in depth for direct handler mounting and future route refactors. + if !middleware.HasPermission(c, "external-admin-user:permissions") { + response.Forbidden(c, "没有配置外管用户权限的权限") + return + } account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{ AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID), - Username: request.Username, Password: request.Password, CreatedByAdminID: middleware.CurrentUserID(c), + Username: request.Username, Password: request.Password, Permissions: request.Permissions.Value, + PermissionsSet: request.Permissions.Set, CreatedByAdminID: middleware.CurrentUserID(c), }) if errors.Is(err, ErrAccountConflict) { response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在") @@ -153,6 +162,10 @@ func (h *Handler) CreateAccount(c *gin.Context) { response.BadRequest(c, "密码不能全部为空白字符") return } + if errors.Is(err, ErrInvalidPermissions) { + response.BadRequest(c, permissionValidationMessage(err)) + return + } if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") return @@ -161,7 +174,7 @@ func (h *Handler) CreateAccount(c *gin.Context) { response.ServerError(c, "创建外管用户失败") return } - shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s", account.AppCode, account.LinkedUser.UserID, account.Username)) + shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s permission_count=%d", account.AppCode, account.LinkedUser.UserID, account.Username, len(account.Permissions))) response.Created(c, account) } diff --git a/server/admin/internal/modules/externaladmin/permissions.go b/server/admin/internal/modules/externaladmin/permissions.go new file mode 100644 index 00000000..8afe6adb --- /dev/null +++ b/server/admin/internal/modules/externaladmin/permissions.go @@ -0,0 +1,344 @@ +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 +} diff --git a/server/admin/internal/modules/externaladmin/permissions_handler.go b/server/admin/internal/modules/externaladmin/permissions_handler.go new file mode 100644 index 00000000..6142a17d --- /dev/null +++ b/server/admin/internal/modules/externaladmin/permissions_handler.go @@ -0,0 +1,122 @@ +package externaladmin + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +// optionalPermissionSelection preserves the JSON distinction required by account +// creation: omitted keeps the legacy full snapshot, while an explicit [] creates a +// valid account with no business permissions. JSON null is rejected as ambiguous. +type optionalPermissionSelection struct { + Value []string + Set bool +} + +func (selection *optionalPermissionSelection) UnmarshalJSON(body []byte) error { + selection.Set = true + body = bytes.TrimSpace(body) + if bytes.Equal(body, []byte("null")) { + return errors.New("permissions must be an array") + } + var permissions []string + if err := json.Unmarshal(body, &permissions); err != nil { + return err + } + selection.Value = permissions + return nil +} + +type updatePermissionsRequest struct { + Permissions optionalPermissionSelection `json:"permissions"` + ExpectedRevision uint64 `json:"expectedRevision"` +} + +func (h *Handler) ListPermissionCatalog(c *gin.Context) { + items := PermissionCatalog() + response.OK(c, gin.H{"items": items, "total": len(items)}) +} + +func (h *Handler) GetAccountPermissions(c *gin.Context) { + id, ok := parseUint64Param(c, "id") + if !ok { + return + } + permissions, err := h.service.GetAccountPermissions(c.Request.Context(), appctx.FromContext(c.Request.Context()), id) + if errors.Is(err, ErrAccountNotFound) { + response.NotFound(c, "外管用户不存在") + return + } + if errors.Is(err, ErrInvalidInput) { + response.BadRequest(c, "外管用户参数不正确") + return + } + if err != nil { + response.ServerError(c, "获取外管用户权限失败") + return + } + response.OK(c, permissions) +} + +func (h *Handler) UpdateAccountPermissions(c *gin.Context) { + id, ok := parseUint64Param(c, "id") + if !ok { + return + } + var request updatePermissionsRequest + if err := c.ShouldBindJSON(&request); err != nil || !request.Permissions.Set || request.ExpectedRevision == 0 { + response.BadRequest(c, "权限参数不正确") + return + } + result, err := h.service.UpdateAccountPermissions(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, UpdatePermissionsInput{ + Permissions: request.Permissions.Value, ExpectedRevision: request.ExpectedRevision, + }) + if errors.Is(err, ErrInvalidPermissions) { + response.BadRequest(c, permissionValidationMessage(err)) + return + } + if errors.Is(err, ErrInvalidInput) { + response.BadRequest(c, "外管用户参数不正确") + return + } + if errors.Is(err, ErrAccountNotFound) { + response.NotFound(c, "外管用户不存在") + return + } + if errors.Is(err, ErrPermissionConflict) { + response.Conflict(c, "外管用户权限已被其他管理员修改,请刷新后重试") + return + } + if err != nil { + response.ServerError(c, "更新外管用户权限失败") + return + } + shared.OperationLogWithResourceID( + c, + h.audit, + "update-external-admin-user-permissions", + "external_admin_accounts", + strconv.FormatUint(id, 10), + "success", + fmt.Sprintf( + "app_code=%s previous_permissions=%s permissions=%s revision=%d changed=%t sessions_revoked=%t", + result.Account.AppCode, + strings.Join(result.PreviousPermissions, ","), + strings.Join(result.Account.Permissions, ","), + result.Account.PermissionRevision, + result.Changed, + result.SessionsRevoked, + ), + ) + response.OK(c, result.Account) +} diff --git a/server/admin/internal/modules/externaladmin/permissions_test.go b/server/admin/internal/modules/externaladmin/permissions_test.go new file mode 100644 index 00000000..457dbb1e --- /dev/null +++ b/server/admin/internal/modules/externaladmin/permissions_test.go @@ -0,0 +1,294 @@ +package externaladmin + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "hyapp-admin-server/internal/appctx" + adminmiddleware "hyapp-admin-server/internal/middleware" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +func TestPermissionCatalogMatchesTwentyProductCapabilities(t *testing.T) { + items := PermissionCatalog() + want := []string{ + "user:list", "user:update", "user-ban:list", "user:ban", "user:unban", + "host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view", + "room:list", "room:update", "privilege:list", "privilege:grant", "pretty-id:grant", + "user-title:grant", "room-background:grant", "user-level:grant", "banner:create", + } + if len(items) != len(want) { + t.Fatalf("catalog count = %d, want %d", len(items), len(want)) + } + for index, item := range items { + if item.Code != want[index] || item.Label == "" || item.Group == "" || !item.DefaultGranted { + t.Fatalf("catalog[%d] = %+v, want code %s with display metadata/default", index, item, want[index]) + } + if !reflect.DeepEqual(item.Capabilities, []string{item.Code}) { + t.Fatalf("catalog capability must be independently addressable: %+v", item) + } + } +} + +func TestPermissionSelectionAllowlistDependenciesAndNormalization(t *testing.T) { + if got, err := validatePermissionSelection(nil); err != nil || len(got) != 0 { + t.Fatalf("explicit empty permissions must be valid: got=%v err=%v", got, err) + } + all := append(DefaultPermissionSnapshot(), " user:list ", "user:list") + got, err := validatePermissionSelection(all) + if err != nil { + t.Fatalf("default permission validation: %v", err) + } + if !reflect.DeepEqual(got, DefaultPermissionSnapshot()) { + t.Fatalf("permissions not deduplicated/sorted: got=%v", got) + } + for _, invalid := range [][]string{ + {"unknown:permission"}, + {"*"}, + {"external-admin:*"}, + {"privilege:grant"}, + {"user-title:grant", "room-background:grant"}, + } { + if _, err := validatePermissionSelection(invalid); !errors.Is(err, ErrInvalidPermissions) { + t.Fatalf("invalid selection %v error = %v", invalid, err) + } + } +} + +func TestPermissionValidationMessagesUseCatalogLabelsWithoutReflectingCodes(t *testing.T) { + _, dependencyErr := validatePermissionSelection([]string{"privilege:grant"}) + dependencyMessage := permissionValidationMessage(dependencyErr) + if !strings.Contains(dependencyMessage, "特权道具发放") || !strings.Contains(dependencyMessage, "用户称号下发") || strings.Contains(dependencyMessage, "privilege:grant") { + t.Fatalf("dependency message leaked internal code: %q", dependencyMessage) + } + _, unknownErr := validatePermissionSelection([]string{"attacker:controlled"}) + unknownMessage := permissionValidationMessage(unknownErr) + if unknownMessage != "外管权限中包含不支持的权限" || strings.Contains(unknownMessage, "attacker") { + t.Fatalf("unknown message reflected input: %q", unknownMessage) + } +} + +func TestEffectivePermissionsSafelyUpgradesLegacyAndRejectsCorruptSnapshots(t *testing.T) { + legacy := []string{ + "app-user:view", "app-user:update", "app-user:status", "app-user:level", + "host:view", "agency:view", "bd:view", "room:view", "room:update", + "resource:view", "resource-grant:view", "resource-grant:create", + "pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update", + "vip-config:grant", "upload:create", + } + if got := effectivePermissions(legacy); !reflect.DeepEqual(got, DefaultPermissionSnapshot()) { + t.Fatalf("legacy default did not expand to full 20: got=%v want=%v", got, DefaultPermissionSnapshot()) + } + got := effectivePermissions([]string{"user:list", "privilege:grant", "unknown:permission", "*", "external-admin:*"}) + if !reflect.DeepEqual(got, []string{"user:list"}) { + t.Fatalf("corrupt new snapshot must remove incomplete bundle/unknowns: %v", got) + } + got = effectivePermissions([]string{"app-config:view", "app-config:update", "app-user:level", "resource:view"}) + if len(got) != 0 { + t.Fatalf("incomplete legacy closures must fail closed: %v", got) + } +} + +func TestCreatePermissionFieldDistinguishesOmittedEmptyAndNull(t *testing.T) { + var omitted createAccountRequest + if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password"}`), &omitted); err != nil { + t.Fatalf("decode omitted: %v", err) + } + if omitted.Permissions.Set { + t.Fatal("omitted permissions must keep legacy default") + } + var empty createAccountRequest + if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":[]}`), &empty); err != nil { + t.Fatalf("decode empty: %v", err) + } + if !empty.Permissions.Set || len(empty.Permissions.Value) != 0 { + t.Fatalf("explicit [] was not preserved: %+v", empty.Permissions) + } + var nullValue createAccountRequest + if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":null}`), &nullValue); err == nil { + t.Fatal("permissions:null must be rejected as ambiguous") + } +} + +func TestCreatePermissionSnapshotKeepsOmittedAndExplicitEmptyDistinct(t *testing.T) { + omitted, err := createPermissionSnapshot(CreateInput{}) + if err != nil || !reflect.DeepEqual(omitted, DefaultPermissionSnapshot()) { + t.Fatalf("omitted snapshot=%v err=%v", omitted, err) + } + empty, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{}}) + if err != nil || len(empty) != 0 { + t.Fatalf("explicit empty snapshot=%v err=%v", empty, err) + } + if _, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{"*"}}); !errors.Is(err, ErrInvalidPermissions) { + t.Fatalf("explicit wildcard error=%v", err) + } +} + +func TestCreateAccountRequiresCredentialAndPermissionDelegationCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + permissions []string + wantStatus int + }{ + {name: "create alone cannot delegate default full snapshot", permissions: []string{"external-admin-user:create"}, wantStatus: http.StatusForbidden}, + {name: "delegation alone cannot create credential", permissions: []string{"external-admin-user:permissions"}, wantStatus: http.StatusForbidden}, + {name: "both capabilities reach request validation", permissions: []string{"external-admin-user:create", "external-admin-user:permissions"}, wantStatus: http.StatusBadRequest}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set(adminmiddleware.ContextPermissions, testCase.permissions) + c.Next() + }) + RegisterAdminRoutes(engine.Group(""), &Handler{service: NewService(nil, nil, Config{})}) + request := httptest.NewRequest(http.MethodPost, "/admin/external-admin-users", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + if recorder.Code != testCase.wantStatus { + t.Fatalf("permissions=%v status=%d body=%s", testCase.permissions, recorder.Code, recorder.Body.String()) + } + }) + } + + // Handler-level defense must also reject omitted permissions when a caller + // bypasses the standard route middleware during a future refactor. + engine := gin.New() + engine.POST("/direct", (&Handler{service: NewService(nil, nil, Config{})}).CreateAccount) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/direct", strings.NewReader(`{"targetUserId":"1","username":"operator","password":"password1"}`)) + request.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("direct omitted-permission create status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestGetAccountPermissionsIsScopedToAppAndReturnsRevision(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectQuery("SELECT `id`,`permissions_json`,`permission_revision` FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\? LIMIT \\?"). + WithArgs(uint64(7), "fami", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "permissions_json", "permission_revision"}).AddRow(7, `["user:update","user:list"]`, 9)) + + view, err := service.GetAccountPermissions(t.Context(), " FAMI ", 7) + if err != nil { + t.Fatalf("get permissions: %v", err) + } + if view.AccountID != 7 || view.Revision != 9 || !reflect.DeepEqual(view.Permissions, []string{"user:list", "user:update"}) { + t.Fatalf("permission view=%+v", view) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func TestUpdatePermissionsUsesRevisionAndRevokesSessionsAtomically(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(4, `["user:list"]`)) + adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*permission_revision.*permissions_json.* WHERE `id` = \\?"). + WillReturnResult(sqlmock.NewResult(0, 1)) + adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0"). + WithArgs("permissions_changed", sqlmock.AnyArg(), uint64(7)).WillReturnResult(sqlmock.NewResult(0, 2)) + adminMock.ExpectCommit() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list","user:update"]`)) + expectPermissionLinkedUser(userMock) + + result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ + Permissions: []string{"user:update", "user:list", "user:update"}, ExpectedRevision: 4, + }) + if err != nil { + t.Fatalf("update permissions: %v", err) + } + if !result.Changed || !result.SessionsRevoked || result.Account.PermissionRevision != 5 || !reflect.DeepEqual(result.PreviousPermissions, []string{"user:list"}) { + t.Fatalf("unexpected update result: %+v", result) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func TestUpdatePermissionsRejectsStaleRevisionWithoutWriting(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list"]`)) + adminMock.ExpectRollback() + + _, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ + Permissions: []string{"user:list", "user:update"}, ExpectedRevision: 4, + }) + if !errors.Is(err, ErrPermissionConflict) { + t.Fatalf("stale revision error = %v", err) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func TestUpdatePermissionsNoopIsIdempotentWithoutRevisionOrSessionChange(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`)) + adminMock.ExpectCommit() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`)) + expectPermissionLinkedUser(userMock) + + result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ + Permissions: []string{" user:list ", "user:list"}, ExpectedRevision: 6, + }) + if err != nil || result.Changed || result.SessionsRevoked || result.Account.PermissionRevision != 6 { + t.Fatalf("noop result=%+v err=%v", result, err) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func TestUpdatePermissionsHandlerMapsRevisionConflictTo409(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(3, `["user:list"]`)) + adminMock.ExpectRollback() + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.PUT("/accounts/:id", func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Next() + }, (&Handler{service: service}).UpdateAccountPermissions) + request := httptest.NewRequest(http.MethodPut, "/accounts/7", strings.NewReader(`{"permissions":["user:list"],"expectedRevision":2}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func permissionAccountRows(revision uint64, permissions string) *sqlmock.Rows { + return sqlmock.NewRows([]string{ + "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "permission_revision", "status", + "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", + }).AddRow(7, "fami", 9001, "operator", "hash", permissions, revision, "active", false, 0, 0, 1, 1, 1) +} + +func expectPermissionLinkedUser(userMock sqlmock.Sqlmock) { + userMock.ExpectQuery("SELECT u.user_id,"). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status", + }).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active")) +} diff --git a/server/admin/internal/modules/externaladmin/route_permissions_test.go b/server/admin/internal/modules/externaladmin/route_permissions_test.go new file mode 100644 index 00000000..852db09a --- /dev/null +++ b/server/admin/internal/modules/externaladmin/route_permissions_test.go @@ -0,0 +1,112 @@ +package externaladmin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/integration/walletclient" + adminmiddleware "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/modules/appuser" + "hyapp-admin-server/internal/modules/hostorg" + "hyapp-admin-server/internal/modules/vipconfig" + walletv1 "hyapp.local/api/proto/wallet/v1" + + "github.com/gin-gonic/gin" +) + +func TestExternalBusinessRoutesKeepIndependentPermissionBoundaries(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(func(c *gin.Context) { + // Each request gets exactly one product capability. Allowed reads reach an + // intentionally unconfigured handler and return 500; denied routes must stop + // at the permission middleware with 403 before any dependency is touched. + c.Set(adminmiddleware.ContextPermissions, []string{c.GetHeader("X-Test-Permission")}) + c.Next() + }) + registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{ + AppUser: appuser.New(nil, nil, nil, nil, nil, config.Config{}, nil), + HostOrg: hostorg.New(nil, nil, nil, nil, nil, nil), + }) + + tests := []struct { + name string + method string + path string + permission string + allowed bool + }{ + {name: "BD Manager list accepts its capability", method: http.MethodGet, path: "/admin/managers", permission: "bd-manager:list", allowed: true}, + {name: "BD Manager list rejects Super Admin capability", method: http.MethodGet, path: "/admin/managers", permission: "super-admin:list"}, + {name: "Super Admin list accepts its capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "super-admin:list", allowed: true}, + {name: "Super Admin list rejects BD Manager capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "bd-manager:list"}, + {name: "user update can read support list", method: http.MethodGet, path: "/app/users", permission: "user:update", allowed: true}, + {name: "user update cannot ban", method: http.MethodPost, path: "/app/users/9001/ban", permission: "user:update"}, + {name: "user unban can read ban support list", method: http.MethodGet, path: "/app/users/bans", permission: "user:unban", allowed: true}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequest(testCase.method, testCase.path, nil) + request.Header.Set("X-Test-Permission", testCase.permission) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + if testCase.allowed && recorder.Code == http.StatusForbidden { + t.Fatalf("permission %s was rejected for %s %s: %s", testCase.permission, testCase.method, testCase.path, recorder.Body.String()) + } + if !testCase.allowed && recorder.Code != http.StatusForbidden { + t.Fatalf("permission %s unexpectedly reached %s %s: status=%d body=%s", testCase.permission, testCase.method, testCase.path, recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestExternalVIPRoutesUseManagerCenterOwnerPolicySource(t *testing.T) { + gin.SetMode(gin.TestMode) + wallet := &externalVIPRouteWallet{} + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Set(adminmiddleware.ContextPermissions, []string{"user-level:grant"}) + c.Set(adminmiddleware.ContextRequestID, "external-vip-route-test") + c.Set(adminmiddleware.ContextUserID, uint(77)) + c.Next() + }) + registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{VIPConfig: vipconfig.New(wallet, nil)}) + + vipRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"reason":"manual"}`)) + vipRequest.Header.Set("Content-Type", "application/json") + vipRecorder := httptest.NewRecorder() + engine.ServeHTTP(vipRecorder, vipRequest) + if vipRecorder.Code != http.StatusCreated || wallet.vipRequest == nil || wallet.vipRequest.GetGrantSource() != "manager_center" { + t.Fatalf("external VIP route source: status=%d request=%+v body=%s", vipRecorder.Code, wallet.vipRequest, vipRecorder.Body.String()) + } + + trialRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-trial-card-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`)) + trialRequest.Header.Set("Content-Type", "application/json") + trialRecorder := httptest.NewRecorder() + engine.ServeHTTP(trialRecorder, trialRequest) + if trialRecorder.Code != http.StatusCreated || wallet.trialRequest == nil || wallet.trialRequest.GetGrantSource() != "manager_center" { + t.Fatalf("external trial route source: status=%d request=%+v body=%s", trialRecorder.Code, wallet.trialRequest, trialRecorder.Body.String()) + } +} + +type externalVIPRouteWallet struct { + walletclient.Client + vipRequest *walletv1.GrantVipRequest + trialRequest *walletv1.GrantVipTrialCardRequest +} + +func (wallet *externalVIPRouteWallet) GrantVip(_ context.Context, request *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) { + wallet.vipRequest = request + return &walletv1.GrantVipResponse{TransactionId: "vip-route"}, nil +} + +func (wallet *externalVIPRouteWallet) GrantVipTrialCard(_ context.Context, request *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) { + wallet.trialRequest = request + return &walletv1.GrantVipTrialCardResponse{TrialCard: &walletv1.VipTrialCard{TrialCardId: "trial-route"}}, nil +} diff --git a/server/admin/internal/modules/externaladmin/routes.go b/server/admin/internal/modules/externaladmin/routes.go index b1726140..bc4ad821 100644 --- a/server/admin/internal/modules/externaladmin/routes.go +++ b/server/admin/internal/modules/externaladmin/routes.go @@ -30,8 +30,11 @@ func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) { return } protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts) + protected.GET("/admin/external-admin-users/permission-catalog", middleware.RequirePermission("external-admin-user:permissions"), h.ListPermissionCatalog) + protected.GET("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.GetAccountPermissions) protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget) - protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), h.CreateAccount) + protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), middleware.RequirePermission("external-admin-user:permissions"), h.CreateAccount) + protected.PUT("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateAccountPermissions) protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus) protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword) } @@ -75,52 +78,60 @@ func noStore() gin.HandlerFunc { func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { // "My team" is not a generic manager-list alias. Its handler derives the root // manager from the external session and never accepts a client-selected user ID. - group.GET("/admin/my-team/agencies", middleware.RequirePermission("bd:view"), h.ListMyTeamAgencies) + group.GET("/admin/my-team/agencies", middleware.RequirePermission("team:view"), h.ListMyTeamAgencies) if handlers.AppUser != nil { - group.GET("/app/users", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListUsers) - group.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListBannedUsers) - group.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), handlers.AppUser.GetUser) - group.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), handlers.AppUser.UpdateUser) - group.PUT("/app/users/:id/levels", middleware.RequirePermission("app-user:level"), handlers.AppUser.AdjustTemporaryLevels) - group.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), handlers.AppUser.BanUser) - group.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), handlers.AppUser.UnbanUser) + group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), handlers.AppUser.ListUsers) + group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), handlers.AppUser.ListBannedUsers) + // Detail is a support read for each user-targeted action, not a second product + // capability. Any independently assigned user action can resolve its exact target. + group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), handlers.AppUser.GetUser) + group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), handlers.AppUser.UpdateUser) + group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), handlers.AppUser.AdjustTemporaryLevels) + group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), handlers.AppUser.BanUser) + group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), handlers.AppUser.UnbanUser) } if handlers.HostOrg != nil { - group.GET("/admin/hosts", middleware.RequirePermission("host:view"), handlers.HostOrg.ListHosts) - group.GET("/admin/agencies", middleware.RequirePermission("agency:view"), handlers.HostOrg.ListAgencies) - group.GET("/admin/bds", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDs) - group.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDLeaders) - group.GET("/admin/managers", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListManagers) + group.GET("/admin/hosts", middleware.RequirePermission("host:list"), handlers.HostOrg.ListHosts) + group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), handlers.HostOrg.ListAgencies) + group.GET("/admin/bds", middleware.RequirePermission("bd:list"), handlers.HostOrg.ListBDs) + // The shared host-org API names are historical: external BD Manager uses + // /managers, while the Super Admin projection uses /bd-leaders. + group.GET("/admin/bd-leaders", middleware.RequirePermission("super-admin:list"), handlers.HostOrg.ListBDLeaders) + group.GET("/admin/managers", middleware.RequirePermission("bd-manager:list"), handlers.HostOrg.ListManagers) } if handlers.RoomAdmin != nil { - group.GET("/admin/rooms", middleware.RequirePermission("room:view"), handlers.RoomAdmin.ListRooms) + group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update"), handlers.RoomAdmin.ListRooms) group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom) } if handlers.Resource != nil { - group.GET("/admin/resources", middleware.RequirePermission("resource:view"), handlers.Resource.ListResources) - group.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), handlers.Resource.ListResourceGrants) - group.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), handlers.Resource.LookupResourceGrantTarget) - group.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResource) - group.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResourceGroup) + // Resource owner data cannot yet classify generic entitlements into the three + // product labels below. The assignment validator therefore keeps them atomic, + // and the route repeats all three checks so corrupt DB JSON still fails closed. + group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources) + group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), handlers.Resource.ListResourceGrants) + group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), handlers.Resource.LookupResourceGrantTarget) + group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.GrantExternalResource) + // External UI never uses group grants. Without an owner-level semantic category, + // a mixed group cannot be authorized safely, so it is intentionally not exposed. } if handlers.PrettyID != nil { - group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), handlers.PrettyID.ListIDs) + group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.ListIDs) group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant) } if handlers.AppConfig != nil { - group.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), handlers.AppConfig.ListBanners) - group.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), handlers.AppConfig.CreateBanner) + group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners) + group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner) } if handlers.VIPConfig != nil { // Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped // grant permission so the portal can select direct-VIP vs trial-card without config write access. - group.GET("/admin/activity/vip-program", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GetProgram) - group.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.ListLevels) - group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GrantVIPTrialCard) - group.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GrantVIP) + group.GET("/admin/activity/vip-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram) + group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.ListLevels) + group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIPTrialCard) + group.POST("/admin/activity/vip-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIP) } if handlers.Upload != nil { // Banner/avatar forms need a real upload endpoint; no other file-management route is exposed. - group.POST("/admin/files/image/upload", middleware.RequirePermission("upload:create"), handlers.Upload.UploadImage) + group.POST("/admin/files/image/upload", middleware.RequirePermission("banner:create"), handlers.Upload.UploadImage) } } diff --git a/server/admin/internal/modules/externaladmin/service.go b/server/admin/internal/modules/externaladmin/service.go index bdc5ba10..02d5b283 100644 --- a/server/admin/internal/modules/externaladmin/service.go +++ b/server/admin/internal/modules/externaladmin/service.go @@ -193,11 +193,15 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account if err := validatePassword(input.Password); err != nil { return AccountDTO{}, err } + permissions, err := createPermissionSnapshot(input) + if err != nil { + return AccountDTO{}, err + } // Login has no client-selected App. Check the normalized username globally before // doing an owner-DB lookup or bcrypt work; the global unique index remains the // authoritative race-safe guard if two administrators create it concurrently. var existing model.ExternalAdminAccount - err := s.db.WithContext(ctx).Select("id").Where("username = ?", input.Username).Take(&existing).Error + err = s.db.WithContext(ctx).Select("id").Where("username = ?", input.Username).Take(&existing).Error if err == nil { return AccountDTO{}, ErrAccountConflict } @@ -218,7 +222,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account if err != nil { return AccountDTO{}, ErrInvalidInput } - permissionsJSON, err := encodePermissions(DefaultPermissionSnapshot()) + permissionsJSON, err := encodePermissions(permissions) if err != nil { return AccountDTO{}, err } @@ -228,6 +232,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account Username: input.Username, PasswordHash: passwordHash, PermissionsJSON: permissionsJSON, + PermissionRevision: 1, Status: model.ExternalAdminStatusActive, PasswordChangeRequired: true, CreatedByAdminID: input.CreatedByAdminID, @@ -774,7 +779,7 @@ func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) Accou } return AccountDTO{ ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status, - LinkedUser: linkedUser, Permissions: permissions, CreatedByAdminID: account.CreatedByAdminID, + LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision, CreatedByAdminID: account.CreatedByAdminID, CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS, } } diff --git a/server/admin/internal/modules/externaladmin/team_test.go b/server/admin/internal/modules/externaladmin/team_test.go index e7be5e60..f81e39b9 100644 --- a/server/admin/internal/modules/externaladmin/team_test.go +++ b/server/admin/internal/modules/externaladmin/team_test.go @@ -46,7 +46,7 @@ func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) { // This fixture models fields populated by AuthRequired. Query parameters below // intentionally carry attacker-selected IDs and must never override the principal. c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) - c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) + c.Set(adminmiddleware.ContextPermissions, []string{"team:view"}) c.Next() }) registerBusinessWhitelist(group, handler, BusinessHandlers{}) @@ -88,7 +88,7 @@ func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) { } } -func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) { +func TestMyTeamRouteRequiresTeamViewPermissionBeforeOwnerCall(t *testing.T) { gin.SetMode(gin.TestMode) client := &managerTeamHostClient{} handler := New(nil, nil, Config{}, nil, WithUserHostClient(client)) @@ -96,7 +96,7 @@ func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) { group := engine.Group("/api/v1/external") group.Use(func(c *gin.Context) { c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) - c.Set(adminmiddleware.ContextPermissions, []string{"agency:view"}) + c.Set(adminmiddleware.ContextPermissions, []string{"agency:list"}) c.Next() }) registerBusinessWhitelist(group, handler, BusinessHandlers{}) @@ -107,7 +107,7 @@ func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) { t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) } if client.calls != 0 { - t.Fatalf("owner service called %d times without bd:view", client.calls) + t.Fatalf("owner service called %d times without team:view", client.calls) } } @@ -119,7 +119,7 @@ func TestMyTeamRouteFiltersScopedIDProjectionBeforeTotalAndPagination(t *testing group := engine.Group("/api/v1/external") group.Use(func(c *gin.Context) { c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) - c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) + c.Set(adminmiddleware.ContextPermissions, []string{"team:view"}) c.Next() }) registerBusinessWhitelist(group, handler, BusinessHandlers{}) diff --git a/server/admin/internal/modules/externaladmin/types.go b/server/admin/internal/modules/externaladmin/types.go index 88202b49..730b2781 100644 --- a/server/admin/internal/modules/externaladmin/types.go +++ b/server/admin/internal/modules/externaladmin/types.go @@ -27,6 +27,8 @@ var ( ErrAccountNotFound = errors.New("external admin account not found") ErrTargetNotFound = errors.New("linked app user not found") ErrInvalidInput = errors.New("invalid external admin input") + ErrInvalidPermissions = errors.New("invalid external admin permissions") + ErrPermissionConflict = errors.New("external admin permission revision conflict") ErrInvalidSession = errors.New("invalid external admin session") ErrPasswordReused = errors.New("new external admin password matches current password") ErrPasswordBlank = errors.New("external admin password cannot be only whitespace") @@ -89,16 +91,17 @@ type LinkedUser struct { } type AccountDTO struct { - ID uint64 `json:"id"` - AppCode string `json:"appCode"` - Username string `json:"username"` - Status string `json:"status"` - LinkedUser LinkedUser `json:"linkedUser"` - Permissions []string `json:"permissions"` - CreatedByAdminID uint `json:"createdByAdminId"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` - LastLoginAtMS *int64 `json:"lastLoginAtMs"` + ID uint64 `json:"id"` + AppCode string `json:"appCode"` + Username string `json:"username"` + Status string `json:"status"` + LinkedUser LinkedUser `json:"linkedUser"` + Permissions []string `json:"permissions"` + PermissionRevision uint64 `json:"permissionRevision"` + CreatedByAdminID uint `json:"createdByAdminId"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` + LastLoginAtMS *int64 `json:"lastLoginAtMs"` } type AccountSummary struct { @@ -151,6 +154,8 @@ type CreateInput struct { TargetUserID string Username string Password string + Permissions []string + PermissionsSet bool CreatedByAdminID uint } @@ -235,29 +240,14 @@ func (value *FlexibleString) UnmarshalJSON(body []byte) error { return nil } -var defaultPermissionSnapshot = []string{ - "app-user:view", - "app-user:update", - "app-user:status", - "app-user:level", - "host:view", - "agency:view", - "bd:view", - "room:view", - "room:update", - "resource:view", - "resource-grant:view", - "resource-grant:create", - "pretty-id:view", - "pretty-id:grant", - "app-config:view", - "app-config:update", - "vip-config:grant", - "upload:create", -} - func DefaultPermissionSnapshot() []string { - return append([]string(nil), defaultPermissionSnapshot...) + permissions := make([]string, 0, len(permissionCatalog)) + for _, item := range permissionCatalog { + if item.DefaultGranted { + permissions = append(permissions, item.Code) + } + } + return normalizePermissions(permissions) } func encodePermissions(permissions []string) (string, error) { @@ -271,7 +261,7 @@ func decodePermissions(raw string) ([]string, error) { if err := json.Unmarshal([]byte(raw), &permissions); err != nil { return nil, err } - return normalizePermissions(permissions), nil + return effectivePermissions(permissions), nil } func normalizePermissions(permissions []string) []string { @@ -293,26 +283,12 @@ func normalizePermissions(permissions []string) []string { } func capabilitiesFor(permissions []string) []string { + permissions = effectivePermissions(permissions) permissionSet := make(map[string]struct{}, len(permissions)) for _, permission := range permissions { permissionSet[permission] = struct{}{} } - aliases := map[string][]string{ - "app-user:view": {"user:list", "user-ban:list"}, - "app-user:update": {"user:update"}, - "app-user:status": {"user:ban", "user:unban"}, - "app-user:level": {"user-level:grant"}, - "host:view": {"host:list"}, - "agency:view": {"agency:list"}, - "bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"}, - "room:view": {"room:list"}, - "room:update": {"room:update"}, - "resource:view": {"privilege:list"}, - "resource-grant:view": {"privilege:list"}, - "resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"}, - "pretty-id:grant": {"pretty-id:grant"}, - "app-config:update": {"banner:create"}, - } + aliases := permissionCapabilityAliases() seen := map[string]struct{}{} capabilities := []string{} for permission := range permissionSet { diff --git a/server/admin/internal/modules/externaladmin/types_test.go b/server/admin/internal/modules/externaladmin/types_test.go index d44274dd..ccc0c7dd 100644 --- a/server/admin/internal/modules/externaladmin/types_test.go +++ b/server/admin/internal/modules/externaladmin/types_test.go @@ -48,11 +48,10 @@ func TestLinkedUserMarshalsLongIDAsString(t *testing.T) { func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.T) { permissions := DefaultPermissionSnapshot() wantPermissions := []string{ - "app-user:view", "app-user:update", "app-user:status", "app-user:level", - "host:view", "agency:view", "bd:view", "room:view", "room:update", - "resource:view", "resource-grant:view", "resource-grant:create", - "pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update", - "vip-config:grant", "upload:create", + "user:list", "user:update", "user-ban:list", "user:ban", "user:unban", + "host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", + "room:list", "room:update", "privilege:list", "privilege:grant", "banner:create", + "pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view", } for _, permission := range wantPermissions { if !contains(permissions, permission) { diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 53ce8041..ddb0f3c3 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -65,6 +65,47 @@ func (h *Handler) ListResources(c *gin.Context) { response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } +// ListExternalGrantResources exposes only the owner-approved manager grant catalog. +// VIP trial cards remain on their dedicated API because a generic entitlement grant +// would not update the VIP state projection. The external route requires the complete +// grant permission bundle before this handler is reached. +func (h *Handler) ListExternalGrantResources(c *gin.Context) { + options := shared.ListOptions(c) + ctx, cancel := h.walletRequestContext(c) + defer cancel() + resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ResourceType: strings.TrimSpace(c.Query("resource_type")), + Keyword: options.Keyword, + Page: int32(options.Page), + PageSize: int32(options.PageSize), + ActiveOnly: true, + ManagerGrantOnly: true, + }) + if err != nil { + response.ServerError(c, "获取可发放资源失败") + return + } + items := make([]resourceDTO, 0, len(resp.GetResources())) + excluded := int64(0) + for _, item := range resp.GetResources() { + if item.GetResourceType() == resourceTypeVIPTrialCard { + excluded++ + continue + } + items = append(items, resourceFromProto(item)) + } + // ManagerGrantOnly is indexed owner data; excluding a dedicated-flow item here is + // defense in depth. Total can only be reduced by rows observed on this page because + // the wallet contract does not expose a negative type filter. + total := resp.GetTotal() - excluded + if total < int64(len(items)) { + total = int64(len(items)) + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) +} + func (h *Handler) GetResource(c *gin.Context) { resourceID, ok := parseID(c, "resource_id") if !ok { @@ -629,6 +670,17 @@ func (h *Handler) DisableGift(c *gin.Context) { } func (h *Handler) GrantResource(c *gin.Context) { + h.grantResource(c, false) +} + +// GrantExternalResource keeps main-admin behavior unchanged while closing the ID +// bypass on the external portal: callers may only submit an active resource that the +// owner explicitly marked grantable for managers. +func (h *Handler) GrantExternalResource(c *gin.Context) { + h.grantResource(c, true) +} + +func (h *Handler) grantResource(c *gin.Context, external bool) { var req grantResourceRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源赠送参数不正确") @@ -639,10 +691,17 @@ func (h *Handler) GrantResource(c *gin.Context) { response.BadRequest(c, err.Error()) return } - if err := h.validateGenericGrantResource(c, req.ResourceID); err != nil { + if err := h.validateGenericGrantResource(c, req.ResourceID, external); err != nil { response.BadRequest(c, err.Error()) return } + grantSource := "admin" + if external { + // Wallet atomically rechecks manager_grant_enabled for manager_center at grant + // time. This closes the GetResource/GrantResource race and records the actual + // external operator channel in the entitlement ledger. + grantSource = grantSourceManagerCenter + } resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{ CommandId: strings.TrimSpace(req.CommandID), AppCode: appctx.FromContext(c.Request.Context()), @@ -652,7 +711,7 @@ func (h *Handler) GrantResource(c *gin.Context) { DurationMs: req.DurationMS, Reason: strings.TrimSpace(req.Reason), OperatorUserId: actorID(c), - GrantSource: "admin", + GrantSource: grantSource, }) if err != nil { response.BadRequest(c, err.Error()) @@ -696,7 +755,7 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) { response.Created(c, grant) } -func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64) error { +func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64, requireManagerGrant bool) error { resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), @@ -705,6 +764,9 @@ func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64) if err != nil { return err } + if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) { + return fmt.Errorf("该资源未开放外管发放") + } // VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影; // 通用资源赠送只会创建 entitlement,放行会产生“背包可见但无法生效”的半成品数据。 if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard { diff --git a/server/admin/internal/modules/resource/handler_test.go b/server/admin/internal/modules/resource/handler_test.go index 5924530e..66d6d18e 100644 --- a/server/admin/internal/modules/resource/handler_test.go +++ b/server/admin/internal/modules/resource/handler_test.go @@ -235,6 +235,83 @@ func TestGenericResourceGrantRejectsVIPTrialCard(t *testing.T) { } } +func TestExternalResourceGrantRejectsIDOutsideManagerGrantCatalog(t *testing.T) { + wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ + 11: {AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: true}, + 12: {AppCode: "lalu", ResourceId: 12, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: false}, + }} + handler := New(wallet, nil, nil, time.Second, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) + c.Set(middleware.ContextRequestID, "external-grant-test") + c.Set(middleware.ContextUserID, uint(7)) + c.Next() + }) + router.POST("/external-grant", handler.GrantExternalResource) + + denied := httptest.NewRecorder() + deniedRequest := httptest.NewRequest(http.MethodPost, "/external-grant", strings.NewReader(`{"commandId":"grant-denied","targetUserId":"318705991371722752","resourceId":12,"quantity":1,"durationMs":0,"reason":"manual"}`)) + deniedRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(denied, deniedRequest) + if denied.Code != http.StatusBadRequest || !strings.Contains(denied.Body.String(), "未开放外管发放") || len(wallet.grantRequests) != 0 { + t.Fatalf("catalog bypass was not rejected: status=%d body=%s grants=%d", denied.Code, denied.Body.String(), len(wallet.grantRequests)) + } + + allowed := httptest.NewRecorder() + allowedRequest := httptest.NewRequest(http.MethodPost, "/external-grant", strings.NewReader(`{"commandId":"grant-allowed","targetUserId":"318705991371722752","resourceId":11,"quantity":1,"durationMs":0,"reason":"manual"}`)) + allowedRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(allowed, allowedRequest) + if allowed.Code != http.StatusCreated || len(wallet.grantRequests) != 1 || wallet.grantRequests[0].GetResourceId() != 11 || wallet.grantRequests[0].GetGrantSource() != "manager_center" { + t.Fatalf("manager grantable resource failed: status=%d body=%s grants=%+v", allowed.Code, allowed.Body.String(), wallet.grantRequests) + } +} + +func TestMainAdminResourceGrantKeepsAdminSource(t *testing.T) { + wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ + 11: {AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true}, + }} + handler := New(wallet, nil, nil, time.Second, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) + c.Set(middleware.ContextRequestID, "admin-grant-source-test") + c.Set(middleware.ContextUserID, uint(7)) + c.Next() + }) + router.POST("/admin-grant", handler.GrantResource) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/admin-grant", strings.NewReader(`{"commandId":"grant-admin","targetUserId":"318705991371722752","resourceId":11,"quantity":1,"reason":"manual"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated || len(wallet.grantRequests) != 1 || wallet.grantRequests[0].GetGrantSource() != "admin" { + t.Fatalf("main admin grant source changed: status=%d request=%+v body=%s", recorder.Code, wallet.grantRequests, recorder.Body.String()) + } +} + +func TestExternalResourceListUsesManagerCatalogAndHidesVIPTrialCard(t *testing.T) { + wallet := &mockResourceWallet{listedResources: []*walletv1.Resource{ + {AppCode: "lalu", ResourceId: 11, ResourceType: "badge", Status: "active", Grantable: true, ManagerGrantEnabled: true}, + {AppCode: "lalu", ResourceId: 99, ResourceType: resourceTypeVIPTrialCard, Status: "active", Grantable: true, ManagerGrantEnabled: true}, + }} + handler := New(wallet, nil, nil, time.Second, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) + c.Set(middleware.ContextRequestID, "external-resource-list-test") + c.Next() + }) + router.GET("/external-resources", handler.ListExternalGrantResources) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/external-resources?page=1&page_size=20", nil)) + if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), `"id":99`) { + t.Fatalf("external list status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(wallet.listResourceRequests) != 1 || !wallet.listResourceRequests[0].GetActiveOnly() || !wallet.listResourceRequests[0].GetManagerGrantOnly() { + t.Fatalf("external list did not use manager catalog: %+v", wallet.listResourceRequests) + } +} + func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) { wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{ 88: { @@ -378,6 +455,22 @@ type mockResourceWallet struct { batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest deletedGifts []*walletv1.DeleteGiftConfigRequest listGrantRequests []*walletv1.ListResourceGrantsRequest + listResourceRequests []*walletv1.ListResourcesRequest + listedResources []*walletv1.Resource + grantRequests []*walletv1.GrantResourceRequest +} + +func (m *mockResourceWallet) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) { + m.listResourceRequests = append(m.listResourceRequests, req) + return &walletv1.ListResourcesResponse{Resources: m.listedResources, Total: int64(len(m.listedResources))}, nil +} + +func (m *mockResourceWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) { + m.grantRequests = append(m.grantRequests, req) + return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{ + AppCode: req.GetAppCode(), GrantId: "grant-result", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), + GrantSource: req.GetGrantSource(), GrantSubjectType: "resource", GrantSubjectId: fmt.Sprint(req.GetResourceId()), Status: "succeeded", + }}, nil } func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { diff --git a/server/admin/internal/modules/vipconfig/handler.go b/server/admin/internal/modules/vipconfig/handler.go index d0bf5a66..7c1395a3 100644 --- a/server/admin/internal/modules/vipconfig/handler.go +++ b/server/admin/internal/modules/vipconfig/handler.go @@ -244,6 +244,17 @@ func (h *Handler) ListLevels(c *gin.Context) { } func (h *Handler) GrantVIP(c *gin.Context) { + h.grantVIP(c, "admin_grant", "admin_vip_grant:") +} + +// GrantExternalVIP uses Wallet's manager-center source so the owner service +// atomically applies its external-manager grant policy instead of trusting only +// this gateway's route permission. +func (h *Handler) GrantExternalVIP(c *gin.Context) { + h.grantVIP(c, "manager_center", "external_vip_grant:") +} + +func (h *Handler) grantVIP(c *gin.Context, grantSource string, defaultCommandPrefix string) { var req grantVipRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "VIP 赠送参数不正确") @@ -261,14 +272,14 @@ func (h *Handler) GrantVIP(c *gin.Context) { } commandID := strings.TrimSpace(req.CommandID) if commandID == "" { - commandID = "admin_vip_grant:" + middleware.CurrentRequestID(c) + commandID = defaultCommandPrefix + middleware.CurrentRequestID(c) } resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{ CommandId: commandID, AppCode: appctx.FromContext(c.Request.Context()), TargetUserId: targetUserID, Level: req.Level, - GrantSource: "admin_grant", + GrantSource: grantSource, OperatorUserId: int64(middleware.CurrentUserID(c)), Reason: reason, }) @@ -281,6 +292,17 @@ func (h *Handler) GrantVIP(c *gin.Context) { } func (h *Handler) GrantVIPTrialCard(c *gin.Context) { + h.grantVIPTrialCard(c, "admin_grant", "admin_vip_trial_card_grant:") +} + +// GrantExternalVIPTrialCard keeps duration/resource enforcement in Wallet's +// manager-center transaction, including the authoritative ManagerGrantEnabled +// check for an explicitly selected trial-card resource. +func (h *Handler) GrantExternalVIPTrialCard(c *gin.Context) { + h.grantVIPTrialCard(c, "manager_center", "external_vip_trial_card_grant:") +} + +func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultCommandPrefix string) { var req grantVipTrialCardRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "VIP 体验卡赠送参数不正确") @@ -298,7 +320,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) { } commandID := strings.TrimSpace(req.CommandID) if commandID == "" { - commandID = "admin_vip_trial_card_grant:" + middleware.CurrentRequestID(c) + commandID = defaultCommandPrefix + middleware.CurrentRequestID(c) } resp, err := h.wallet.GrantVipTrialCard(c.Request.Context(), &walletv1.GrantVipTrialCardRequest{ CommandId: commandID, @@ -307,7 +329,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) { Level: req.Level, DurationMs: req.DurationMS, ResourceId: req.ResourceID, - GrantSource: "admin_grant", + GrantSource: grantSource, OperatorUserId: int64(middleware.CurrentUserID(c)), Reason: reason, }) diff --git a/server/admin/internal/modules/vipconfig/handler_test.go b/server/admin/internal/modules/vipconfig/handler_test.go index 514c2746..1da5f5f8 100644 --- a/server/admin/internal/modules/vipconfig/handler_test.go +++ b/server/admin/internal/modules/vipconfig/handler_test.go @@ -135,7 +135,7 @@ func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String()) } req := wallet.grantTrialRequest - if req.GetCommandId() != "admin_vip_trial_card_grant:vip-config-test" || req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 { + if req.GetCommandId() != "admin_vip_trial_card_grant:vip-config-test" || req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 || req.GetGrantSource() != "admin_grant" { t.Fatalf("trial grant command context mismatch: %+v", req) } if req.ResourceId != nil || req.GetTargetUserId() != 318705991371722752 || req.GetDurationMs() != 1_728_000_000 { @@ -146,6 +146,59 @@ func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t } } +func TestExternalVIPGrantsUseManagerCenterOwnerPolicySource(t *testing.T) { + wallet := &fakeVIPConfigWallet{ + grantVIPResponse: &walletv1.GrantVipResponse{TransactionId: "vip-external"}, + grantTrialResponse: &walletv1.GrantVipTrialCardResponse{ + TrialCard: &walletv1.VipTrialCard{TrialCardId: "trial-external"}, + }, + } + gintest := func(path string, body string, handler gin.HandlerFunc) *httptest.ResponseRecorder { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Set(middleware.ContextRequestID, "external-vip-test") + c.Set(middleware.ContextUserID, uint(77)) + c.Next() + }) + router.POST(path, handler) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + return recorder + } + handler := New(wallet, nil) + vipRecorder := gintest("/external-vip", `{"targetUserId":"318705991371722752","level":5,"reason":"manual"}`, handler.GrantExternalVIP) + if vipRecorder.Code != http.StatusCreated || wallet.grantVIPRequest == nil || wallet.grantVIPRequest.GetGrantSource() != "manager_center" || wallet.grantVIPRequest.GetCommandId() != "external_vip_grant:external-vip-test" { + t.Fatalf("external VIP source mismatch: status=%d request=%+v body=%s", vipRecorder.Code, wallet.grantVIPRequest, vipRecorder.Body.String()) + } + trialRecorder := gintest("/external-trial", `{"targetUserId":"318705991371722752","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`, handler.GrantExternalVIPTrialCard) + if trialRecorder.Code != http.StatusCreated || wallet.grantTrialRequest == nil || wallet.grantTrialRequest.GetGrantSource() != "manager_center" || wallet.grantTrialRequest.GetCommandId() != "external_vip_trial_card_grant:external-vip-test" { + t.Fatalf("external trial source mismatch: status=%d request=%+v body=%s", trialRecorder.Code, wallet.grantTrialRequest, trialRecorder.Body.String()) + } +} + +func TestMainAdminVIPGrantKeepsAdminSource(t *testing.T) { + wallet := &fakeVIPConfigWallet{grantVIPResponse: &walletv1.GrantVipResponse{TransactionId: "vip-admin"}} + handler := New(wallet, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Set(middleware.ContextRequestID, "admin-vip-test") + c.Set(middleware.ContextUserID, uint(77)) + c.Next() + }) + router.POST("/admin-vip", handler.GrantVIP) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/admin-vip", strings.NewReader(`{"targetUserId":"318705991371722752","level":5,"reason":"manual"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated || wallet.grantVIPRequest == nil || wallet.grantVIPRequest.GetGrantSource() != "admin_grant" || wallet.grantVIPRequest.GetCommandId() != "admin_vip_grant:admin-vip-test" { + t.Fatalf("main admin VIP source changed: status=%d request=%+v body=%s", recorder.Code, wallet.grantVIPRequest, recorder.Body.String()) + } +} + func serveVIPConfigRequest(t *testing.T, wallet walletclient.Client, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() gin.SetMode(gin.TestMode) @@ -174,10 +227,12 @@ type fakeVIPConfigWallet struct { updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse grantTrialResponse *walletv1.GrantVipTrialCardResponse + grantVIPResponse *walletv1.GrantVipResponse getProgramRequest *walletv1.GetVipProgramConfigRequest updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest grantTrialRequest *walletv1.GrantVipTrialCardRequest + grantVIPRequest *walletv1.GrantVipRequest } func (f *fakeVIPConfigWallet) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) { @@ -199,3 +254,8 @@ func (f *fakeVIPConfigWallet) GrantVipTrialCard(_ context.Context, req *walletv1 f.grantTrialRequest = req return f.grantTrialResponse, nil } + +func (f *fakeVIPConfigWallet) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) { + f.grantVIPRequest = req + return f.grantVIPResponse, nil +} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 4813d23e..c5f68b68 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -20,6 +20,7 @@ var defaultPermissions = []model.Permission{ {Name: "外管用户创建", Code: "external-admin-user:create", Kind: "button", Description: "按当前 App 用户短 ID 创建外管账号"}, {Name: "外管用户状态", Code: "external-admin-user:status", Kind: "button", Description: "启用或停用当前 App 的外管账号"}, {Name: "外管用户重置密码", Code: "external-admin-user:reset-password", Kind: "button", Description: "重置当前 App 的外管账号密码并撤销全部会话"}, + {Name: "外管用户权限配置", Code: "external-admin-user:permissions", Kind: "button", Description: "配置当前 App 外管用户权限并撤销其现有会话"}, {Name: "团队查看", Code: "team:view", Kind: "menu"}, {Name: "团队创建", Code: "team:create", Kind: "button"}, {Name: "团队更新", Code: "team:update", Kind: "button"}, diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index 4f12349a..25d2b888 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -127,11 +127,11 @@ func TestManagedDefaultRolePermissionMatrix(t *testing.T) { func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { assertRolePermissions(t, roleCodeOperationsLead, []string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update", "external-admin-user:view", "external-admin-user:status"}, - []string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password"}, + []string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, ) assertRolePermissions(t, roleCodeOperationsSpecialist, []string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export", "external-admin-user:view", "external-admin-user:status"}, - []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "external-admin-user:create", "external-admin-user:reset-password"}, + []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, ) assertRolePermissions(t, roleCodeProductLead, []string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"}, @@ -269,6 +269,30 @@ func TestExternalAdminCredentialDelegationCorrectionMigration(t *testing.T) { } } +func TestExternalAdminPermissionAssignmentMigrationIsIncrementalAndPlatformOnly(t *testing.T) { + content, err := os.ReadFile("../../migrations/101_external_admin_permission_assignment.sql") + if err != nil { + t.Fatalf("read external admin permission assignment migration: %v", err) + } + sqlText := string(content) + for _, token := range []string{ + "'external-admin-user:permissions'", "WHERE role.code = 'platform-admin'", + "admin_role_permissions", "admin_permissions permission", "ADD COLUMN permission_revision", + "BIGINT UNSIGNED NOT NULL DEFAULT 1", "ALGORITHM=INPLACE", "LOCK=NONE", + } { + if !strings.Contains(sqlText, token) { + t.Fatalf("external admin permission assignment migration missing %s", token) + } + } + if strings.Contains(sqlText, "'ops-admin'") || strings.Contains(sqlText, "'operations-specialist'") { + t.Fatal("external permission delegation must not be seeded to operations roles") + } + upperSQL := strings.ToUpper(sqlText) + if strings.Contains(upperSQL, "UPDATE EXTERNAL_ADMIN_ACCOUNTS") || strings.Contains(upperSQL, "DELETE FROM EXTERNAL_ADMIN_ACCOUNTS") { + t.Fatal("permission revision migration must not rewrite account rows in application SQL") + } +} + func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) { t.Helper() quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) diff --git a/server/admin/migrations/101_external_admin_permission_assignment.sql b/server/admin/migrations/101_external_admin_permission_assignment.sql new file mode 100644 index 00000000..0ac0875b --- /dev/null +++ b/server/admin/migrations/101_external_admin_permission_assignment.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 098-100 may already be recorded in production, so permission delegation is added +-- incrementally instead of rewriting an executed migration. external_admin_accounts is a +-- small credential table; adding a fixed-width column with INPLACE + LOCK=NONE may scan/rebuild +-- only that table on older supported MySQL versions while allowing concurrent reads/writes. +-- Run this outside credential-creation peaks even though no users/rooms/wallet table is touched. +ALTER TABLE external_admin_accounts + ADD COLUMN permission_revision BIGINT UNSIGNED NOT NULL DEFAULT 1 AFTER permissions_json, + ALGORITHM=INPLACE, + LOCK=NONE; + +-- Both RBAC lookup predicates below use unique indexes (admin_permissions.code and +-- admin_roles.code); the final INSERT is guarded by admin_role_permissions(role_id, +-- permission_id), touches one row, and scans no business, external-account or session table. +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('外管用户权限配置', 'external-admin-user:permissions', 'button', '配置当前 App 外管用户权限并撤销其现有会话', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +-- 权限配置可以扩大外部操作者的业务能力,只授予平台管理员;运营岗位仍只拥有 +-- 098/099 中的查看与启停能力,不能通过本接口进行权限委派。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code = 'platform-admin' + AND permission.code = 'external-admin-user:permissions';