feat(admin): configure external account permissions

This commit is contained in:
zhx 2026-07-15 18:40:42 +08:00
parent e7389c1e74
commit 7748b186f5
20 changed files with 1290 additions and 108 deletions

View File

@ -4,7 +4,7 @@
外管后台是独立于主后台、Databi 和 Finance 的浏览器入口: 外管后台是独立于主后台、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/` 使用独立账号、服务端 opaque session、CSRF token 和首次改密流程,不读取主后台 token 或 localStorage。
- 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts``external_admin_sessions``external_admin_login_logs``external_admin_operation_logs` - 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts``external_admin_sessions``external_admin_login_logs``external_admin_operation_logs`
- 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一服务端通过账号行自动确定 `app_code` 并固化到会话Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。 - 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一服务端通过账号行自动确定 `app_code` 并固化到会话Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。
@ -13,11 +13,19 @@
创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。 创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。
- `platform-admin`:查看、创建、启停和重置密码。创建和重置等同于签发一组固定的高权限外管能力,因此只允许平台管理员执行 - `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create``external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号
- `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。 - `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。
- 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。 - 外管登录账号名跨 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 故障会阻断新登录但不破坏已建立会话。 2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。
3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback不要信任 `0.0.0.0/0``::/0` 或整段未知私网。 3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback不要信任 `0.0.0.0/0``::/0` 或整段未知私网。
4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。 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 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403省略 header 时仍只能看到 Fami 数据。
- 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。 - 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。
- 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403改密后才可进入授权页面。 - 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403改密后才可进入授权页面。
- 用旧 `revision` 更新权限必须返回 409成功减少权限后旧会话必须立即返回 401原权限接口必须返回 403。重复提交相同快照时版本保持不变。
- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。 - 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。
- Redis 临时不可用时新登录应在约 200 ms 后返回 503恢复后不需要重启服务。 - Redis 临时不可用时新登录应在约 200 ms 后返回 503恢复后不需要重启服务。
- `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML不能回退到主后台入口。 - `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML不能回退到主后台入口。

View File

@ -51,6 +51,7 @@ type ExternalAdminAccount struct {
Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_app_username;not null" json:"username"` Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_app_username;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
PermissionsJSON string `gorm:"column:permissions_json;type:json;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"` 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"` PasswordChangeRequired bool `gorm:"not null;default:true" json:"passwordChangeRequired"`
FailedLoginCount uint `gorm:"not null;default:0" json:"-"` FailedLoginCount uint `gorm:"not null;default:0" json:"-"`

View File

@ -125,6 +125,7 @@ func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T)
register: func(engine *gin.Engine, handler *Handler) { register: func(engine *gin.Engine, handler *Handler) {
engine.POST("/create", func(c *gin.Context) { engine.POST("/create", func(c *gin.Context) {
c.Set(adminmiddleware.ContextUserID, uint(1)) 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.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Next() c.Next()
}, handler.CreateAccount) }, handler.CreateAccount)

View File

@ -62,9 +62,10 @@ const (
) )
type createAccountRequest struct { type createAccountRequest struct {
TargetUserID FlexibleString `json:"targetUserId" binding:"required"` TargetUserID FlexibleString `json:"targetUserId" binding:"required"`
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
Permissions optionalPermissionSelection `json:"permissions"`
} }
type statusRequest struct { type statusRequest struct {
@ -137,9 +138,17 @@ func (h *Handler) CreateAccount(c *gin.Context) {
response.BadRequest(c, "创建参数不正确") response.BadRequest(c, "创建参数不正确")
return 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{ account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{
AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID), 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) { if errors.Is(err, ErrAccountConflict) {
response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在") response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在")
@ -153,6 +162,10 @@ func (h *Handler) CreateAccount(c *gin.Context) {
response.BadRequest(c, "密码不能全部为空白字符") response.BadRequest(c, "密码不能全部为空白字符")
return return
} }
if errors.Is(err, ErrInvalidPermissions) {
response.BadRequest(c, permissionValidationMessage(err))
return
}
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位")
return return
@ -161,7 +174,7 @@ func (h *Handler) CreateAccount(c *gin.Context) {
response.ServerError(c, "创建外管用户失败") response.ServerError(c, "创建外管用户失败")
return 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) response.Created(c, account)
} }

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"))
}

View File

@ -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
}

