Selaa lähdekoodia

feat(rbac): S1 引入 Casbin v3 策略引擎 + 自写 GORM Adapter(第②层接口强制)

- casbin/casbin/v3@v3.8.1(弃官方 gorm-adapter,仅 +doublestar/govaluate 传递依赖,无 DB driver)
- internal/infra/rbac/casbin_model.conf: RBAC with Domains(sub=角色码/dom=机构/obj=path keyMatch2/act=method regexMatch;embed 内嵌)
- internal/infra/rbac/enforcer.go: 自写 gormAdapter(persist.Adapter 6 方法 + ruleRow 映射)+ NewEnforcer/Reload/Get
- internal/infra/rbac/enforcer_test.go: 单测 5 项全 PASS(model.conf 解析/无 g 自匹配/白名单矩阵/regexMatch/keyMatch2 通配)
- 配套 DDL: core-model-ddl.sql v1.3 已加 casbin_rule 表 + 策略种子 14 条 + ERD(12→13 表)

设计: unified/01-architecture/casbin-rbac-design.md v1.2
验证: go build ./... 通过 / go vet 通过 / go test 5 项 PASS
Q 1 päivä sitten
vanhempi
sitoutus
027c26377f
5 muutettua tiedostoa jossa 437 lisäystä ja 0 poistoa
  1. 3 0
      go.mod
  2. 28 0
      go.sum
  3. 33 0
      internal/infra/rbac/casbin_model.conf
  4. 184 0
      internal/infra/rbac/enforcer.go
  5. 189 0
      internal/infra/rbac/enforcer_test.go

+ 3 - 0
go.mod

@@ -20,6 +20,9 @@ require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
+	github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
+	github.com/casbin/casbin/v3 v3.8.1 // indirect
+	github.com/casbin/govaluate v1.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect

+ 28 - 0
go.sum

@@ -6,12 +6,21 @@ github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQg
 github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
+github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
+github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
+github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
@@ -46,8 +55,11 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -61,6 +73,7 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
@@ -72,6 +85,7 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
@@ -90,14 +104,19 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
 golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
@@ -106,11 +125,20 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
 gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
 modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
 modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
 modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
 modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
 modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
 modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
 modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
 modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=

+ 33 - 0
internal/infra/rbac/casbin_model.conf

@@ -0,0 +1,33 @@
+# RBAC with Domains 策略模型(casbin v3)
+# 定位:RBAC 三层模型的"第②层接口强制"——角色 + 机构(domain) + 资源(path) + 动作(method)。
+# 依据:unified/01-architecture/casbin-rbac-design.md v1.1(sub=角色码 / 两表并存 / 30s 轮询已拍板)。
+#
+# 请求四维:
+#   r.sub  = 角色码(super_admin/academic/finance/teacher/student/parent/market;拍板用 sc.Role,非 ULID)
+#   r.dom  = 机构 ID(institution_id;策略可写 * 表示不区分机构)
+#   r.obj  = 请求 path(keyMatch2 通配,如 /org/institutions/*)
+#   r.act  = HTTP method(regexMatch,如 GET|POST)
+#
+# 策略(p):p, <角色码>, <机构ID|*>, <path>, <method>
+# 角色继承(g):g, <name>, <role>, <dom>——当前不预填(sub 直接=角色码,g 自匹配成立),
+#   保留定义供未来"人级授权"升级(sub 换 ULID + membership 变更写 g 规则,见设计 §4.2)。
+#
+# matcher:
+#   - g(r.sub, p.sub, r.dom):角色码在 dom 下的继承自匹配(当前恒真,无 g 规则时);
+#   - (p.dom == "*" || r.dom == p.dom):策略 dom=* 全机构放行,或 dom 精确匹配;
+#   - keyMatch2(r.obj, p.obj):path 通配匹配(/org/institutions/* 匹配 /org/institutions/1);
+#   - regexMatch(r.act, p.act):method 正则匹配("GET|POST" 命中 GET 或 POST)。
+[request_definition]
+r = sub, dom, obj, act
+
+[policy_definition]
+p = sub, dom, obj, act
+
+[role_definition]
+g = _, _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = g(r.sub, p.sub, r.dom) && (p.dom == "*" || r.dom == p.dom) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)

+ 184 - 0
internal/infra/rbac/enforcer.go

