From afa8513d4b6cf218cea04f320621ae21a365c98b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:42:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix=F0=9F=90=9B:=20keep=20the=20code=20gene?= =?UTF-8?q?rator's=20writes=20inside=20their=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator joins a table's package, table and business names into the paths it writes, and wrote them with os.MkdirAll and os.WriteFile. A name carrying "..", or a symlink under the target directory pointing elsewhere, took the write wherever it led. Every file is now written through os.Root: backend files under the working directory, frontend files under gen.frontpath. A path that resolves outside its root is refused, symlinks included, and the request reports the write failure without writing anything outside. --- app/other/apis/tools/gen.go | 59 ++++++++++------ app/other/apis/tools/gen_paths_test.go | 97 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 app/other/apis/tools/gen_paths_test.go diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 689aa7f5..ad64b1dc 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -319,22 +319,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 +374,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 +555,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..f5db9fc1 --- /dev/null +++ b/app/other/apis/tools/gen_paths_test.go @@ -0,0 +1,97 @@ +package tools + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// 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 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) + } + }) + } +} From c0ca07c95b6394cadd99cfd618c93aee28c91b31 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:43:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix=F0=9F=90=9B:=20refuse=20generator=20nam?= =?UTF-8?q?es=20that=20cannot=20be=20path=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package, table and business names a table's configuration carries become directories and file names, and nothing on the server checked them; the config page's patterns were the only check. Each is now checked against what it becomes: a package name is lowercase letters and digits, a table name letters, digits and underscores, a business name an identifier starting with a lowercase letter. None allows a dot or a separator, and none is stricter than the config page. The check runs on import, where a list holding one bad name is refused whole, on save, and again before generating, so a configuration saved before this check existed is refused rather than trusted. --- app/other/apis/tools/gen.go | 6 + app/other/apis/tools/gen_paths_test.go | 124 ++++++++++++++++++++ app/other/apis/tools/sys_tables.go | 22 +++- app/other/apis/tools/sys_tables_validate.go | 41 +++++++ 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index ad64b1dc..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) diff --git a/app/other/apis/tools/gen_paths_test.go b/app/other/apis/tools/gen_paths_test.go index f5db9fc1..4eef5074 100644 --- a/app/other/apis/tools/gen_paths_test.go +++ b/app/other/apis/tools/gen_paths_test.go @@ -1,13 +1,18 @@ 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 @@ -57,6 +62,40 @@ func TestNOActionsGenWritesEveryFileInsideItsRoots(t *testing.T) { } } +// 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. @@ -95,3 +134,88 @@ func TestNOActionsGenDoesNotFollowASymlinkOutOfItsRoot(t *testing.T) { }) } } + +// 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 +} From 8aa5a0cd9a915614a8f1aae06d5b243f85cc9a96 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:43:19 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix=F0=9F=90=9B:=20bind=20the=20code=20gene?= =?UTF-8?q?rator's=20APIs=20to=20its=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A role is granted an API through its menus: saving a role writes a casbin_rule for every API bound to each menu it holds. The seed data bound none to 代码生成 or 代码生成修改, so a role given either menu received no API, which went unnoticed only because every generator route skipped the role check. Migration 1786700011000 binds the nine APIs the generator page calls and the four its edit page calls, creating any that sys_api lacks, and grants them to each role that already holds the menu. Menus are found by component, so renumbered ones are found and deleted ones skipped. Tested on SQLite, MySQL and PostgreSQL, with SQL Server in CI. --- .../version/1786700011000_gen_menu_apis.go | 126 ++++++++++++ ...86700011000_gen_menu_apis_dialects_test.go | 113 +++++++++++ .../1786700011000_gen_menu_apis_test.go | 187 ++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 cmd/migrate/migration/version/1786700011000_gen_menu_apis.go create mode 100644 cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go create mode 100644 cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go 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 +} From e3478549196fcc7c5546b1ba74ecfecad48970ae Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:43:32 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix=F0=9F=90=9B:=20check=20the=20role=20on?= =?UTF-8?q?=20the=20code=20generator's=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generator route was reachable by any account that could log in: the /gen and /db routes were in CasbinExclude, and the /sys/tables routes were mounted without AuthCheckRole. Any user could read and change any table's configuration and, in dev mode, generate files from it. All thirteen now go through AuthCheckRole. admin is let through as before; another role needs the generator's menus, whose APIs the previous commit binds and grants. The captcha, registered alongside them, stays public. --- app/other/router/gen_router.go | 14 ++- app/other/router/gen_router_authz_test.go | 141 ++++++++++++++++++++++ common/middleware/settings.go | 7 -- 3 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 app/other/router/gen_router_authz_test.go 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/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"},