View File

@ -30,8 +30,11 @@ func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) {
return return
} }
protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts) 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.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.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) 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) { func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) {
// "My team" is not a generic manager-list alias. Its handler derives the root // "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. // 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 { if handlers.AppUser != nil {
group.GET("/app/users", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListUsers) group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), handlers.AppUser.ListUsers)
group.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListBannedUsers) group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), handlers.AppUser.ListBannedUsers)
group.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), handlers.AppUser.GetUser) // Detail is a support read for each user-targeted action, not a second product
group.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), handlers.AppUser.UpdateUser) // capability. Any independently assigned user action can resolve its exact target.
group.PUT("/app/users/:id/levels", middleware.RequirePermission("app-user:level"), handlers.AppUser.AdjustTemporaryLevels) group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), handlers.AppUser.GetUser)
group.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), handlers.AppUser.BanUser) group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), handlers.AppUser.UpdateUser)
group.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), handlers.AppUser.UnbanUser) 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 { if handlers.HostOrg != nil {
group.GET("/admin/hosts", middleware.RequirePermission("host:view"), handlers.HostOrg.ListHosts) group.GET("/admin/hosts", middleware.RequirePermission("host:list"), handlers.HostOrg.ListHosts)
group.GET("/admin/agencies", middleware.RequirePermission("agency:view"), handlers.HostOrg.ListAgencies) group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), handlers.HostOrg.ListAgencies)
group.GET("/admin/bds", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDs) group.GET("/admin/bds", middleware.RequirePermission("bd:list"), handlers.HostOrg.ListBDs)
group.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDLeaders) // The shared host-org API names are historical: external BD Manager uses
group.GET("/admin/managers", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListManagers) // /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 { 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) group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom)
} }
if handlers.Resource != nil { if handlers.Resource != nil {
group.GET("/admin/resources", middleware.RequirePermission("resource:view"), handlers.Resource.ListResources) // Resource owner data cannot yet classify generic entitlements into the three
group.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), handlers.Resource.ListResourceGrants) // product labels below. The assignment validator therefore keeps them atomic,
group.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), handlers.Resource.LookupResourceGrantTarget) // and the route repeats all three checks so corrupt DB JSON still fails closed.
group.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResource) group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources)
group.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResourceGroup) 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 { 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) group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant)
} }
if handlers.AppConfig != nil { if handlers.AppConfig != nil {
group.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), handlers.AppConfig.ListBanners) group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners)
group.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), handlers.AppConfig.CreateBanner) group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner)
} }
if handlers.VIPConfig != nil { if handlers.VIPConfig != nil {
// Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped // 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. // 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-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram)
group.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.ListLevels) group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level: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-trial-card-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIPTrialCard)
group.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GrantVIP) group.POST("/admin/activity/vip-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIP)
} }
if handlers.Upload != nil { if handlers.Upload != nil {
// Banner/avatar forms need a real upload endpoint; no other file-management route is exposed. // 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)
} }
} }

View File

