From 8aa5a0cd9a915614a8f1aae06d5b243f85cc9a96 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:43:19 +0800 Subject: [PATCH] =?UTF-8?q?fix=F0=9F=90=9B:=20bind=20the=20code=20generato?= =?UTF-8?q?r'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 +}