@@ -0,0 +1,184 @@
+// Package rbac RBAC 接口强制层(第②层)—— Casbin v3 策略引擎 + 自写 GORM 存储。
+//
+// 定位(RBAC 三层模型的"接口强制层",2026-11-08 拍板"引入 Casbin+GORM"):
+//   - 本包持有进程内 *casbin.Enforcer(RBAC with Domains,模型见 casbin_model.conf);
+//   - 策略存 MySQL `casbin_rule` 表(ptype/v0/v1/v2/v3),经自写 GORM Adapter 读写
+//     (不用官方 gorm-adapter——其依赖拉入 pgx/sqlserver/postgres driver + modernc.org/sqlite 大跳,
+//     风险大;自写 Adapter 仅 6 方法,零额外 driver 依赖,只需 casbin/v3);
+//   - 接口强制中间件见 middleware.go(CasbinAuth);策略改配写库后 Reload 即时生效(跨实例 30s 轮询,设计 §9)。
+//
+// 设计依据:unified/01-architecture/casbin-rbac-design.md v1.1。
+//   - sub=角色码(sc.Role,非 ULID;保留 g 关系口子供未来人级授权);
+//   - 两表并存:casbin_rule(接口路径)与 t_role_permission(菜单权限点)不合并;
+//   - 超管代码层恒放行(middleware 判 sc.SuperAdmin,不查策略)。
+//
+// 依赖方向:本包属 infra 横切(鉴权执行),依赖 infra/db + gorm + casbin/v3;
+// 不依赖业务域(identity/org)。装配层(cmd/api/main.go)在 DB 就绪后 NewEnforcer。
+package rbac
+
+import (
+	"embed"
+	"fmt"
+	"strings"
+
+	"github.com/casbin/casbin/v3"
+	"github.com/casbin/casbin/v3/model"
+	"github.com/casbin/casbin/v3/persist"
+	"gorm.io/gorm"
+)
+
+//go:embed casbin_model.conf
+var modelConfFS embed.FS
+
+// gormAdapter 自写 GORM 存储适配器(实现 casbin persist.Adapter 6 方法)。
+//
+// 表结构 `casbin_rule`(ptype/v0/v1/v2/v3)——目标态 DDL 见 core-model-ddl.sql v1.3,
+// 本包只读写、不建表(建表走 DDL 先行纪律 + main 的 AutoMigrate 守卫,见 S3)。
+type gormAdapter struct {
+	db *gorm.DB
+}
+
+// ruleRow casbin_rule 表行映射(显式列名,对齐 §1.2 命名纪律)。
+type ruleRow struct {
+	Ptype string `gorm:"column:ptype"`
+	V0    string `gorm:"column:v0"`
+	V1    string `gorm:"column:v1"`
+	V2    string `gorm:"column:v2"`
+	V3    string `gorm:"column:v3"`
+}
+
+// 编译期断言:gormAdapter 须满足 casbin persist.Adapter(签名漂移即编译失败)。
+var _ persist.Adapter = (*gormAdapter)(nil)
+
+// LoadPolicy 全量加载策略入模型(LoadPolicy 语义:清空现有 + 逐行载入)。
+func (a *gormAdapter) LoadPolicy(m model.Model) error {
+	var rows []ruleRow
+	if err := a.db.Model(&ruleRow{}).Find(&rows).Error; err != nil {
+		return err
+	}
+	m.ClearPolicy()
+	for _, r := range rows {
+		line := strings.Join([]string{r.Ptype, r.V0, r.V1, r.V2, r.V3}, ",")
+		if err := persist.LoadPolicyLine(line, m); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// SavePolicy 全量保存策略(清空表 + 逐条写入;本包仅在显式全量替换时调用,默认改配走 RemoveFilteredPolicy+AddPolicy)。
+func (a *gormAdapter) SavePolicy(m model.Model) error {
+	// 清空
+	if err := a.db.Exec("DELETE FROM casbin_rule").Error; err != nil {
+		return err
+	}
+	// 逐条写入(p 全量;g 一般为空)。
+	// v3 model.GetPolicy(sec, ptype) 双参、双返回值;本模型 p 段 ptype="p"、g 段 ptype="g"。
+	for _, sec := range []string{"p", "g"} {
+		pol, err := m.GetPolicy(sec, sec)
+		if err != nil {
+			return err
+		}
+		for _, rule := range pol {
+			if err := a.AddPolicy(sec, sec, rule); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// AddPolicy 追加一条策略(ptype + 字段;v3 列不足时补空串,保持 5 列)。
+func (a *gormAdapter) AddPolicy(sec string, ptype string, rule []string) error {
+	fields := rule
+	for len(fields) < 4 {
+		fields = append(fields, "")
+	}
+	row := ruleRow{
+		Ptype: ptype,
+		V0:    fields[0],
+		V1:    fields[1],
+		V2:    fields[2],
+		V3:    fields[3],
+	}
+	return a.db.Create(&row).Error
+}
+
+// RemovePolicy 删除精确匹配的某条策略。
+func (a *gormAdapter) RemovePolicy(sec string, ptype string, rule []string) error {
+	q := a.db.Where("ptype = ?", ptype)
+	if len(rule) > 0 {
+		q = q.Where("v0 = ?", rule[0])
+	}
+	if len(rule) > 1 {
+		q = q.Where("v1 = ?", rule[1])
+	}
+	if len(rule) > 2 {
+		q = q.Where("v2 = ?", rule[2])
+	}
+	if len(rule) > 3 {
+		q = q.Where("v3 = ?", rule[3])
+	}
+	return q.Delete(&ruleRow{}).Error
+}
+
+// RemoveFilteredPolicy 按字段位删策略(fieldIndex 从 0 计,对应 v0..v3)。
+// fieldValues 为空且 fieldIndex>=0 时,删除该 ptype 下所有行(全量替换语义用)。
+func (a *gormAdapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error {
+	q := a.db.Where("ptype = ?", ptype)
+	if len(fieldValues) > 0 {
+		if fieldIndex < 0 || fieldIndex > 3 {
+			return fmt.Errorf("rbac: RemoveFilteredPolicy 非法字段位 %d", fieldIndex)
+		}
+		col := []string{"v0", "v1", "v2", "v3"}[fieldIndex]
+		q = q.Where(fmt.Sprintf("%s = ?", col), fieldValues[0])
+	}
+	return q.Delete(&ruleRow{}).Error
+}
+
+// Enforcer 进程内 Casbin 鉴权执行器(启动装配一次,全进程共享;含自写 GORM Adapter)。
+//
+// 并发说明:*casbin.Enforcer 内部自带读写锁,Enforce/LoadPolicy 并发安全;
+// 本结构仅持有只读引用 + 暴露 Reload,不额外加锁。
+type Enforcer struct {
+	e *casbin.Enforcer
+}
+
+// NewEnforcer 构造 Enforcer(model.conf 内嵌 + GORM Adapter;启动即 LoadPolicy)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填;casbin_rule 表须已建,见 core-model-ddl.sql v1.3)。
+//
+// 出参:
+//   - *Enforcer:就绪执行器(LoadPolicy 成功后返回;表为空时策略为空集——非超管全 1003,
+//     属 fail-closed;装配层应告警提示种子缺失,见设计 §9 启动)。
+//   - error:DB 加载策略失败。
+func NewEnforcer(db *gorm.DB) (*Enforcer, error) {
+	if db == nil {
+		return nil, fmt.Errorf("rbac: DB 未注入")
+	}
+	raw, err := modelConfFS.ReadFile("casbin_model.conf")
+	if err != nil {
+		return nil, fmt.Errorf("rbac: 读取内嵌 model.conf 失败: %w", err)
+	}
+	m, err := model.NewModelFromString(string(raw))
+	if err != nil {
+		return nil, fmt.Errorf("rbac: 解析 model.conf 失败: %w", err)
+	}
+	adapter := &gormAdapter{db: db}
+	e, err := casbin.NewEnforcer(m, adapter)
+	if err != nil {
+		return nil, fmt.Errorf("rbac: 构造 Enforcer 失败: %w", err)
+	}
+	return &Enforcer{e: e}, nil
+}
+
+// Reload 重新加载策略(改配端点写库后调用,本实例即时生效;跨实例靠 30s 轮询,设计 §9)。
+func (en *Enforcer) Reload() error {
+	return en.e.LoadPolicy()
+}
+
+// Get 暴露底层 *casbin.Enforcer(供 middleware 调 Enforce;不暴露写接口,写走改配端点)。
+func (en *Enforcer) Get() *casbin.Enforcer {
+	return en.e
+}

+ 189 - 0
internal/infra/rbac/enforcer_test.go

@@ -0,0 +1,189 @@
+// 包 rbac 单测:casbin_model.conf 解析 + matcher 四维语义(纯内存,不碰 DB)。
+//
+// 覆盖范围(S1):
+//   - model.conf 内嵌加载成功(NewModelFromString 无解析错误);
+//   - matcher 四维语义:sub(角色码自匹配) × dom(机构/* 通配) × obj(keyMatch2 通配) × act(regexMatch);
+//   - 策略种子命中矩阵(现路由白名单 1:1 平移的核心场景,对齐 main.go 白名单);
+//   - 无 g 规则时 sub==p.sub 自匹配恒真(HasLink 源码 line 310 语义验证)。
+//
+// 不覆盖(留给 S3 真库 e2e):gormAdapter 的 LoadPolicy/SavePolicy/AddPolicy/RemovePolicy
+// 对 casbin_rule 表的真实 CRUD(需 e2e_test 库)。
+package rbac
+
+import (
+	"testing"
+
+	"github.com/casbin/casbin/v3"
+	"github.com/casbin/casbin/v3/model"
+)
+
+// newMemEnforcer 构造纯内存 Enforcer(内嵌 model.conf + 无 adapter,手动注入策略)。
+// 单测隔离用:不依赖 DB,专注验证 model.conf 解析与 matcher 语义。
+func newMemEnforcer(t *testing.T) *casbin.Enforcer {
+	t.Helper()
+	raw, err := modelConfFS.ReadFile("casbin_model.conf")
+	if err != nil {
+		t.Fatalf("读取内嵌 model.conf 失败: %v", err)
+	}
+	m, err := model.NewModelFromString(string(raw))
+	if err != nil {
+		t.Fatalf("解析 model.conf 失败(matcher 语法错误): %v", err)
+	}
+	e, err := casbin.NewEnforcer(m) // 单 model 参数 → InitWithModelAndAdapter(m, nil),不 LoadPolicy
+	if err != nil {
+		t.Fatalf("构造 Enforcer 失败: %v", err)
+	}
+	return e
+}
+
+// seedPolicies 注入 S1 策略种子(现路由白名单 1:1 平移,对齐 core-model-ddl.sql v1.3 INSERT)。
+func seedPolicies(t *testing.T, e *casbin.Enforcer) {
+	t.Helper()
+	// /org 组:academic/finance/teacher × 4 端点(GET),dom=* 不区分机构
+	orgPaths := []string{
+		"/org/institutions",   // 列表
+		"/org/institutions/*", // 详情(keyMatch2 通配 /org/institutions/1)
+		"/org/campuses",
+		"/org/campuses/*",
+	}
+	for _, role := range []string{"academic", "finance", "teacher"} {
+		for _, obj := range orgPaths {
+			if _, err := e.AddPolicy(role, "*", obj, "GET"); err != nil {
+				t.Fatalf("注入策略失败 %s %s: %v", role, obj, err)
+			}
+		}
+	}
+	// /org/role-permissions 组:super_admin × 2 端点(GET|PUT)
+	for _, obj := range []string{"/org/role-permissions", "/org/role-permissions/*"} {
+		if _, err := e.AddPolicy("super_admin", "*", obj, "GET|PUT"); err != nil {
+			t.Fatalf("注入策略失败 super_admin %s: %v", obj, err)
+		}
+	}
+}
+
+// TestModelConfParse 验证 model.conf 内嵌加载 + 四维 matcher 无语法错误。
+func TestModelConfParse(t *testing.T) {
+	e := newMemEnforcer(t)
+	if e == nil {
+		t.Fatal("Enforcer 构造为 nil")
+	}
+	// 空策略下 Enforce 应恒 false(fail-closed;超管由 middleware 代码层放行,不走策略)。
+	ok, err := e.Enforce("academic", "1", "/org/institutions", "GET")
+	if err != nil {
+		t.Fatalf("Enforce 报错: %v", err)
+	}
+	if ok {
+		t.Error("空策略下 Enforce 应为 false(fail-closed),实际 true")
+	}
+}
+
+// TestRoleSelfMatchNoGRule 验证无 g 规则时 sub==p.sub 自匹配恒真(HasLink line 310 语义)。
+func TestRoleSelfMatchNoGRule(t *testing.T) {
+	e := newMemEnforcer(t)
+	// 注入单条策略:academic 角色码 + dom=* + /org/institutions + GET
+	if _, err := e.AddPolicy("academic", "*", "/org/institutions", "GET"); err != nil {
+		t.Fatalf("注入策略失败: %v", err)
+	}
+	// sub=academic(角色码)应命中 p.sub=academic(自匹配,无 g 规则也成立)。
+	ok, err := e.Enforce("academic", "999", "/org/institutions", "GET")
+	if err != nil {
+		t.Fatalf("Enforce 报错: %v", err)
+	}
+	if !ok {
+		t.Error("无 g 规则时 sub=academic 应自匹配 p.sub=academic 命中,实际未命中")
+	}
+}
+
+// TestOrgWhitelistMatrix 验证现路由白名单 1:1 平移的命中矩阵。
+func TestOrgWhitelistMatrix(t *testing.T) {
+	e := newMemEnforcer(t)
+	seedPolicies(t, e)
+
+	cases := []struct {
+		name string
+		sub  string
+		dom  string
+		obj  string
+		act  string
+		want bool
+	}{
+		// 白名单内角色 × 白名单内路径 → allow
+		{"academic 列表", "academic", "1", "/org/institutions", "GET", true},
+		{"academic 详情(keyMatch2)", "academic", "1", "/org/institutions/1", "GET", true},
+		{"academic 校区列表", "academic", "1", "/org/campuses", "GET", true},
+		{"academic 校区详情", "academic", "1", "/org/campuses/42", "GET", true},
+		{"finance 列表", "finance", "1", "/org/institutions", "GET", true},
+		{"teacher 详情", "teacher", "1", "/org/campuses/7", "GET", true},
+		// dom=* 通配:任意机构 ID 都命中
+		{"academic 跨机构 dom", "academic", "999", "/org/institutions", "GET", true},
+		// 白名单外角色 → deny(1003 场景)
+		{"student 越权", "student", "1", "/org/institutions", "GET", false},
+		{"parent 越权", "parent", "1", "/org/campuses", "GET", false},
+		{"market 越权", "market", "1", "/org/institutions", "GET", false},
+		// 白名单外路径 → deny
+		{"academic 越权路径", "academic", "1", "/org/role-permissions", "GET", false},
+		{"academic 越权方法", "academic", "1", "/org/institutions", "POST", false},
+		// super_admin 策略(rbacCfg 组)
+		{"super_admin 读矩阵", "super_admin", "1", "/org/role-permissions", "GET", true},
+		{"super_admin 写矩阵", "super_admin", "1", "/org/role-permissions", "PUT", true},
+		{"super_admin 详情通配", "super_admin", "1", "/org/role-permissions/academic", "GET", true},
+		// academic 不能写矩阵
+		{"academic 写矩阵越权", "academic", "1", "/org/role-permissions", "PUT", false},
+	}
+	for _, c := range cases {
+		ok, err := e.Enforce(c.sub, c.dom, c.obj, c.act)
+		if err != nil {
+			t.Fatalf("Enforce 报错 [%s]: %v", c.name, err)
+		}
+		if ok != c.want {
+			t.Errorf("场景[%s] sub=%s dom=%s obj=%s act=%s 期望=%v 实际=%v",
+				c.name, c.sub, c.dom, c.obj, c.act, c.want, ok)
+		}
+	}
+}
+
+// TestActRegexMatch 验证 act 维度 regexMatch 语义(GET|PUT 命中 GET/PUT,不命中 DELETE)。
+func TestActRegexMatch(t *testing.T) {
+	e := newMemEnforcer(t)
+	if _, err := e.AddPolicy("super_admin", "*", "/org/role-permissions", "GET|PUT"); err != nil {
+		t.Fatalf("注入策略失败: %v", err)
+	}
+	acts := map[string]bool{
+		"GET":    true,
+		"PUT":    true,
+		"DELETE": false,
+		"POST":   false,
+	}
+	for act, want := range acts {
+		ok, err := e.Enforce("super_admin", "1", "/org/role-permissions", act)
+		if err != nil {
+			t.Fatalf("Enforce 报错 act=%s: %v", act, err)
+		}
+		if ok != want {
+			t.Errorf("act=%s 期望=%v 实际=%v", act, want, ok)
+		}
+	}
+}
+
+// TestKeyMatch2Wildcard 验证 obj 维度 keyMatch2 通配语义(/org/institutions/* 匹配多级/单级尾段)。
+func TestKeyMatch2Wildcard(t *testing.T) {
+	e := newMemEnforcer(t)
+	if _, err := e.AddPolicy("academic", "*", "/org/institutions/*", "GET"); err != nil {
+		t.Fatalf("注入策略失败: %v", err)
+	}
+	objs := map[string]bool{
+		"/org/institutions":     false, // 精确列表路径不匹配通配尾段(* 需有尾段)
+		"/org/institutions/1":   true,  // 单段尾段命中
+		"/org/institutions/a/b": true,  // 多级尾段命中
+		"/org/other/1":          false, // 不同前缀不命中
+	}
+	for obj, want := range objs {
+		ok, err := e.Enforce("academic", "1", obj, "GET")
+		if err != nil {
+			t.Fatalf("Enforce 报错 obj=%s: %v", obj, err)
+		}
+		if ok != want {
+			t.Errorf("obj=%s 期望=%v 实际=%v", obj, want, ok)
+		}
+	}
+}