Files
dujiao-next/internal/app/httpserver/rbac_coverage_test.go
T

253 lines
6.4 KiB
Go
Raw 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 httpserver
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"testing"
"github.com/dujiao-next/internal/authz"
"github.com/casbin/casbin/v3/util"
)
// TestAllAdminRoutesCoveredByBuiltinRoles 校验 admin 路由文件里的每条路由
// 都被 authz.BuiltinRoleSeeds() 中至少一条角色策略覆盖。
//
// 目的:避免新增 admin 接口时忘记同步 RBAC 预置角色,导致非超管角色无法通过
// 角色分配获得该权限(catalog UI 上能看到,但任何角色都拿不到)。
//
// 实现:静态扫描 routes_admin*.go、internal/modules/**/routes.go
// 以及 internal/platform/http/**/routes.go,
// 提取 admin/authorized/paymentProtected.METHOD("/path", ...) 调用,
// 与 builtin role seeds 用 keyMatch2 比对(与运行时 Casbin 模型一致)。
func TestAllAdminRoutesCoveredByBuiltinRoles(t *testing.T) {
routes, err := extractAdminRoutesFromSource()
if err != nil {
t.Fatalf("extract admin routes: %v", err)
}
if len(routes) == 0 {
t.Fatalf("no admin routes extracted; parser or source layout changed?")
}
t.Logf("validating RBAC coverage for %d admin routes", len(routes))
seeds := authz.BuiltinRoleSeeds()
if len(seeds) == 0 {
t.Fatalf("no builtin role seeds")
}
type policy struct {
object string
action string
}
var policies []policy
for _, seed := range seeds {
for _, p := range seed.Policies {
policies = append(policies, policy{
object: authz.NormalizeObject(p.Object),
action: authz.NormalizeAction(p.Action),
})
}
}
var uncovered []adminRoute
for _, r := range routes {
matched := false
for _, p := range policies {
if p.action != "*" && p.action != r.method {
continue
}
if util.KeyMatch2(r.object, p.object) {
matched = true
break
}
}
if !matched {
uncovered = append(uncovered, r)
}
}
if len(uncovered) > 0 {
var lines []string
for _, r := range uncovered {
lines = append(lines, " "+r.method+" "+r.object)
}
t.Fatalf("the following admin routes are not covered by any builtin role seed in authz.BuiltinRoleSeeds() — add them to the appropriate role in api/internal/authz/bootstrap.go:\n%s",
strings.Join(lines, "\n"))
}
}
type adminRoute struct {
method string
object string // 例如 "/admin/users/:id"
}
var adminRouteMethods = map[string]struct{}{
"GET": {},
"POST": {},
"PUT": {},
"PATCH": {},
"DELETE": {},
}
var adminRouteReceivers = map[string]struct{}{
"admin": {},
"authorized": {},
"paymentProtected": {},
}
var publicAdminRoutes = map[string]struct{}{
"POST /admin/login": {},
"POST /admin/login/verify-2fa": {},
}
// extractAdminRoutesFromSource 从应用 admin 路由、模块路由和平台 HTTP 路由文件中读取调用。
// 方法范围:GET / POST / PUT / PATCH / DELETE。HEAD/OPTIONS 不参与 RBAC。
func extractAdminRoutesFromSource() ([]adminRoute, error) {
_, thisFile, _, _ := runtime.Caller(0)
routerDirectory := filepath.Dir(thisFile)
sources, err := discoverAdminRouteSources(routerDirectory)
if err != nil {
return nil, err
}
if len(sources) == 0 {
return nil, os.ErrNotExist
}
seen := make(map[string]struct{})
var out []adminRoute
for _, source := range sources {
routes, err := extractAdminRoutesFromFile(source)
if err != nil {
return nil, err
}
for _, route := range routes {
key := route.method + " " + route.object
if _, public := publicAdminRoutes[key]; public {
continue
}
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
out = append(out, route)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].object == out[j].object {
return out[i].method < out[j].method
}
return out[i].object < out[j].object
})
return out, nil
}
func discoverAdminRouteSources(routerDirectory string) ([]string, error) {
adminSources, err := filepath.Glob(filepath.Join(routerDirectory, "routes_admin*.go"))
if err != nil {
return nil, err
}
sources := make([]string, 0, len(adminSources)+32)
for _, path := range adminSources {
if !strings.HasSuffix(path, "_test.go") {
sources = append(sources, path)
}
}
for _, directory := range []string{
filepath.Join(routerDirectory, "..", "..", "modules"),
filepath.Join(routerDirectory, "..", "..", "platform", "http"),
} {
if err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() && entry.Name() == "routes.go" {
sources = append(sources, path)
}
return nil
}); err != nil {
return nil, err
}
}
sort.Strings(sources)
return sources, nil
}
func TestExtractAdminRoutesIncludesPlatformHTTPRoutes(t *testing.T) {
routes, err := extractAdminRoutesFromSource()
if err != nil {
t.Fatalf("extract admin routes: %v", err)
}
for _, route := range routes {
if route.method == "GET" && route.object == "/admin/system/version/check" {
return
}
}
t.Fatal("platform HTTP route GET /admin/system/version/check was not discovered")
}
func extractAdminRoutesFromFile(path string) ([]adminRoute, error) {
fileSet := token.NewFileSet()
file, err := parser.ParseFile(fileSet, path, nil, 0)
if err != nil {
return nil, err
}
var routes []adminRoute
var routeErr error
ast.Inspect(file, func(node ast.Node) bool {
if routeErr != nil {
return false
}
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
receiver, ok := selector.X.(*ast.Ident)
if !ok {
return true
}
if _, ok := adminRouteReceivers[receiver.Name]; !ok {
return true
}
if _, ok := adminRouteMethods[selector.Sel.Name]; !ok {
return true
}
pathLiteral, ok := call.Args[0].(*ast.BasicLit)
if !ok || pathLiteral.Kind != token.STRING {
routeErr = fmt.Errorf("%s:%d: admin route path must be a string literal", path, fileSet.Position(call.Args[0].Pos()).Line)
return false
}
routePath, err := strconv.Unquote(pathLiteral.Value)
if err != nil {
routeErr = fmt.Errorf("%s:%d: decode admin route path: %w", path, fileSet.Position(pathLiteral.Pos()).Line, err)
return false
}
routes = append(routes, adminRoute{
method: selector.Sel.Name,
object: authz.NormalizeObject("/admin" + routePath),
})
return true
})
if routeErr != nil {
return nil, routeErr
}
return routes, nil
}