@ -193,11 +193,15 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
if err := validatePassword(input.Password); err != nil { if err := validatePassword(input.Password); err != nil {
return AccountDTO{}, err 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 // 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 // doing an owner-DB lookup or bcrypt work; the global unique index remains the
// authoritative race-safe guard if two administrators create it concurrently. // authoritative race-safe guard if two administrators create it concurrently.
var existing model.ExternalAdminAccount 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 { if err == nil {
return AccountDTO{}, ErrAccountConflict return AccountDTO{}, ErrAccountConflict
} }
@ -218,7 +222,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
if err != nil { if err != nil {
return AccountDTO{}, ErrInvalidInput return AccountDTO{}, ErrInvalidInput
} }
permissionsJSON, err := encodePermissions(DefaultPermissionSnapshot()) permissionsJSON, err := encodePermissions(permissions)
if err != nil { if err != nil {
return AccountDTO{}, err return AccountDTO{}, err
} }
@ -228,6 +232,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
Username: input.Username, Username: input.Username,
PasswordHash: passwordHash, PasswordHash: passwordHash,
PermissionsJSON: permissionsJSON, PermissionsJSON: permissionsJSON,
PermissionRevision: 1,
Status: model.ExternalAdminStatusActive, Status: model.ExternalAdminStatusActive,
PasswordChangeRequired: true, PasswordChangeRequired: true,
CreatedByAdminID: input.CreatedByAdminID, CreatedByAdminID: input.CreatedByAdminID,
@ -774,7 +779,7 @@ func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) Accou
} }
return AccountDTO{ return AccountDTO{
ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status, 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, CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS,
} }
} }

View File

@ -46,7 +46,7 @@ func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) {
// This fixture models fields populated by AuthRequired. Query parameters below // This fixture models fields populated by AuthRequired. Query parameters below
// intentionally carry attacker-selected IDs and must never override the principal. // intentionally carry attacker-selected IDs and must never override the principal.
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next() c.Next()
}) })
registerBusinessWhitelist(group, handler, BusinessHandlers{}) 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) gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{} client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client)) handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
@ -96,7 +96,7 @@ func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) {
group := engine.Group("/api/v1/external") group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) { group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"agency:view"}) c.Set(adminmiddleware.ContextPermissions, []string{"agency:list"})
c.Next() c.Next()
}) })
registerBusinessWhitelist(group, handler, BusinessHandlers{}) registerBusinessWhitelist(group, handler, BusinessHandlers{})
@ -107,7 +107,7 @@ func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
} }
if client.calls != 0 { 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 := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) { group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next() c.Next()
}) })
registerBusinessWhitelist(group, handler, BusinessHandlers{}) registerBusinessWhitelist(group, handler, BusinessHandlers{})

View File

