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) 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=