fix(#156): reconcile purchaser permissions at startup
This commit is contained in:
@@ -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 启动直接返回错误,不注册路由、不监听端口,避免权限矩阵未对齐时继续提供服务。既有版本化迁移保留其历史语义,但不再是运行时权限同步的唯一入口。
|
||||
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user