Merge pull request #955 from go-admin-team/fix/gen-path-and-authz

fix🐛: confine the code generator's writes and check roles on its routes
This commit is contained in:
wenjianzhang
2026-09-27 19:56:29 +08:00
committed by GitHub
10 changed files with 901 additions and 36 deletions
+44 -21
View File
@@ -241,6 +241,12 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool {
e.Error(500, err, err.Error())
return false
}
// Checked again here, not only on save: a configuration saved before the
// save-time check existed is still in sys_tables.
if err := validateGenPathFields(tab); err != nil {
e.Error(500, err, err.Error())
return false
}
// R2: see the matching call and comment in Preview above.
applyInferredColumnWidths(tab.Columns)
@@ -319,22 +325,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool {
// up - a flat gen/{BusinessName}.ts would let two tables in different
// packages silently overwrite each other's translations, since
// BusinessName only has a pattern check, no uniqueness check.
back, front := ".", config.GenConfig.FrontPath
files := []struct {
path string
content []byte
root, name string
content []byte
}{
{"./app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]},
{"./app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]},
{"./app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]},
{config.GenConfig.FrontPath + "/api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]},
{config.GenConfig.FrontPath + "/views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]},
{"./app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]},
{"./app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]},
{config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]},
{config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]},
{back, "app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]},
{back, "app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]},
{back, "app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]},
{front, "api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]},
{front, "views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]},
{back, "app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]},
{back, "app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]},
{front, "lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]},
{front, "lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]},
}
for _, f := range files {
if err := writeGenerated(f.path, f.content); err != nil {
if err := writeGenerated(f.root, f.name, f.content); err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("生成文件写入失败!错误详情:%s", err.Error()))
return false
@@ -373,7 +380,7 @@ func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) bool {
e.Error(500, err, fmt.Sprintf("数据迁移模版渲染失败!错误详情:%s", err.Error()))
return false
}
if err := writeGenerated("./cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil {
if err := writeGenerated(".", "cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil {
e.Logger.Error(err)
e.Error(500, err, fmt.Sprintf("数据迁移文件写入失败!错误详情:%s", err.Error()))
return false
@@ -554,14 +561,30 @@ func renderAll(data any, tpls ...*template.Template) ([][]byte, error) {
return out, nil
}
// writeGenerated writes one generated file, creating its directory first,
// which pkg.FileCreate does not do. It replaced pkg.FileCreate here while
// that returned no error and, when the file could not be created, ended the
// process with log.Fatalln - one unwritable path took the whole server down
// with it. go-admin-core v2.11.0 made pkg.FileCreate return the error.
func writeGenerated(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
// writeGenerated writes content to name, a slash-separated path under root,
// creating root and the directories in between.
//
// It goes through os.Root, so name cannot leave root: a ".." component, an
// absolute path, or a symlink under root that points outside it is refused
// rather than followed. root itself is configuration (the working directory,
// or gen.frontpath), and may be or contain a symlink.
//
// It replaced pkg.FileCreate here while that returned no error and, when the
// file could not be created, ended the process with log.Fatalln - one
// unwritable path took the whole server down with it. go-admin-core v2.11.0
// made pkg.FileCreate return the error.
func writeGenerated(root, name string, content []byte) error {
if err := os.MkdirAll(root, os.ModePerm); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
r, err := os.OpenRoot(root)
if err != nil {
return err
}
defer r.Close()
name = filepath.FromSlash(name)
if err := r.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return err
}
return r.WriteFile(name, content, 0o644)
}
+221
View File
@@ -0,0 +1,221 @@
package tools
import (
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go-admin/app/other/models/tools"
)
// The generator builds the paths it writes to from three fields of a table's
// configuration. These tests hold that none of them can take a write outside
// the working directory or gen.frontpath, and that a refused write leaves
// nothing behind - inside the roots or outside them.
// filesUnder lists every file below dir, or none when dir does not exist.
func filesUnder(t *testing.T, dir string) []string {
t.Helper()
var files []string
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
files = append(files, p)
}
return nil
})
return files
}
func TestNOActionsGenWritesEveryFileInsideItsRoots(t *testing.T) {
dir := genWorkspace(t)
var ok bool
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
if !ok || len(bodies) != 0 {
t.Fatalf("ok=%v, responses %+v; want true and no error response", ok, bodies)
}
want := []string{
"app/admin/models/gen_err.go",
"app/admin/apis/gen_err.go",
"app/admin/router/gen_err.go",
"app/admin/service/dto/gen_err.go",
"app/admin/service/gen_err.go",
"ui/api/admin/gen-err.ts",
"ui/views/admin/gen-err/index.vue",
"ui/lang/zh-CN/gen/admin/genErr.ts",
"ui/lang/en-US/gen/admin/genErr.ts",
}
for _, rel := range want {
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil {
t.Errorf("%s was not written: %v", rel, err)
}
}
if got := len(writtenFiles(t, dir)); got != len(want) {
t.Errorf("wrote %d files, want %d: %v", got, len(want), writtenFiles(t, dir))
}
}
// A configuration already in sys_tables reaches NOActionsGen as it was saved,
// so this is also the case of a row saved before the save-time check existed.
func TestNOActionsGenRefusesAPathFieldThatLeavesItsRoot(t *testing.T) {
cases := map[string]func(*tools.SysTables){
"packageName": func(tab *tools.SysTables) { tab.PackageName = "../../escaped" },
"tableName": func(tab *tools.SysTables) { tab.TBName = "../../../escaped" },
"businessName": func(tab *tools.SysTables) { tab.BusinessName = "../../../../escaped" },
"absolute": func(tab *tools.SysTables) { tab.PackageName = "/escaped" },
}
for name, spoil := range cases {
t.Run(name, func(t *testing.T) {
dir := genWorkspace(t)
tab := genTable()
spoil(&tab)
var ok bool
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, tab) }, nil)
if ok || len(bodies) != 1 || bodies[0].Code != 500 || !strings.Contains(bodies[0].Msg, "不合法") {
t.Fatalf("ok=%v, responses %+v; want false and one 500 naming the invalid field", ok, bodies)
}
if files := writtenFiles(t, dir); len(files) > 0 {
t.Errorf("wrote %v inside the workspace", files)
}
// Where each of the paths above would have landed.
for _, p := range []string{filepath.Join(dir, "..", "escaped"), filepath.Join(filepath.Dir(dir), "..", "escaped"), "/escaped"} {
if _, err := os.Stat(p); err == nil {
t.Errorf("%s exists after a refused generation", p)
}
}
})
}
}
// A symlink under a root that points out of it is the case a name check
// cannot see: every component of the name is legal. os.Root refuses to follow
// it. The file planted where the write would land must survive unchanged.
func TestNOActionsGenDoesNotFollowASymlinkOutOfItsRoot(t *testing.T) {
for _, link := range []string{"app/admin", "ui/api"} {
t.Run(link, func(t *testing.T) {
dir := genWorkspace(t)
outside := t.TempDir()
planted := filepath.Join(outside, "models", "gen_err.go")
if err := os.MkdirAll(filepath.Dir(planted), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(planted, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
at := filepath.Join(dir, filepath.FromSlash(link))
if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, at); err != nil {
t.Fatal(err)
}
var ok bool
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
if ok || len(bodies) != 1 || bodies[0].Code != 500 || !strings.Contains(bodies[0].Msg, "生成文件写入失败") {
t.Fatalf("ok=%v, responses %+v; want false and one 500 for the write", ok, bodies)
}
if b, err := os.ReadFile(planted); err != nil || string(b) != "original" {
t.Errorf("the file outside the root reads %q (err %v), want it untouched", b, err)
}
if files := filesUnder(t, outside); len(files) != 1 {
t.Errorf("files outside the root: %v, want only the planted one", files)
}
})
}
}
// putTable saves a table's configuration the way the config page does.
func putTable(t *testing.T, tab tools.SysTables) genBody {
t.Helper()
body, _ := json.Marshal(tab)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPut, "/", strings.NewReader(string(body)))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("db", genDB(t))
SysTable{}.Update(c)
var res genBody
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decoding %q: %v", w.Body.String(), err)
}
return res
}
func TestSavingAConfigurationRefusesAPathField(t *testing.T) {
db := genDB(t)
stored := genTable()
created, err := stored.Create(db)
if err != nil {
t.Fatal(err)
}
edited := created
edited.PackageName = "../escaped"
if res := putTable(t, edited); res.Code != 500 || !strings.Contains(res.Msg, "packageName") {
t.Fatalf("response %+v; want 500 naming packageName", res)
}
var row tools.SysTables
if err := db.First(&row, created.TableId).Error; err != nil {
t.Fatal(err)
}
if row.PackageName != "admin" {
t.Errorf("package_name = %q after a refused save, want it unchanged", row.PackageName)
}
}
// What the config page and the importer produce has to keep passing.
func TestValidateGenPathFieldsAcceptsWhatTheConfigPageAccepts(t *testing.T) {
for _, tab := range []tools.SysTables{
{PackageName: "admin", TBName: "sys_user", BusinessName: "sysUser"},
{PackageName: "shop2", TBName: "order2", BusinessName: "order2"},
{PackageName: "x", TBName: "T_Mixed_1", BusinessName: "tMixed1"},
} {
if err := validateGenPathFields(tab); err != nil {
t.Errorf("%+v refused: %v", tab, err)
}
}
}
// The importer reads table names from the database, which accepts names no
// file can carry. A list holding one is refused whole: the valid table
// beside it is not imported either.
func TestImportRefusesATableNameThatCannotBeAFileName(t *testing.T) {
g := newGenEnv(t)
const good, bad = "gpa_ok", "gpa-dash"
// dropFixture does not quote the name, and the point of bad is that it
// needs quoting, so the rows and tables are removed here.
drop := func() {
g.db.Unscoped().Where("table_name IN ?", []string{good, bad}).Delete(&tools.SysTables{})
for _, table := range []string{good, bad} {
g.db.Exec("DROP TABLE IF EXISTS `" + table + "`")
}
}
drop()
t.Cleanup(drop)
for _, table := range []string{good, bad} {
if err := g.db.Exec("CREATE TABLE `" + table + "` (id int NOT NULL AUTO_INCREMENT PRIMARY KEY," + auditColumns + ")").Error; err != nil {
t.Fatalf("creating %s: %v", table, err)
}
}
if code := callHandler(t, g.db, SysTable{}.Insert, "/?tables="+good+","+bad, nil); code == http.StatusOK {
t.Fatal("the import succeeded")
}
var n int64
g.db.Model(&tools.SysTables{}).Where("table_name IN ?", []string{good, bad}).Count(&n)
if n != 0 {
t.Errorf("%d table(s) imported from a refused list, want 0", n)
}
}
+18 -4
View File
@@ -172,17 +172,26 @@ func (e SysTable) Insert(c *gin.Context) {
return
}
// Every table is read and checked before any is saved, so a list with
// one table whose name cannot become a file name is refused whole
// instead of importing the tables ahead of it.
tables := make([]tools.SysTables, 0, len(tablesList))
for i := 0; i < len(tablesList); i++ {
data, err := genTableInit(db, tablesList, i, c)
if err != nil {
log.Errorf("genTableInit error, %s", err.Error())
e.Error(500, err, "")
return
}
_, err = data.Create(db)
if err != nil {
if err = validateGenPathFields(data); err != nil {
log.Errorf("validate table error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
tables = append(tables, data)
}
for i := range tables {
if _, err = tables[i].Create(db); err != nil {
log.Errorf("Create error, %s", err.Error())
e.Error(500, err, "")
return
@@ -377,6 +386,11 @@ func (e SysTable) Update(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
if err = validateGenPathFields(data); err != nil {
log.Errorf("validate table error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
log.Errorf("validate businessName error, %s", err.Error())
e.Error(500, err, err.Error())
@@ -110,3 +110,44 @@ func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, t
}
return nil
}
// The three fields gen.go joins into the paths it writes to. Each is checked
// against what it has to be where it lands, not against one shared pattern:
//
// - packageName names a Go package and the app/{packageName} directory.
// Lowercase letters and digits, as a Go package name should be; no
// hyphen, which Go rejects, and no underscore, which genInfoForm.vue
// already refuses.
// - tableName is the imported table's own name, and becomes a .go file name
// and, with "_" turned into "-", a .ts/.vue one. Letters, digits and
// underscores: what a table the importer can read is normally called, and
// nothing that can step out of a directory.
// - businessName is a JavaScript identifier and a .ts file name. It starts
// with a lowercase letter, as genInfoForm.vue requires, but may carry
// digits, which a name derived from a table such as order2 does.
//
// None of the three allows a dot or a path separator, so none can name a
// parent directory or an absolute path. The rules are no stricter than the
// config page's own, so nothing it accepts is refused here.
var (
packageNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
tableNamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
businessNamePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
)
// validateGenPathFields checks the fields gen.go builds file paths from. It
// runs where a configuration is saved and again before files are written, so
// a row saved before this check existed is refused at generation rather than
// trusted because it is already in the database.
func validateGenPathFields(tab tools.SysTables) error {
if !packageNamePattern.MatchString(tab.PackageName) {
return fmt.Errorf("packageName=%q 不合法:只能包含小写字母和数字,且以字母开头", tab.PackageName)
}
if !tableNamePattern.MatchString(tab.TBName) {
return fmt.Errorf("tableName=%q 不合法:只能包含字母、数字和下划线", tab.TBName)
}
if !businessNamePattern.MatchString(tab.BusinessName) {
return fmt.Errorf("businessName=%q 不合法:只能包含字母和数字,且以小写字母开头", tab.BusinessName)
}
return nil
}
+10 -4
View File
@@ -7,6 +7,7 @@ import (
"go-admin/app/admin/apis"
"go-admin/app/other/apis/tools"
"go-admin/common/middleware"
)
// GenWriteRoutesEnabled reports whether the code generator's writing endpoints
@@ -52,7 +53,12 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
r1.GET("/captcha", sys.GenerateCaptchaHandler)
}
r := v1.Group("").Use(authMiddleware.MiddlewareFunc())
// AuthCheckRole on every generator route: a role reaches them through the
// code generator's menus, which the 1786700011000 migration binds them to.
// They used to be in CasbinExclude, which let any account that could log
// in read and change any table's configuration and, in dev mode, write
// files from it.
r := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
gen := tools.Gen{}
r.GET("/gen/preview/:tableId", gen.Preview)
@@ -67,7 +73,7 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
}
func registerDBRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
db := v1.Group("/db").Use(authMiddleware.MiddlewareFunc())
db := v1.Group("/db").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
gen := tools.Gen{}
db.GET("/tables/page", gen.GetDBTableList)
@@ -79,8 +85,8 @@ func registerSysTableRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl
tables := v1.Group("/sys/tables")
{
sysTable := tools.SysTable{}
tables.Group("").Use(authMiddleware.MiddlewareFunc()).GET("/page", sysTable.GetPage)
tablesInfo := tables.Group("/info").Use(authMiddleware.MiddlewareFunc())
tables.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).GET("/page", sysTable.GetPage)
tablesInfo := tables.Group("/info").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
tablesInfo.POST("", sysTable.Insert)
tablesInfo.PUT("", sysTable.Update)
+141
View File
@@ -0,0 +1,141 @@
package router
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
// Every route the code generator registers, as a request and as the policy
// that grants it.
var genRoutes = []struct{ method, path, policy string }{
{"GET", "/api/v1/gen/preview/1", "/api/v1/gen/preview/:tableId"},
{"GET", "/api/v1/gen/toproject/1", "/api/v1/gen/toproject/:tableId"},
{"GET", "/api/v1/gen/apitofile/1", "/api/v1/gen/apitofile/:tableId"},
{"GET", "/api/v1/gen/todb/1", "/api/v1/gen/todb/:tableId"},
{"GET", "/api/v1/gen/tabletree", "/api/v1/gen/tabletree"},
{"GET", "/api/v1/db/tables/page", "/api/v1/db/tables/page"},
{"GET", "/api/v1/db/columns/page", "/api/v1/db/columns/page"},
{"GET", "/api/v1/sys/tables/page", "/api/v1/sys/tables/page"},
{"GET", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
{"GET", "/api/v1/sys/tables/info/1", "/api/v1/sys/tables/info/:tableId"},
{"POST", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
{"PUT", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
{"DELETE", "/api/v1/sys/tables/info/1", "/api/v1/sys/tables/info/:tableId"},
}
// genAuthzEngine serves the generator's routes behind a real JWT middleware
// and a real casbin enforcer for host, holding the policies given.
func genAuthzEngine(t *testing.T, host string, grant func(add func(role, path, method string))) (*gin.Engine, *jwt.GinJWTMiddleware) {
t.Helper()
gin.SetMode(gin.TestMode)
previousMode := config.ApplicationConfig.Mode
config.ApplicationConfig.Mode = "dev" // the writing routes exist only here
t.Cleanup(func() { config.ApplicationConfig.Mode = previousMode })
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
previousInterval := mycasbin.ReloadInterval
mycasbin.ReloadInterval = 0
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
e := mycasbin.Setup(db, host)
grant(func(role, path, method string) {
if _, err := e.AddPolicy(role, path, method); err != nil {
t.Fatal(err)
}
})
previous := sdk.Runtime.GetCasbinByTenant(host)
sdk.Runtime.SetCasbinByTenant(host, e)
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(host, previous) })
mw, err := jwt.New(&jwt.GinJWTMiddleware{
Realm: "test",
Key: []byte("test-key"),
Timeout: time.Hour,
PayloadFunc: func(data interface{}) jwt.MapClaims { return data.(jwt.MapClaims) },
TokenLookup: "header: Authorization",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
})
if err != nil {
t.Fatal(err)
}
r := gin.New()
// A request AuthCheckRole lets through reaches a handler with no logger or
// database in its context, which panics. Recovery turns that into a 500:
// past the check, which is all these tests ask.
r.Use(gin.RecoveryWithWriter(io.Discard))
v1 := r.Group("/api/v1")
sysNoCheckRoleRouter(v1, mw)
registerDBRouter(v1, mw)
registerSysTableRouter(v1, mw)
return r, mw
}
// deniedByRole reports whether AuthCheckRole refused the request. It answers
// HTTP 200 with code 403 in the body; anything else got past it, even if the
// handler behind it then failed for want of a database.
func deniedByRole(t *testing.T, r *gin.Engine, mw *jwt.GinJWTMiddleware, host, role, method, path string) bool {
t.Helper()
token, _, err := mw.TokenGenerator(jwt.MapClaims{"identity": 7, "rolekey": role, "roleid": 2})
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
req.Host = host
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var body struct{ Code int }
_ = json.Unmarshal(w.Body.Bytes(), &body)
return body.Code == 403 && strings.Contains(w.Body.String(), "没有该接口访问权限")
}
func TestGeneratorRoutesRequireARoleThatHoldsThem(t *testing.T) {
const host = "gen-authz.test"
r, mw := genAuthzEngine(t, host, func(add func(role, path, method string)) {
for _, route := range genRoutes {
add("developer", route.policy, route.method)
}
})
for _, route := range genRoutes {
name := route.method + " " + route.path
if !deniedByRole(t, r, mw, host, "clerk", route.method, route.path) {
t.Errorf("%s: a role holding no policy got past AuthCheckRole", name)
}
if deniedByRole(t, r, mw, host, "developer", route.method, route.path) {
t.Errorf("%s: a role holding the policy was refused", name)
}
if deniedByRole(t, r, mw, host, "admin", route.method, route.path) {
t.Errorf("%s: admin was refused", name)
}
}
}
// The captcha shares a registration function with the generator's routes and
// has to stay reachable before anyone has logged in.
func TestCaptchaStaysPublic(t *testing.T) {
r, _ := genAuthzEngine(t, "gen-authz-captcha.test", func(func(string, string, string)) {})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/captcha", nil))
if w.Code == http.StatusUnauthorized || strings.Contains(w.Body.String(), "没有该接口访问权限") {
t.Errorf("captcha answered %d %s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,126 @@
package version
import (
"errors"
"runtime"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Bind the code generator's APIs to its two menus, and grant them to every
// role that already holds one of those menus.
//
// The generator's routes were in CasbinExclude, or mounted without
// AuthCheckRole, so any account that could log in reached them. They now go
// through AuthCheckRole. A role is granted an API through the menus it is
// given: SysRole's update writes a casbin_rule for every API bound to each of
// its menus in sys_menu_api_rule. The seed data bound none to the
// generator's menus, so without this a role given 代码生成 would be refused
// by every endpoint the page calls.
//
// Binding alone only reaches roles saved after this migration. Roles that
// already hold a generator menu are granted here, so a deployment that gave
// the generator to a role keeps it working across the upgrade. The admin
// role is let through by AuthCheckRole without a policy and needs none.
//
// Menus are found by component, not id: a deployment may have renumbered
// them, and one that deleted a menu has nobody to grant and nothing to bind.
// An API missing from sys_api is created, since its row is what the binding
// points at.
//
// Ordered after 1786700003000 (the soft-delete conversion), so the runtime
// models under app/admin/models are used, not cmd/migrate/migration/models -
// see schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700011000GenMenuApis)
}
// genMenuApis is which page calls which endpoint, read off go-admin-ui's
// src/api/tools/gen.ts and the views under dev-tools/gen.
var genMenuApis = []struct {
component string
apis []models.SysApi
}{
{"/dev-tools/gen/index", []models.SysApi{
{Title: "代码生成表列表", Path: "/api/v1/sys/tables/page", Action: "GET"},
{Title: "数据库表列表", Path: "/api/v1/db/tables/page", Action: "GET"},
{Title: "数据表列列表", Path: "/api/v1/db/columns/page", Action: "GET"},
{Title: "导入表", Path: "/api/v1/sys/tables/info", Action: "POST"},
{Title: "删除表配置", Path: "/api/v1/sys/tables/info/:tableId", Action: "DELETE"},
{Title: "生成预览通过id获取", Path: "/api/v1/gen/preview/:tableId", Action: "GET"},
{Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET"},
{Title: "生成api带文件", Path: "/api/v1/gen/apitofile/:tableId", Action: "GET"},
{Title: "数据库表生成到DB", Path: "/api/v1/gen/todb/:tableId", Action: "GET"},
}},
{"/dev-tools/gen/editTable", []models.SysApi{
{Title: "表配置详情", Path: "/api/v1/sys/tables/info/:tableId", Action: "GET"},
{Title: "按表名查询表配置", Path: "/api/v1/sys/tables/info", Action: "GET"},
{Title: "修改表配置", Path: "/api/v1/sys/tables/info", Action: "PUT"},
{Title: "关系表数据【代码生成】", Path: "/api/v1/gen/tabletree", Action: "GET"},
}},
}
func _1786700011000GenMenuApis(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := bindGenMenuApis(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// bindGenMenuApis is split out so tests can run it without sys_migration.
func bindGenMenuApis(tx *gorm.DB) error {
for _, m := range genMenuApis {
var menu models.SysMenu
err := tx.Where("component = ?", m.component).First(&menu).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
if err != nil {
return err
}
apis := make([]models.SysApi, 0, len(m.apis))
for _, want := range m.apis {
var api models.SysApi
err := tx.Where("path = ? AND action = ?", want.Path, want.Action).First(&api).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
api = want
api.Type = "SYS"
err = tx.Create(&api).Error
}
if err != nil {
return err
}
apis = append(apis, api)
}
if err := tx.Model(&menu).Association("SysApi").Append(apis); err != nil {
return err
}
var roleKeys []string
if err := tx.Model(&models.SysRole{}).
Joins("JOIN sys_role_menu ON sys_role_menu.role_id = sys_role.role_id").
Where("sys_role_menu.menu_id = ?", menu.MenuId).
Distinct().Pluck("sys_role.role_key", &roleKeys).Error; err != nil {
return err
}
for _, key := range roleKeys {
for _, a := range apis {
if err := tx.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
key, a.Path, a.Action, key, a.Path, a.Action,
).Error; err != nil {
return err
}
}
}
}
return nil
}
@@ -0,0 +1,113 @@
package version
import (
"os"
"testing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
)
// bindGenMenuApis writes casbin_rule with INSERT ... SELECT ... WHERE NOT
// EXISTS and sys_menu_api_rule through gorm's association code; both are
// dialect-sensitive, and the other tests for it run on SQLite only. This runs
// the same grant on every database CI has.
const mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN"
func mysqlDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv(mysqlDSNEnv)
if dsn == "" {
if os.Getenv("CI") != "" {
t.Fatalf("%s is not set while CI is: the MySQL migration tests must not skip here", mysqlDSNEnv)
}
t.Skipf("%s is not set; skipping the MySQL migration tests", mysqlDSNEnv)
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("connecting to %s: %v", mysqlDSNEnv, err)
}
return db
}
// casbinRuleRow is the shape gorm-adapter gives casbin_rule, declared here so
// each dialect builds the table its own way.
type casbinRuleRow struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Ptype string `gorm:"size:100"`
V0 string `gorm:"size:100"`
V1 string `gorm:"size:100"`
V2 string `gorm:"size:100"`
V3 string `gorm:"size:100"`
V4 string `gorm:"size:100"`
V5 string `gorm:"size:100"`
}
func (casbinRuleRow) TableName() string { return "casbin_rule" }
func grantOn(t *testing.T, db *gorm.DB) {
t.Helper()
tables := []any{&casbinRuleRow{}, &adminmodels.SysApi{}, &adminmodels.SysMenu{}, &adminmodels.SysRole{}}
drop := func() {
for _, join := range []string{"sys_menu_api_rule", "sys_role_menu", "sys_role_dept"} {
if db.Migrator().HasTable(join) {
if err := db.Migrator().DropTable(join); err != nil {
t.Fatal(err)
}
}
}
for _, m := range tables {
if db.Migrator().HasTable(m) {
if err := db.Migrator().DropTable(m); err != nil {
t.Fatal(err)
}
}
}
}
drop()
t.Cleanup(drop)
if err := db.AutoMigrate(tables...); err != nil {
t.Fatal(err)
}
for _, m := range []adminmodels.SysMenu{
{MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"},
{MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"},
} {
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
}
var gen adminmodels.SysMenu
if err := db.Where("component = ?", "/dev-tools/gen/index").First(&gen).Error; err != nil {
t.Fatal(err)
}
role := adminmodels.SysRole{RoleKey: "developer", RoleName: "developer"}
if err := db.Create(&role).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", role.RoleId, gen.MenuId).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := bindGenMenuApis(db); err != nil {
t.Fatalf("run %d: %v", i+1, err)
}
}
var n int64
db.Model(&casbinRuleRow{}).Where("ptype = 'p' AND v0 = ?", "developer").Count(&n)
if n != 9 {
t.Errorf("developer holds %d policies after two runs, want 9", n)
}
if bound := db.Model(&gen).Association("SysApi").Count(); bound != 9 {
t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", bound)
}
}
func TestGenMenuApisOnMySQL(t *testing.T) { grantOn(t, mysqlDB(t)) }
func TestGenMenuApisOnPostgres(t *testing.T) { grantOn(t, postgresDB(t)) }
func TestGenMenuApisOnSQLServer(t *testing.T) { grantOn(t, sqlserverDB(t)) }
@@ -0,0 +1,187 @@
package version
import (
"sort"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"go-admin/app/admin/models"
)
const (
genMenuId = 261
editMenuId = 262
)
// openGenMenuDB builds the tables bindGenMenuApis reads and writes, with the
// two generator menus under the ids config/db.sql gives them.
func openGenMenuDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(new(models.SysApi), new(models.SysMenu), new(models.SysRole)); err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TABLE casbin_rule (id integer primary key autoincrement,
ptype text, v0 text, v1 text, v2 text, v3 text, v4 text, v5 text)`).Error; err != nil {
t.Fatal(err)
}
for _, m := range []models.SysMenu{
{MenuId: genMenuId, MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"},
{MenuId: editMenuId, MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"},
} {
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func addRole(t *testing.T, db *gorm.DB, id int, key string, menus ...int) {
t.Helper()
if err := db.Create(&models.SysRole{RoleId: id, RoleKey: key, RoleName: key}).Error; err != nil {
t.Fatal(err)
}
for _, m := range menus {
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", id, m).Error; err != nil {
t.Fatal(err)
}
}
}
func policies(t *testing.T, db *gorm.DB, role string) []string {
t.Helper()
var rows []struct{ V1, V2 string }
if err := db.Raw("SELECT v1, v2 FROM casbin_rule WHERE ptype = 'p' AND v0 = ?", role).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.V2+" "+r.V1)
}
sort.Strings(out)
return out
}
func boundApis(t *testing.T, db *gorm.DB, menuId int) int {
t.Helper()
menu := models.SysMenu{MenuId: menuId}
return int(db.Model(&menu).Association("SysApi").Count())
}
func TestGenMenuApisGrantsWhatARoleAlreadyHolds(t *testing.T) {
db := openGenMenuDB(t)
addRole(t, db, 2, "developer", genMenuId, editMenuId)
addRole(t, db, 3, "editor", editMenuId)
addRole(t, db, 4, "clerk")
if err := bindGenMenuApis(db); err != nil {
t.Fatal(err)
}
if n := boundApis(t, db, genMenuId); n != 9 {
t.Errorf("代码生成 is bound to %d APIs, want 9", n)
}
if n := boundApis(t, db, editMenuId); n != 4 {
t.Errorf("代码生成修改 is bound to %d APIs, want 4", n)
}
if got := policies(t, db, "developer"); len(got) != 13 {
t.Errorf("developer holds %d policies, want 13: %v", len(got), got)
}
want := []string{
"GET /api/v1/gen/tabletree",
"GET /api/v1/sys/tables/info",
"GET /api/v1/sys/tables/info/:tableId",
"PUT /api/v1/sys/tables/info",
}
if got := policies(t, db, "editor"); !equal(got, want) {
t.Errorf("editor holds %v, want %v", got, want)
}
if got := policies(t, db, "clerk"); len(got) != 0 {
t.Errorf("clerk, who holds neither menu, was granted %v", got)
}
}
// The seed data already registers most of these APIs. Binding must point at
// those rows, not add a second row per API next to each.
func TestGenMenuApisReusesRegisteredApis(t *testing.T) {
db := openGenMenuDB(t)
seeded := models.SysApi{Id: 32, Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET", Type: "SYS"}
if err := db.Create(&seeded).Error; err != nil {
t.Fatal(err)
}
if err := bindGenMenuApis(db); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.SysApi{}).Where("path = ? AND action = ?", seeded.Path, seeded.Action).Count(&n)
if n != 1 {
t.Errorf("%d rows for %s, want the seeded one only", n, seeded.Path)
}
var total int64
db.Model(&models.SysApi{}).Count(&total)
if total != 13 {
t.Errorf("sys_api holds %d rows, want 13: the 12 missing created and the seeded one reused", total)
}
}
func TestGenMenuApisRunTwiceAddsNothing(t *testing.T) {
db := openGenMenuDB(t)
addRole(t, db, 2, "developer", genMenuId, editMenuId)
for i := 0; i < 2; i++ {
if err := bindGenMenuApis(db); err != nil {
t.Fatalf("run %d: %v", i+1, err)
}
}
if n := boundApis(t, db, genMenuId); n != 9 {
t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", n)
}
if got := policies(t, db, "developer"); len(got) != 13 {
t.Errorf("developer holds %d policies after two runs, want 13", len(got))
}
}
func TestGenMenuApisSkipsADeletedMenuAndADeletedRole(t *testing.T) {
db := openGenMenuDB(t)
if err := db.Delete(&models.SysMenu{MenuId: editMenuId}).Error; err != nil {
t.Fatal(err)
}
addRole(t, db, 5, "gone", genMenuId)
if err := db.Model(&models.SysRole{}).Where("role_id = ?", 5).
Update("deleted_at", time.Now().UnixMilli()).Error; err != nil {
t.Fatal(err)
}
if err := bindGenMenuApis(db); err != nil {
t.Fatal(err)
}
if n := boundApis(t, db, editMenuId); n != 0 {
t.Errorf("a deleted menu was bound to %d APIs", n)
}
if got := policies(t, db, "gone"); len(got) != 0 {
t.Errorf("a deleted role was granted %v", got)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
-7
View File
@@ -10,13 +10,6 @@ var CasbinExclude = []UrlInfo{
{Url: "/api/v1/dict/type-option-select", Method: "GET"},
{Url: "/api/v1/dict-data/option-select", Method: "GET"},
{Url: "/api/v1/deptTree", Method: "GET"},
{Url: "/api/v1/db/tables/page", Method: "GET"},
{Url: "/api/v1/db/columns/page", Method: "GET"},
{Url: "/api/v1/gen/toproject/:tableId", Method: "GET"},
{Url: "/api/v1/gen/todb/:tableId", Method: "GET"},
{Url: "/api/v1/gen/tabletree", Method: "GET"},
{Url: "/api/v1/gen/preview/:tableId", Method: "GET"},
{Url: "/api/v1/gen/apitofile/:tableId", Method: "GET"},
{Url: "/api/v1/getCaptcha", Method: "GET"},
{Url: "/api/v1/getinfo", Method: "GET"},
{Url: "/api/v1/menuTreeselect", Method: "GET"},