@ -27,6 +27,8 @@ var (
ErrAccountNotFound = errors.New("external admin account not found") ErrAccountNotFound = errors.New("external admin account not found")
ErrTargetNotFound = errors.New("linked app user not found") ErrTargetNotFound = errors.New("linked app user not found")
ErrInvalidInput = errors.New("invalid external admin input") 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") ErrInvalidSession = errors.New("invalid external admin session")
ErrPasswordReused = errors.New("new external admin password matches current password") ErrPasswordReused = errors.New("new external admin password matches current password")
ErrPasswordBlank = errors.New("external admin password cannot be only whitespace") ErrPasswordBlank = errors.New("external admin password cannot be only whitespace")
@ -89,16 +91,17 @@ type LinkedUser struct {
} }
type AccountDTO struct { type AccountDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
Username string `json:"username"` Username string `json:"username"`
Status string `json:"status"` Status string `json:"status"`
LinkedUser LinkedUser `json:"linkedUser"` LinkedUser LinkedUser `json:"linkedUser"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
CreatedByAdminID uint `json:"createdByAdminId"` PermissionRevision uint64 `json:"permissionRevision"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedByAdminID uint `json:"createdByAdminId"`
UpdatedAtMS int64 `json:"updatedAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
LastLoginAtMS *int64 `json:"lastLoginAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"`
LastLoginAtMS *int64 `json:"lastLoginAtMs"`
} }
type AccountSummary struct { type AccountSummary struct {
@ -151,6 +154,8 @@ type CreateInput struct {
TargetUserID string TargetUserID string
Username string Username string
Password string Password string
Permissions []string
PermissionsSet bool
CreatedByAdminID uint CreatedByAdminID uint
} }
@ -235,29 +240,14 @@ func (value *FlexibleString) UnmarshalJSON(body []byte) error {
return nil 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 { 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) { 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 { if err := json.Unmarshal([]byte(raw), &permissions); err != nil {
return nil, err return nil, err
} }
return normalizePermissions(permissions), nil return effectivePermissions(permissions), nil
} }
func normalizePermissions(permissions []string) []string { func normalizePermissions(permissions []string) []string {
@ -293,26 +283,12 @@ func normalizePermissions(permissions []string) []string {
} }
func capabilitiesFor(permissions []string) []string { func capabilitiesFor(permissions []string) []string {
permissions = effectivePermissions(permissions)
permissionSet := make(map[string]struct{}, len(permissions)) permissionSet := make(map[string]struct{}, len(permissions))
for _, permission := range permissions { for _, permission := range permissions {
permissionSet[permission] = struct{}{} permissionSet[permission] = struct{}{}
} }
aliases := map[string][]string{ aliases := permissionCapabilityAliases()
"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"},
}
seen := map[string]struct{}{} seen := map[string]struct{}{}
capabilities := []string{} capabilities := []string{}
for permission := range permissionSet { for permission := range permissionSet {

View File

@ -48,11 +48,10 @@ func TestLinkedUserMarshalsLongIDAsString(t *testing.T) {
func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.T) { func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.T) {
permissions := DefaultPermissionSnapshot() permissions := DefaultPermissionSnapshot()
wantPermissions := []string{ wantPermissions := []string{
"app-user:view", "app-user:update", "app-user:status", "app-user:level", "user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:view", "agency:view", "bd:view", "room:view", "room:update", "host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list",
"resource:view", "resource-grant:view", "resource-grant:create", "room:list", "room:update", "privilege:list", "privilege:grant", "banner:create",
"pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update", "pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view",
"vip-config:grant", "upload:create",
} }
for _, permission := range wantPermissions { for _, permission := range wantPermissions {
if !contains(permissions, permission) { if !contains(permissions, permission) {

View File

@ -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()}) 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) { func (h *Handler) GetResource(c *gin.Context) {
resourceID, ok := parseID(c, "resource_id") resourceID, ok := parseID(c, "resource_id")
if !ok { if !ok {
@ -629,6 +670,17 @@ func (h *Handler) DisableGift(c *gin.Context) {
} }
func (h *Handler) GrantResource(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 var req grantResourceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "资源赠送参数不正确") response.BadRequest(c, "资源赠送参数不正确")
@ -639,10 +691,17 @@ func (h *Handler) GrantResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return 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()) response.BadRequest(c, err.Error())
return 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{ resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
CommandId: strings.TrimSpace(req.CommandID), CommandId: strings.TrimSpace(req.CommandID),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -652,7 +711,7 @@ func (h *Handler) GrantResource(c *gin.Context) {
DurationMs: req.DurationMS, DurationMs: req.DurationMS,
Reason: strings.TrimSpace(req.Reason), Reason: strings.TrimSpace(req.Reason),
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
GrantSource: "admin", GrantSource: grantSource,
}) })
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
@ -696,7 +755,7 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
response.Created(c, grant) 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{ resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -705,6 +764,9 @@ func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64)
if err != nil { if err != nil {
return err return err
} }
if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) {
return fmt.Errorf("该资源未开放外管发放")
}
// VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影; // VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影;
// 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。 // 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。
if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard { if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {

View File

@ -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) { func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) {
wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{ wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{
88: { 88: {
@ -378,6 +455,22 @@ type mockResourceWallet struct {
batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest deletedGifts []*walletv1.DeleteGiftConfigRequest
listGrantRequests []*walletv1.ListResourceGrantsRequest 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) { func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {

View File

@ -244,6 +244,17 @@ func (h *Handler) ListLevels(c *gin.Context) {
} }
func (h *Handler) GrantVIP(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 var req grantVipRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 赠送参数不正确") response.BadRequest(c, "VIP 赠送参数不正确")
@ -261,14 +272,14 @@ func (h *Handler) GrantVIP(c *gin.Context) {
} }
commandID := strings.TrimSpace(req.CommandID) commandID := strings.TrimSpace(req.CommandID)
if 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{ resp, err := h.wallet.GrantVip(c.Request.Context(), &walletv1.GrantVipRequest{
CommandId: commandID, CommandId: commandID,
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
TargetUserId: targetUserID, TargetUserId: targetUserID,
Level: req.Level, Level: req.Level,
GrantSource: "admin_grant", GrantSource: grantSource,
OperatorUserId: int64(middleware.CurrentUserID(c)), OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason, Reason: reason,
}) })
@ -281,6 +292,17 @@ func (h *Handler) GrantVIP(c *gin.Context) {
} }
func (h *Handler) GrantVIPTrialCard(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 var req grantVipTrialCardRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 体验卡赠送参数不正确") response.BadRequest(c, "VIP 体验卡赠送参数不正确")
@ -298,7 +320,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
} }
commandID := strings.TrimSpace(req.CommandID) commandID := strings.TrimSpace(req.CommandID)
if 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{ resp, err := h.wallet.GrantVipTrialCard(c.Request.Context(), &walletv1.GrantVipTrialCardRequest{
CommandId: commandID, CommandId: commandID,
@ -307,7 +329,7 @@ func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
Level: req.Level, Level: req.Level,
DurationMs: req.DurationMS, DurationMs: req.DurationMS,
ResourceId: req.ResourceID, ResourceId: req.ResourceID,
GrantSource: "admin_grant", GrantSource: grantSource,
OperatorUserId: int64(middleware.CurrentUserID(c)), OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason, Reason: reason,
}) })

View File

@ -135,7 +135,7 @@ func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t
t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String())
} }
req := wallet.grantTrialRequest 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) t.Fatalf("trial grant command context mismatch: %+v", req)
} }
if req.ResourceId != nil || req.GetTargetUserId() != 318705991371722752 || req.GetDurationMs() != 1_728_000_000 { 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 { func serveVIPConfigRequest(t *testing.T, wallet walletclient.Client, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@ -174,10 +227,12 @@ type fakeVIPConfigWallet struct {
updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse
updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse
grantTrialResponse *walletv1.GrantVipTrialCardResponse grantTrialResponse *walletv1.GrantVipTrialCardResponse
grantVIPResponse *walletv1.GrantVipResponse
getProgramRequest *walletv1.GetVipProgramConfigRequest getProgramRequest *walletv1.GetVipProgramConfigRequest
updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest
updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest
grantTrialRequest *walletv1.GrantVipTrialCardRequest grantTrialRequest *walletv1.GrantVipTrialCardRequest
grantVIPRequest *walletv1.GrantVipRequest
} }
func (f *fakeVIPConfigWallet) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) { 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 f.grantTrialRequest = req
return f.grantTrialResponse, nil return f.grantTrialResponse, nil
} }
func (f *fakeVIPConfigWallet) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
f.grantVIPRequest = req
return f.grantVIPResponse, nil
}

View File

@ -20,6 +20,7 @@ var defaultPermissions = []model.Permission{
{Name: "外管用户创建", Code: "external-admin-user:create", Kind: "button", Description: "按当前 App 用户短 ID 创建外管账号"}, {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:status", Kind: "button", Description: "启用或停用当前 App 的外管账号"},
{Name: "外管用户重置密码", Code: "external-admin-user:reset-password", 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:view", Kind: "menu"},
{Name: "团队创建", Code: "team:create", Kind: "button"}, {Name: "团队创建", Code: "team:create", Kind: "button"},
{Name: "团队更新", Code: "team:update", Kind: "button"}, {Name: "团队更新", Code: "team:update", Kind: "button"},

View File

@ -127,11 +127,11 @@ func TestManagedDefaultRolePermissionMatrix(t *testing.T) {
func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead, 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-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, 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{"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, assertRolePermissions(t, roleCodeProductLead,
[]string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"}, []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) { func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper() t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)

View File

@ -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';