From ec7d838ebdbcadf0b4e251f635150bc242728dbb Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 30 Aug 2026 10:03:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix=F0=9F=90=9B:=20key=20the=20casbin=20enf?= =?UTF-8?q?orcer=20by=20tenant=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupSimpleDatabase runs once per configured database - one per host in the multi-tenant configuration - and passed the same empty key to mycasbin.Setup every time. Setup caches per key, so every host after the first was handed the enforcer built from the first host's database and was authorized against a casbin_rule table that was not its own. Takes effect with the go-admin-core release that keys the cache; before it, Setup ignored the argument entirely. --- common/database/initialize.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/database/initialize.go b/common/database/initialize.go index 09fb5253..75c2bd68 100644 --- a/common/database/initialize.go +++ b/common/database/initialize.go @@ -63,7 +63,11 @@ func setupSimpleDatabase(host string, c *toolsConfig.Database) { log.Info(pkg.Green(c.Driver + " connect success !")) } - e := mycasbin.Setup(db, "") + // Keyed by host, matching the database this enforcer reads from. Passing + // the same key for every host would hand each one the enforcer built from + // whichever database was configured first, and the rest would be decided + // by a casbin_rule table that is not theirs. + e := mycasbin.Setup(db, host) sdk.Runtime.SetDbByTenant(host, db) sdk.Runtime.SetCasbinByTenant(host, e) From 0fa015b6d034e3b386f420caa7d3274284e11e65 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Mon, 31 Aug 2026 14:01:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?perf=F0=9F=91=8C:=20stop=20recompiling=20pa?= =?UTF-8?q?tterns=20when=20scanning=20the=20casbin=20exclusion=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthCheckRole walks CasbinExclude for every non-admin request, and used casbin's util.KeyMatch2 to test each entry. That delegates to util.RegexMatch, which is regexp.MatchString - it compiles its pattern on every call - so a 32-entry list cost about 2,566 allocations per request before the request reached Enforce. Test the method first, which rules out most entries with a string compare, and take the path test from go-admin-core, whose KeyMatch2 answers the same thing without recompiling. The scan drops to 52ns and no allocations. The loop moves out of AuthCheckRole so the tests exercise the code a request runs rather than a copy of it, and an allocation budget fails if the uncached matcher comes back. --- common/middleware/permission.go | 40 ++++++++++-- common/middleware/permission_scan_test.go | 79 +++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 common/middleware/permission_scan_test.go diff --git a/common/middleware/permission.go b/common/middleware/permission.go index 5f0bc870..993fde77 100644 --- a/common/middleware/permission.go +++ b/common/middleware/permission.go @@ -1,10 +1,11 @@ package middleware import ( - "github.com/casbin/casbin/v3/util" + "fmt" "net/http" "github.com/gin-gonic/gin" + mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin" "github.com/go-admin-team/go-admin-core/v2/jwtauth" "github.com/go-admin-team/go-admin-core/v2/response" "github.com/go-admin-team/go-admin-core/v2/sdk" @@ -26,11 +27,9 @@ func AuthCheckRole() gin.HandlerFunc { c.Next() return } - for _, i := range CasbinExclude { - if util.KeyMatch2(c.Request.URL.Path, i.Url) && c.Request.Method == i.Method { - casbinExclude = true - break - } + casbinExclude, err = excludedFromCasbin(c.Request.Method, c.Request.URL.Path) + if err != nil { + log.Errorf("AuthCheckRole: %s", err) } if casbinExclude { log.Infof("Casbin exclusion, no validation method:%s path:%s", c.Request.Method, c.Request.URL.Path) @@ -59,3 +58,32 @@ func AuthCheckRole() gin.HandlerFunc { } } + +// excludedFromCasbin reports whether the route skips the permission check. +// +// It runs for every non-admin request, so the order matters: the method rules +// out most entries with a string compare, where the path test costs a pattern +// match. mycasbin.KeyMatch2 answers what casbin's util.KeyMatch2 answers +// without recompiling the pattern every time, which is what made this loop +// expensive - about 2,500 allocations per request against a 32-entry list. +// +// A pattern that will not compile is a bug in CasbinExclude rather than in the +// request, so the entry is skipped and the scan continues; the error comes +// back for the caller to log. +func excludedFromCasbin(method, path string) (bool, error) { + var bad error + for _, i := range CasbinExclude { + if method != i.Method { + continue + } + ok, err := mycasbin.KeyMatch2(path, i.Url) + if err != nil { + bad = fmt.Errorf("CasbinExclude entry %q is not a valid pattern: %w", i.Url, err) + continue + } + if ok { + return true, bad + } + } + return false, bad +} diff --git a/common/middleware/permission_scan_test.go b/common/middleware/permission_scan_test.go new file mode 100644 index 00000000..6ad23094 --- /dev/null +++ b/common/middleware/permission_scan_test.go @@ -0,0 +1,79 @@ +package middleware + +import "testing" + +// excluded is excludedFromCasbin with the error dropped: these tests are about +// the answer and its cost, and CasbinExclude has no malformed entry to report. +func excluded(t testing.TB, path, method string) bool { + t.Helper() + ok, err := excludedFromCasbin(method, path) + if err != nil { + t.Fatalf("CasbinExclude holds a pattern that will not compile: %s", err) + } + return ok +} + +// TestCasbinExcludeScanMatches pins the behaviour the scan has to keep: an +// excluded route is recognised, a protected one is not, and the method has to +// agree. +func TestCasbinExcludeScanMatches(t *testing.T) { + cases := []struct { + path, method string + want bool + }{ + {"/api/v1/health", "GET", true}, + {"/api/v1/login", "POST", true}, + {"/api/v1/roleMenuTreeselect/12", "GET", true}, + {"/api/v1/dept", "GET", false}, + {"/api/v1/sys-user", "GET", false}, + // Same path, wrong method: sys-user is excluded for PUT only. + {"/api/v1/sys-user", "PUT", true}, + {"/api/v1/health", "POST", false}, + } + for _, c := range cases { + if got := excluded(t, c.path, c.method); got != c.want { + t.Errorf("excludedFromCasbin(%s %s) = %v, want %v", c.method, c.path, got, c.want) + } + } +} + +// TestCasbinExcludeScanAllocationBudget is what keeps the scan cheap. +// +// The list is walked per request with a pattern match per entry, and +// casbin's util.KeyMatch2 compiles a regexp on every call - the whole scan +// cost about 2,566 allocations that way. Going back to it fails this test. +// +// Allocation counts are deterministic across machines; wall-clock is not. +func TestCasbinExcludeScanAllocationBudget(t *testing.T) { + // A protected route, so the scan runs to the end without an early match - + // the case every authenticated business request hits. + const path, method = "/api/v1/dept", "GET" + + if excluded(t, path, method) { + t.Fatalf("setup failed: %s is in the exclusion list", path) + } + + // The budget covers the GET entries that carry a path parameter, which + // still need a match. Measured at 0 for the cached matcher; the headroom + // is for entries being added to the list. + const budget = 64 + + got := testing.AllocsPerRun(100, func() { + _, _ = excludedFromCasbin(method, path) + }) + if got > budget { + t.Errorf("scanning CasbinExclude allocates %.0f times, budget is %d\n"+ + "casbin's util.KeyMatch2 costs about 2566 here; use mycasbin.KeyMatch2", + got, budget) + } +} + +// BenchmarkCasbinExcludeScan reports what the scan adds to a request. +func BenchmarkCasbinExcludeScan(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, _ = excludedFromCasbin("GET", "/api/v1/dept") + } + }) +} diff --git a/go.mod b/go.mod index eef3c025..8365aaa4 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/casbin/casbin/v3 v3.8.1 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 - github.com/go-admin-team/go-admin-core/v2 v2.2.0 + github.com/go-admin-team/go-admin-core/v2 v2.3.0 github.com/google/uuid v1.6.0 github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible github.com/mssola/user_agent v0.6.0 diff --git a/go.sum b/go.sum index b682fc96..e9c23d8e 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-admin-team/go-admin-core/v2 v2.2.0 h1:8aJk9q5RQ+T0a6gs95Ay93qzGUc5ilXv90RbXPiwnEI= -github.com/go-admin-team/go-admin-core/v2 v2.2.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g= +github.com/go-admin-team/go-admin-core/v2 v2.3.0 h1:P2Po+jsJByzv1y2cN8eEGfFOutWSYNzQTJxLGAFmHkE= +github.com/go-admin-team/go-admin-core/v2 v2.3.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=