diff --git a/cmd/migrate/migration/version/1786700012000_bind_authz_apis.go b/cmd/migrate/migration/version/1786700012000_bind_authz_apis.go new file mode 100644 index 00000000..d6f1f880 --- /dev/null +++ b/cmd/migrate/migration/version/1786700012000_bind_authz_apis.go @@ -0,0 +1,98 @@ +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 APIs that were mounted without AuthCheckRole to the menus that +// expose them, and grant them to every role that already holds one of those +// menus. +// +// /job/start, /job/remove and /role-status now go through AuthCheckRole. The +// seed data registered them in sys_api but bound none to a menu, so a role +// saved through the menu tree could never be granted them. Binding only +// reaches roles saved after this migration; roles that already hold a menu +// are granted here so an upgrade does not take the buttons away from them. +// The admin role is let through by AuthCheckRole and needs no policy. +// +// Menus are found by permission, not id, for the same reason as in +// 1786700011000. start has no menu of its own in the seed, so it follows +// job:sysJob:edit as well as job:sysJob:start where a deployment added one. +// +// Ordered after 1786700003000, so the runtime models are used. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700012000BindAuthzApis) +} + +var authzMenuApis = []struct { + permissions []string + api models.SysApi +}{ + {[]string{"admin:sysRole:update"}, models.SysApi{Title: "角色状态修改", Path: "/api/v1/role-status", Action: "PUT"}}, + {[]string{"job:sysJob:remove"}, models.SysApi{Title: "job移除", Path: "/api/v1/job/remove/:id", Action: "GET"}}, + {[]string{"job:sysJob:start", "job:sysJob:edit"}, models.SysApi{Title: "job启动", Path: "/api/v1/job/start/:id", Action: "GET"}}, +} + +func _1786700012000BindAuthzApis(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := bindAuthzApis(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +// bindAuthzApis is split out so tests can run it without sys_migration. +func bindAuthzApis(tx *gorm.DB) error { + for _, b := range authzMenuApis { + var menus []models.SysMenu + if err := tx.Where("permission IN ?", b.permissions).Find(&menus).Error; err != nil { + return err + } + if len(menus) == 0 { + continue + } + + var api models.SysApi + err := tx.Where("path = ? AND action = ?", b.api.Path, b.api.Action).First(&api).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + api = b.api + api.Type = "BUS" + err = tx.Create(&api).Error + } + if err != nil { + return err + } + + for i := range menus { + if err := tx.Model(&menus[i]).Association("SysApi").Append(&api); 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 = ?", menus[i].MenuId). + Distinct().Pluck("sys_role.role_key", &roleKeys).Error; err != nil { + return err + } + for _, key := range roleKeys { + 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, api.Path, api.Action, key, api.Path, api.Action, + ).Error; err != nil { + return err + } + } + } + } + return nil +} diff --git a/cmd/migrate/migration/version/1786700012000_bind_authz_apis_test.go b/cmd/migrate/migration/version/1786700012000_bind_authz_apis_test.go new file mode 100644 index 00000000..5f7e1fd3 --- /dev/null +++ b/cmd/migrate/migration/version/1786700012000_bind_authz_apis_test.go @@ -0,0 +1,87 @@ +package version + +import ( + "testing" + + "go-admin/app/admin/models" +) + +const ( + roleUpdateMenuId = 226 + jobEditMenuId = 463 + jobRemoveMenuId = 464 +) + +func TestBindAuthzApisGrantsWhatARoleAlreadyHolds(t *testing.T) { + db := openGenMenuDB(t) + for _, m := range []models.SysMenu{ + {MenuId: roleUpdateMenuId, MenuName: "修改角色", Permission: "admin:sysRole:update", MenuType: "F"}, + {MenuId: jobEditMenuId, MenuName: "修改定时任务", Permission: "job:sysJob:edit", MenuType: "F"}, + {MenuId: jobRemoveMenuId, MenuName: "删除定时任务", Permission: "job:sysJob:remove", MenuType: "F"}, + } { + if err := db.Create(&m).Error; err != nil { + t.Fatal(err) + } + } + addRole(t, db, 2, "roleAdmin", roleUpdateMenuId) + addRole(t, db, 3, "jobEditor", jobEditMenuId) + addRole(t, db, 4, "jobRemover", jobRemoveMenuId) + addRole(t, db, 5, "clerk") + + if err := bindAuthzApis(db); err != nil { + t.Fatal(err) + } + + for role, want := range map[string][]string{ + "roleAdmin": {"PUT /api/v1/role-status"}, + "jobEditor": {"GET /api/v1/job/start/:id"}, + "jobRemover": {"GET /api/v1/job/remove/:id"}, + "clerk": nil, + } { + if got := policies(t, db, role); !equal(got, want) { + t.Errorf("%s holds %v, want %v", role, got, want) + } + } +} + +func TestBindAuthzApisReusesSeededRowAndIsIdempotent(t *testing.T) { + db := openGenMenuDB(t) + if err := db.Create(&models.SysMenu{MenuId: roleUpdateMenuId, Permission: "admin:sysRole:update", MenuType: "F"}).Error; err != nil { + t.Fatal(err) + } + seeded := models.SysApi{Id: 153, Path: "/api/v1/role-status", Action: "PUT"} + if err := db.Create(&seeded).Error; err != nil { + t.Fatal(err) + } + addRole(t, db, 2, "roleAdmin", roleUpdateMenuId) + + for i := 0; i < 2; i++ { + if err := bindAuthzApis(db); err != nil { + t.Fatalf("run %d: %v", i+1, err) + } + } + + var n int64 + db.Model(&models.SysApi{}).Where("path = ?", seeded.Path).Count(&n) + if n != 1 { + t.Errorf("%d rows for %s, want the seeded one only", n, seeded.Path) + } + if got := boundApis(t, db, roleUpdateMenuId); got != 1 { + t.Errorf("menu bound to %d APIs after two runs, want 1", got) + } + if got := policies(t, db, "roleAdmin"); len(got) != 1 { + t.Errorf("roleAdmin holds %v after two runs, want one policy", got) + } +} + +func TestBindAuthzApisSkipsMenusThatDoNotExist(t *testing.T) { + db := openGenMenuDB(t) + if err := bindAuthzApis(db); err != nil { + t.Fatal(err) + } + var n int64 + db.Model(&models.SysApi{}).Count(&n) + if n != 0 { + t.Errorf("%d APIs created with no menu to bind, want 0", n) + } +}