fix(#156): reconcile purchaser permissions at startup

This commit is contained in:
QiuSW
2026-08-29 16:30:23 +08:00
parent cc326ebc6c
commit 7c18159d70
4 changed files with 233 additions and 2 deletions
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: fdc3e2871913ea7c64fa9729296ce41273866c2b
synchronized_at: 2026-08-29T08:00:07Z
wiki_revision: d0222d4337992bedcf2b973bc3bb9d43922c0ea3
synchronized_at: 2026-08-29T08:29:05Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -294,3 +294,11 @@ PddProductDetailCollector
- 迁移保留页面菜单 ID、路由、组件和 API 关联,移除旧的 11 个一级模块根菜单;导航固定为“分组 → 页面”两级,不增加第三级。
- Admin 角色继续通过角色菜单查询取得全部 GoAuto 页面;采购员获得两个父组和除 AI 规格匹配外的 10 个页面,AI 页面及入口均不可见。
- Web 继续通过 `/api/v1/menurole` 动态生成路由;直接访问组内页面时展开对应父组并高亮当前页面。
## GoAuto 采购员 API 权限启动对账(#156)
- `server/app/goauto/access/purchaser.go` 的 `AdminAPIs` 是 GoAuto 管理接口与采购员授权矩阵的唯一代码事实源;`PurchaserAPIs()` 只筛选其中明确标记为采购员可用的条目。
- API 服务在注册路由和监听端口之前调用 `access.ReconcilePurchaserPermissions`:补齐 `sys_api` 缺失项,并在单一事务内只删除、重建 `casbin_rule` 中 `ptype=p, v0=purchaser` 的策略。其他角色、自建策略和菜单绑定不在对账范围。
- 对账每次服务启动执行且幂等;代码新增采购员接口后无需补丁迁移,重启即可补齐;代码减权后旧采购员策略会被清除。
- 任一数据库对账失败时,API 启动直接返回错误,不注册路由、不监听端口,避免权限矩阵未对齐时继续提供服务。既有版本化迁移保留其历史语义,但不再是运行时权限同步的唯一入口。
+89
View File
@@ -0,0 +1,89 @@
package access
import "gorm.io/gorm"
// ReconcilePurchaserPermissions makes the reviewed GoAuto API matrix the
// runtime source of truth for the purchaser role. The transaction is
// deliberately fail-closed: callers must not start serving requests when the
// role, API catalogue and Casbin policies cannot be aligned atomically.
func ReconcilePurchaserPermissions(db *gorm.DB) error {
return reconcilePurchaserPermissions(db, AdminAPIs)
}
func reconcilePurchaserPermissions(db *gorm.DB, permissions []APIPermission) error {
return db.Transaction(func(tx *gorm.DB) error {
role := purchaserRole{}
if err := tx.Where("role_key = ?", RolePurchaser).
Assign(purchaserRole{
RoleName: "采购员", Status: "2", RoleSort: 20, Admin: false,
DataScope: "1", Remark: "GoAuto 采购业务角色(系统维护)",
}).FirstOrCreate(&role, purchaserRole{RoleKey: RolePurchaser}).Error; err != nil {
return err
}
for _, permission := range permissions {
api := purchaserAPI{}
if err := tx.Where(purchaserAPI{Path: permission.Path, Action: permission.Method}).
Attrs(purchaserAPI{Title: permission.Title, Type: "BUS"}).
FirstOrCreate(&api).Error; err != nil {
return err
}
}
if err := tx.Where("ptype = ? AND v0 = ?", "p", RolePurchaser).
Delete(&purchaserPolicy{}).Error; err != nil {
return err
}
for _, permission := range permissions {
if !permission.Purchaser {
continue
}
policy := purchaserPolicy{
Ptype: "p", V0: RolePurchaser, V1: permission.Path, V2: permission.Method,
}
if err := tx.Create(&policy).Error; err != nil {
return err
}
}
return nil
})
}
// These private models intentionally cover only columns needed by the
// reconciler. Keeping them here avoids coupling runtime startup to historical
// migration packages while targeting the same production tables.
type purchaserRole struct {
RoleID int `gorm:"column:role_id;primaryKey;autoIncrement"`
RoleName string `gorm:"column:role_name;size:128"`
Status string `gorm:"column:status;size:4"`
RoleKey string `gorm:"column:role_key;size:128"`
RoleSort int `gorm:"column:role_sort"`
Remark string `gorm:"column:remark;size:255"`
Admin bool `gorm:"column:admin"`
DataScope string `gorm:"column:data_scope;size:128"`
}
func (purchaserRole) TableName() string { return "sys_role" }
type purchaserAPI struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
Title string `gorm:"column:title;size:128"`
Path string `gorm:"column:path;size:128"`
Type string `gorm:"column:type;size:16"`
Action string `gorm:"column:action;size:16"`
}
func (purchaserAPI) TableName() string { return "sys_api" }
type purchaserPolicy struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
Ptype string `gorm:"column:ptype;size:100"`
V0 string `gorm:"column:v0;size:100"`
V1 string `gorm:"column:v1;size:100"`
V2 string `gorm:"column:v2;size:100"`
V3 string `gorm:"column:v3;size:100"`
V4 string `gorm:"column:v4;size:100"`
V5 string `gorm:"column:v5;size:100"`
}
func (purchaserPolicy) TableName() string { return "casbin_rule" }
+128
View File
@@ -0,0 +1,128 @@
package access
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestReconcilePurchaserPermissions(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&purchaserRole{}, &purchaserAPI{}, &purchaserPolicy{}); err != nil {
t.Fatal(err)
}
custom := purchaserPolicy{Ptype: "p", V0: "custom-role", V1: "/custom", V2: "GET"}
if err = db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err = ReconcilePurchaserPermissions(db); err != nil {
t.Fatal(err)
}
if err = ReconcilePurchaserPermissions(db); err != nil {
t.Fatalf("reconcile must be repeatable: %v", err)
}
var apiCount int64
if err = db.Model(&purchaserAPI{}).Count(&apiCount).Error; err != nil {
t.Fatal(err)
}
if apiCount != int64(len(AdminAPIs)) {
t.Fatalf("sys_api count=%d, want %d", apiCount, len(AdminAPIs))
}
for _, permission := range AdminAPIs {
var count int64
if err = db.Model(&purchaserAPI{}).
Where("path = ? AND action = ?", permission.Path, permission.Method).
Count(&count).Error; err != nil || count != 1 {
t.Fatalf("API %s %s count=%d err=%v", permission.Method, permission.Path, count, err)
}
}
var purchaserCount int64
if err = db.Model(&purchaserPolicy{}).
Where("ptype = ? AND v0 = ?", "p", RolePurchaser).
Count(&purchaserCount).Error; err != nil {
t.Fatal(err)
}
if purchaserCount != int64(len(PurchaserAPIs())) {
t.Fatalf("purchaser policy count=%d, want %d", purchaserCount, len(PurchaserAPIs()))
}
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
}
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&purchaserRole{}, &purchaserAPI{}, &purchaserPolicy{}); err != nil {
t.Fatal(err)
}
permissions := []APIPermission{
{Title: "保留", Path: "/kept", Method: "GET", Purchaser: true},
{Title: "移除", Path: "/removed", Method: "POST", Purchaser: true},
{Title: "管理员", Path: "/admin-only", Method: "DELETE", Purchaser: false},
}
if err = reconcilePurchaserPermissions(db, permissions); err != nil {
t.Fatal(err)
}
other := purchaserPolicy{Ptype: "p", V0: "other-role", V1: "/removed", V2: "POST"}
if err = db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if err = reconcilePurchaserPermissions(db, permissions[:1]); err != nil {
t.Fatal(err)
}
assertPolicyCount(t, db, RolePurchaser, "/kept", "GET", 1)
assertPolicyCount(t, db, RolePurchaser, "/removed", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/admin-only", "DELETE", 0)
assertPolicyCount(t, db, "other-role", "/removed", "POST", 1)
}
func TestReconcilePurchaserPermissionsRollsBackOnCatalogueFailure(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Deliberately omit sys_api so reconciliation fails after attempting to
// create the purchaser role. The surrounding transaction must undo it.
if err = db.AutoMigrate(&purchaserRole{}, &purchaserPolicy{}); err != nil {
t.Fatal(err)
}
other := purchaserPolicy{Ptype: "p", V0: "other-role", V1: "/custom", V2: "GET"}
if err = db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if err = ReconcilePurchaserPermissions(db); err == nil {
t.Fatal("reconcile unexpectedly succeeded without sys_api")
}
var roleCount int64
if err = db.Model(&purchaserRole{}).Where("role_key = ?", RolePurchaser).Count(&roleCount).Error; err != nil {
t.Fatal(err)
}
if roleCount != 0 {
t.Fatalf("failed reconcile left purchaser role behind: %d", roleCount)
}
assertPolicyCount(t, db, "other-role", "/custom", "GET", 1)
}
func assertPolicyCount(t *testing.T, db *gorm.DB, role, path, method string, want int64) {
t.Helper()
var count int64
if err := db.Model(&purchaserPolicy{}).
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", role, path, method).
Count(&count).Error; err != nil || count != want {
t.Fatalf("policy %s %s %s count=%d want=%d err=%v", role, method, path, count, want, err)
}
}
+6
View File
@@ -20,6 +20,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
goautoaccess "go-admin/app/goauto/access"
goautodevice "go-admin/app/goauto/device"
goautopurchase "go-admin/app/goauto/purchase"
goautoreplacement "go-admin/app/goauto/replacement"
@@ -89,6 +90,11 @@ func run() error {
if config.ApplicationConfig.Mode == pkg.ModeProd.String() {
gin.SetMode(gin.ReleaseMode)
}
for name, db := range sdk.Runtime.GetDb() {
if err := goautoaccess.ReconcilePurchaserPermissions(db); err != nil {
return fmt.Errorf("reconcile GoAuto purchaser permissions for database %q: %w", name, err)
}
}
initRouter()
for _, f := range AppRouters {