diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 689aa7f5..47b0cf8c 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -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) } diff --git a/app/other/apis/tools/gen_paths_test.go b/app/other/apis/tools/gen_paths_test.go new file mode 100644 index 00000000..4eef5074 --- /dev/null +++ b/app/other/apis/tools/gen_paths_test.go @@ -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) + } +} diff --git a/app/other/apis/tools/sys_tables.go b/app/other/apis/tools/sys_tables.go index e8c84fea..8165f4ce 100644 --- a/app/other/apis/tools/sys_tables.go +++ b/app/other/apis/tools/sys_tables.go @@ -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()) diff --git a/app/other/apis/tools/sys_tables_validate.go b/app/other/apis/tools/sys_tables_validate.go index 067f6aaa..ba6576c4 100644 --- a/app/other/apis/tools/sys_tables_validate.go +++ b/app/other/apis/tools/sys_tables_validate.go @@ -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 +} diff --git a/app/other/router/gen_router.go b/app/other/router/gen_router.go index 6b8e39a4..6bb37efe 100644 --- a/app/other/router/gen_router.go +++ b/app/other/router/gen_router.go @@ -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) diff --git a/app/other/router/gen_router_authz_test.go b/app/other/router/gen_router_authz_test.go new file mode 100644 index 00000000..3de895e3 --- /dev/null +++ b/app/other/router/gen_router_authz_test.go @@ -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()) + } +} diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go new file mode 100644 index 00000000..db5f005a --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go @@ -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 +} diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go new file mode 100644 index 00000000..fab2726d --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go @@ -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)) } diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go new file mode 100644 index 00000000..c3f96d1f --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go @@ -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 +} diff --git a/common/middleware/settings.go b/common/middleware/settings.go index 821da8e4..f504ad4c 100644 --- a/common/middleware/settings.go +++ b/common/middleware/settings.go @@ -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"},