Files

311 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"github.com/qianfree/team-api/internal/logic/admin"
)
// adminAPIDir 是管理后台 API 定义所在目录(相对本测试文件)。
// 路由由 cmd.go 中 g.Bind(adminController.NewV1()) 从这些 g.Meta 注解注册,
// 前缀统一为 /api/admin。
const adminAPIDir = "../../api/admin/v1"
// adminRoutePrefix 与 internal/cmd/cmd.go 的 group.Group("/admin", ...) 保持一致。
const adminRoutePrefix = "/api/admin"
// manualAdminRoutes 列出不经 g.Bind(adminController) 注册、但同样挂在
// AdminPermissionGuard 之下的路由(见 internal/cmd/cmd.go 的独立路由组)。
// 这些路由没有 g.Meta 注解,AST 扫不到,需手工登记。
var manualAdminRoutes = []adminRoute{
{Method: "GET", Path: "/api/admin/files/{id}/serve"},
}
type adminRoute struct {
Method string
Path string
Source string // 来源文件:行,用于报错定位
}
// collectAdminRoutes 解析 api/admin/v1 下全部 g.Meta 注解,得到已注册的 admin 路由清单。
//
// 这里刻意用 AST 解析源码而不是 ghttp.Server.GetRoutes():后者需要导入
// internal/controller/admin,而它反向依赖 internal/middleware,会造成 import cycle。
// 解析源码同样精确(路由本就由这些注解生成),且不需要构造 server 或任何外部依赖。
func collectAdminRoutes(t *testing.T) []adminRoute {
t.Helper()
files, err := filepath.Glob(filepath.Join(adminAPIDir, "*.go"))
if err != nil {
t.Fatalf("扫描 %s 失败: %v", adminAPIDir, err)
}
fset := token.NewFileSet()
seen := make(map[string]bool)
var out []adminRoute
add := func(r adminRoute) {
key := r.Method + " " + r.Path
if seen[key] {
return
}
seen[key] = true
out = append(out, r)
}
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
t.Fatalf("解析 %s 失败: %v", file, err)
}
ast.Inspect(f, func(n ast.Node) bool {
st, ok := n.(*ast.StructType)
if !ok || st.Fields == nil {
return true
}
for _, field := range st.Fields.List {
// g.Meta 是嵌入字段(无字段名),带 path/method 标签
if len(field.Names) != 0 || field.Tag == nil {
continue
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
continue
}
tag := reflect.StructTag(raw)
path := strings.TrimSpace(tag.Get("path"))
if path == "" {
continue
}
pos := fset.Position(field.Pos())
for _, m := range strings.Split(tag.Get("method"), ",") {
m = strings.ToUpper(strings.TrimSpace(m))
if m == "" {
m = "GET" // g.Meta 省略 method 时 GoFrame 默认注册 GET
}
add(adminRoute{
Method: m,
Path: adminRoutePrefix + path,
Source: filepath.Base(pos.Filename) + ":" + strconv.Itoa(pos.Line),
})
}
}
return true
})
}
for _, r := range manualAdminRoutes {
r.Source = "internal/cmd/cmd.go(手工注册)"
add(r)
}
if len(out) == 0 {
t.Fatalf("未从 %s 解析到任何路由,测试装置失效", adminAPIDir)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Method < out[j].Method
})
return out
}
// concreteFromPattern 把路由模式里的占位段替换成具体值,
// 使其可以喂给 matchPermission(它按具体路径做前缀/后缀匹配,不认识占位符)。
//
// /api/admin/users/{id}/permissions -> /api/admin/users/1/permissions
// /api/admin/channels/:id -> /api/admin/channels/1
func concreteFromPattern(pattern string) string {
segs := strings.Split(pattern, "/")
for i, seg := range segs {
if seg == "" {
continue
}
if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") {
segs[i] = "1"
}
}
return strings.Join(segs, "/")
}
// allPermissionPoints 收集 predefinedPermissionGroups 里声明的全部权限点,
// 外加中间件自身消费的伪权限点。
func allPermissionPoints() map[string]bool {
set := map[string]bool{
// self:access 不属于任何模块分组:它表示「登录即可访问自己的资源」,
// 在 AdminPermissionGuard 里被直接放行,不参与角色授权。
"self:access": true,
}
for _, g := range admin.PermissionGroups() {
for _, p := range g.Permissions {
set[p] = true
}
}
return set
}
// TestEveryAdminRouteHasPermissionRule 保证每个受保护的 admin 接口都能匹配到权限规则。
//
// AdminPermissionGuard 对未匹配到规则的路由返回 403(默认拒绝)。若新增接口时忘了在
// adminPermissionRules 登记,低权限角色会拿到一个无法解释的 403 —— 本测试把这类遗漏
// 在 CI 阶段拦下,而不是等到线上有人点开某个页面才发现。
func TestEveryAdminRouteHasPermissionRule(t *testing.T) {
var missing []string
for _, r := range collectAdminRoutes(t) {
path := concreteFromPattern(r.Path)
if isAdminPublicPath(path) {
continue
}
// 超管专属路由(角色管理、在线自更新)由 superAdminOnlyRules 硬性拦截,
// 先于权限规则判定,不走权限点授权,因此不要求出现在 adminPermissionRules 里。
if isSuperAdminOnly(r.Method, path) {
continue
}
if perm := matchPermission(r.Method, path); perm == "" {
missing = append(missing, r.Method+" "+r.Path+" ("+r.Source+")")
}
}
if len(missing) > 0 {
t.Fatalf("以下 %d 个 admin 接口未配置权限规则(请在 adminPermissionRules 中补齐):\n %s",
len(missing), strings.Join(missing, "\n "))
}
}
// TestPermissionRulesReferenceKnownPoints 保证规则里引用的权限点都是真实存在的。
//
// 规则里写错一个字(如 channel:veiw)不会有任何编译期报错,但会造成该接口对所有
// 非超管角色永久 403 —— 因为没有任何角色能被授予一个不存在的权限点。
func TestPermissionRulesReferenceKnownPoints(t *testing.T) {
known := allPermissionPoints()
for _, rule := range adminPermissionRules {
if !known[rule.perm] {
target := rule.path
if target == "" {
target = rule.prefix + "*" + rule.suffix
}
t.Errorf("规则 %s %s 引用了未定义的权限点 %q", rule.method, target, rule.perm)
}
}
}
// TestMatchedPermissionsAreGrantable 从路由侧再验一次:
// 每个受保护接口最终要求的权限点,都必须是可以授予某个角色的。
func TestMatchedPermissionsAreGrantable(t *testing.T) {
known := allPermissionPoints()
for _, r := range collectAdminRoutes(t) {
path := concreteFromPattern(r.Path)
if isAdminPublicPath(path) {
continue
}
perm := matchPermission(r.Method, path)
if perm == "" {
continue // 由 TestEveryAdminRouteHasPermissionRule 负责报错
}
if !known[perm] {
t.Errorf("接口 %s %s 要求权限点 %q,但该权限点未在 predefinedPermissionGroups 中定义",
r.Method, r.Path, perm)
}
}
}
// writeOnViewPermExceptions 列出「写方法但语义上是读」的接口白名单。
//
// 加白之前先问一句:这个接口有没有改变任何持久状态?只有答案是「没有,用 POST 纯粹是
// 因为查询参数太复杂放不进 query string」时才可以加,并写明理由。
var writeOnViewPermExceptions = map[string]string{
// 导出模型配置为 JSON:只读取并序列化现有配置,不写库。用 POST 是因为筛选条件是
// 一个可能很大的模型 ID 数组。
"POST /api/admin/models/export-json": "纯导出,不改变任何状态",
}
// TestWriteRoutesDoNotUseViewPermissions 保证写接口不会挂在只读权限点上。
//
// 这是本次权限审计里出现最多的一类缺陷:接口按 :view 配置,于是「技术支持」这类被明确
// 定位成只读的角色,实际拥有了改密、改配置、履约订单的能力。权限点的档位语义
// (read/operate/full)只有在规则表遵守它时才成立,靠人工 review 守不住这条线。
func TestWriteRoutesDoNotUseViewPermissions(t *testing.T) {
for _, r := range collectAdminRoutes(t) {
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
continue
}
path := concreteFromPattern(r.Path)
if isAdminPublicPath(path) || isSuperAdminOnly(r.Method, path) {
continue
}
if _, ok := writeOnViewPermExceptions[r.Method+" "+r.Path]; ok {
continue
}
perm := matchPermission(r.Method, path)
if strings.HasSuffix(perm, ":view") {
t.Errorf("写接口 %s %s 挂在只读权限点 %q 上:只读角色会因此获得写能力。"+
"请改挂对应的操作/完全档权限点;若该接口确实不改变任何状态,"+
"把它加进 writeOnViewPermExceptions 并说明理由", r.Method, r.Path, perm)
}
}
}
// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。
//
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条
// 限制,一个被下放了 user:edit 的账号就能给自己挂上更高权限的角色,整套角色体系当场失效。
// 新增角色相关路由时,本测试会强制你同步登记 superAdminOnlyRules。
func TestRoleManagementIsSuperAdminOnly(t *testing.T) {
var leaked []string
for _, r := range collectAdminRoutes(t) {
path := concreteFromPattern(r.Path)
isRoleRoute := strings.HasPrefix(r.Path, "/api/admin/roles") ||
strings.HasSuffix(r.Path, "/roles")
if !isRoleRoute {
continue
}
if !isSuperAdminOnly(r.Method, path) {
leaked = append(leaked, r.Method+" "+r.Path+" ("+r.Source+")")
}
}
if len(leaked) > 0 {
t.Fatalf("以下角色管理接口未限定为超级管理员专属(请补入 superAdminOnlyRules):\n %s",
strings.Join(leaked, "\n "))
}
}
// TestSuperAdminOnlyRulesDoNotOverreach 反向确认:超管专属清单不应误伤普通账号管理。
//
// 账号管理(创建/禁用/改密/删除)按 user:* 授权、可以正常下放,只有「分配角色」才是
// 权限体系的元操作。两者路径相邻(都在 /api/admin/users/ 下),容易被前缀规则误伤。
func TestSuperAdminOnlyRulesDoNotOverreach(t *testing.T) {
shouldStayDelegatable := []struct{ method, path string }{
{"GET", "/api/admin/users"},
{"POST", "/api/admin/users"},
{"PUT", "/api/admin/users/1"},
{"PUT", "/api/admin/users/1/status"},
{"PUT", "/api/admin/users/1/reset-password"},
{"PUT", "/api/admin/users/1/unlock"},
{"DELETE", "/api/admin/users/1"},
{"GET", "/api/admin/users/1/permissions"},
}
for _, c := range shouldStayDelegatable {
if isSuperAdminOnly(c.method, c.path) {
t.Errorf("%s %s 属于普通账号管理,不应被限定为超管专属", c.method, c.path)
}
}
}