From ee5784ef764da64d7e93e156e5dcd1f3b264a690 Mon Sep 17 00:00:00 2001 From: SanJiu Date: Sat, 5 Sep 2026 12:30:35 +0800 Subject: [PATCH] feat: add authztest package for authorization test helpers with failure context Implements the test helpers proposed in #1624 as a new sub-package: AssertAllow/AssertDeny thin wrappers around Enforce, plus Diagnose/Explain near-miss attribution that reports, position by position, why a request was denied (or which policy line allowed an unexpected allow). Role inheritance via g rules is credited rather than falsely reported as a subject mismatch, domain-aware models check links in the policy line's own domain, and wildcard idiom (p.obj == '*') is recognized. Enforce errors are surfaced as errors, not misattributed denies. --- authztest/authztest.go | 233 ++++++++++++++++++++++++++++ authztest/authztest_test.go | 294 ++++++++++++++++++++++++++++++++++++ authztest/example_test.go | 59 ++++++++ authztest/helpers.go | 101 +++++++++++++ 4 files changed, 687 insertions(+) create mode 100644 authztest/authztest.go create mode 100644 authztest/authztest_test.go create mode 100644 authztest/example_test.go create mode 100644 authztest/helpers.go diff --git a/authztest/authztest.go b/authztest/authztest.go new file mode 100644 index 00000000..df953c73 --- /dev/null +++ b/authztest/authztest.go @@ -0,0 +1,233 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package authztest provides lightweight test helpers for Casbin +// authorization tests with meaningful failure context. +// +// The standard way to test Casbin policies is a stream of bare Enforce +// calls: +// +// if ok, _ := e.Enforce("alice", "data1", "read"); !ok { +// t.Error("expected alice to read data1") +// } +// +// When such a test fails you learn *that* it failed, not *why*. These +// helpers keep the ergonomics (one line per assertion) and add the missing +// context: on failure they report the policies that came closest to +// matching, position by position, including role inheritance via g rules. +// +// authztest.AssertAllow(t, e, "alice", "data1", "read") +// authztest.AssertDeny(t, e, "bob", "data1", "write") +// +// Failure output looks like: +// +// authztest: expected ALLOW, got DENY for (bob, data1, read) +// no policy matched; closest candidates: +// p, alice, data1, read +// sub: 'bob' does not match 'alice' (no role link to alice) +// obj: ok ('data1') +// act: ok ('read') +package authztest + +import ( + "fmt" + "strings" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/rbac" +) + +// T is the subset of testing.TB (and therefore *testing.T, *testing.B and +// *testing.F) used by the helpers. +type T interface { + Helper() + Errorf(format string, args ...interface{}) +} + +// AssertAllow asserts that the enforcer allows the request. On failure it +// reports the request and a near-miss diagnosis of the closest policies. +func AssertAllow(t T, e casbin.IEnforcer, rvals ...interface{}) { + t.Helper() + ok, err := e.Enforce(rvals...) + if err != nil { + t.Errorf("authztest: enforce(%s) returned error: %v", formatRequest(rvals), err) + return + } + if ok { + return + } + d := Diagnose(e, rvals...) + t.Errorf("authztest: expected ALLOW, got DENY for %s\n%s", formatRequest(rvals), d) +} + +// AssertDeny asserts that the enforcer denies the request. On unexpected +// allow it reports which policy line(s) matched. +func AssertDeny(t T, e casbin.IEnforcer, rvals ...interface{}) { + t.Helper() + ok, err := e.Enforce(rvals...) + if err != nil { + t.Errorf("authztest: enforce(%s) returned error: %v", formatRequest(rvals), err) + return + } + if !ok { + return + } + d := Diagnose(e, rvals...) + t.Errorf("authztest: expected DENY, got ALLOW for %s\n%s", formatRequest(rvals), d) +} + +// NearMiss describes how close one policy line came to matching a request. +type NearMiss struct { + Policy []string // the raw policy line + Matched []string // human-readable per-position notes for matched positions + Failed []string // human-readable per-position notes for failed positions +} + +// Score is the number of positions that matched; used to rank near misses. +func (n NearMiss) Score() int { return len(n.Matched) } + +// FullyMatched reports whether every position matched. When a request was +// unexpectedly allowed, the fully matched policies are the reason. +func (n NearMiss) FullyMatched() bool { return len(n.Failed) == 0 } + +// Diagnose returns a human-readable explanation of why a request was +// allowed or denied, ranking the closest policy lines first. +// +// The attribution is a structural analysis of the policy and g (role) +// rules, not a re-evaluation of the matcher expression. Matchers that use +// built-in functions (keyMatch, regexMatch, ...) may legitimately match in +// ways the structural analysis cannot see; the output is labelled +// accordingly and is meant to point you at the right line, not to replace +// reading the model. +func Diagnose(e casbin.IEnforcer, rvals ...interface{}) string { + misses, err := Explain(e, rvals...) + if err != nil || len(misses) == 0 { + return fmt.Sprintf("(no diagnosis available: %v)", err) + } + + var b strings.Builder + allowed := misses[0].FullyMatched() + if allowed { + b.WriteString("matched policy line(s):") + } else { + b.WriteString("no policy matched; closest candidates:") + } + b.WriteString("\n") + + shown := 0 + for _, m := range misses { + if allowed && !m.FullyMatched() { + continue + } + if shown == 3 { + b.WriteString(fmt.Sprintf(" (+%d more)\n", len(misses)-shown)) + break + } + b.WriteString(fmt.Sprintf(" p, %s\n", strings.Join(m.Policy, ", "))) + for _, note := range m.Matched { + b.WriteString(" " + note + "\n") + } + for _, note := range m.Failed { + b.WriteString(" " + note + "\n") + } + shown++ + } + if !allowed && shown == 0 { + b.WriteString(" (none)\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// Explain returns the structured near-miss analysis behind Diagnose, sorted +// from closest match to furthest. The first element is fully matched +// exactly when the request was allowed. +func Explain(e casbin.IEnforcer, rvals ...interface{}) ([]NearMiss, error) { + m := e.GetModel() + rTok := tokens(m, "r", "r") + pTok := tokens(m, "p", "p") + if rTok == nil || pTok == nil { + return nil, fmt.Errorf("model lacks request_definition (r) or policy_definition (p)") + } + + rvals = pad(rvals, len(rTok)) + rstr := toStrings(rvals) + subIdx := indexOf(rTok, "sub") + domIdx := indexOf(rTok, "dom") + + roleMgr := e.GetRoleManager() + policies, err := e.GetPolicy() + if err != nil { + return nil, err + } + + var misses []NearMiss + for _, pol := range policies { + n := NearMiss{Policy: pol} + for i, pv := range pol { + if i >= len(rTok) { + break + } + name := pTok[i] + rv := rstr[i] + switch { + case pv == "*": + n.Matched = append(n.Matched, fmt.Sprintf("%s: ok (%s matches wildcard)", name, rv)) + case pv == rv: + n.Matched = append(n.Matched, fmt.Sprintf("%s: ok ('%s')", name, rv)) + case i == subIdx && roleLinked(roleMgr, rv, pv, pol, rstr, domIdx): + n.Matched = append(n.Matched, fmt.Sprintf("%s: ok ('%s' inherits '%s' via g)", name, rv, pv)) + default: + n.Failed = append(n.Failed, fmt.Sprintf("%s: '%s' does not match '%s' (%s)", name, rv, pv, failureHint(i, subIdx, roleMgr, rv, pv, pol, rstr, domIdx))) + } + } + if len(pol) != len(rTok) { + n.Failed = append(n.Failed, fmt.Sprintf("arity: policy has %d fields, request_definition declares %d", len(pol), len(rTok))) + } + misses = append(misses, n) + } + + sortMisses(misses) + return misses, nil +} + +// roleLinked reports whether the request subject reaches the policy subject +// through a g rule. For domain-aware models the link is checked in the +// policy line's own domain: "does the request subject hold this role where +// this policy applies?", which makes the remaining failure positions +// unambiguous. +func roleLinked(roleMgr rbac.RoleManager, sub, role string, pol, rstr []string, domIdx int) bool { + if roleMgr == nil || sub == role { + return false + } + if domIdx < 0 || domIdx >= len(rstr) { + ok, _ := roleMgr.HasLink(sub, role) + return ok + } + dom := rstr[domIdx] // fall back to the request's domain + if domIdx < len(pol) { + dom = pol[domIdx] + } + ok, _ := roleMgr.HasLink(sub, role, dom) + return ok +} + +func failureHint(i, subIdx int, roleMgr rbac.RoleManager, rv, pv string, pol, rstr []string, domIdx int) string { + if i == subIdx { + return "no role link from '" + rv + "' to '" + pv + "'" + } + return "not a wildcard" +} diff --git a/authztest/authztest_test.go b/authztest/authztest_test.go new file mode 100644 index 00000000..414d58a1 --- /dev/null +++ b/authztest/authztest_test.go @@ -0,0 +1,294 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package authztest + +import ( + "fmt" + "strings" + "testing" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/model" +) + +// captureT records Errorf calls instead of failing the Go test binary. +type captureT struct { + errs []string +} + +func (c *captureT) Helper() {} + +func (c *captureT) Errorf(format string, args ...interface{}) { + c.errs = append(c.errs, fmt.Sprintf(format, args...)) +} + +const basicModel = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && r.obj == p.obj && r.act == p.act +` + +const rbacModel = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +` + +const domainsModel = ` +[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) && r.dom == p.dom && r.obj == p.obj && r.act == p.act +` + +func newEnforcer(t *testing.T, modelText, policyText string) casbin.IEnforcer { + t.Helper() + m, err := model.NewModelFromString(modelText) + if err != nil { + t.Fatalf("load model: %v", err) + } + e, err := casbin.NewEnforcer(m) + if err != nil { + t.Fatalf("new enforcer: %v", err) + } + for _, line := range strings.Split(strings.TrimSpace(policyText), "\n") { + f := strings.Split(strings.TrimSpace(line), ",") + if len(f) < 2 { + continue + } + for i := range f { + f[i] = strings.TrimSpace(f[i]) + } + if f[0] == "g" { + if _, err := e.AddGroupingPolicy(f[1:]); err != nil { + t.Fatalf("add grouping policy %v: %v", f, err) + } + continue + } + if _, err := e.AddPolicy(f[1:]); err != nil { + t.Fatalf("add policy %v: %v", f, err) + } + } + return e +} + +func TestAssertAllowPasses(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1, read\n") + ct := &captureT{} + AssertAllow(ct, e, "alice", "data1", "read") + if len(ct.errs) != 0 { + t.Fatalf("unexpected errors: %v", ct.errs) + } +} + +func TestAssertDenyPasses(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1, read\n") + ct := &captureT{} + AssertDeny(ct, e, "bob", "data1", "read") + if len(ct.errs) != 0 { + t.Fatalf("unexpected errors: %v", ct.errs) + } +} + +func TestAssertAllowFailureNamesTheFailingPosition(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1, read\n") + ct := &captureT{} + AssertAllow(ct, e, "bob", "data1", "read") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + msg := ct.errs[0] + if !strings.Contains(msg, "expected ALLOW, got DENY") { + t.Errorf("message should state the expectation: %s", msg) + } + if !strings.Contains(msg, "(bob, data1, read)") { + t.Errorf("message should restate the request: %s", msg) + } + if !strings.Contains(msg, "\n sub: 'bob' does not match 'alice'") { + t.Errorf("message should name the failing position: %s", msg) + } + if !strings.Contains(msg, "\n act: ok ('read')") { + t.Errorf("message should credit the positions that matched: %s", msg) + } +} + +const wildcardModel = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = (r.sub == p.sub || p.sub == "*") && (r.obj == p.obj || p.obj == "*") && (r.act == p.act || p.act == "*") +` + +func TestAssertDenyFailureShowsTheMatchingPolicy(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1, read\n") + ct := &captureT{} + AssertDeny(ct, e, "alice", "data1", "read") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + msg := ct.errs[0] + if !strings.Contains(msg, "expected DENY, got ALLOW") { + t.Errorf("message should state the expectation: %s", msg) + } + if !strings.Contains(msg, "p, alice, data1, read") { + t.Errorf("message should show the policy line that allowed it: %s", msg) + } +} + +func TestRBACRoleInheritanceIsNotReportedAsMismatch(t *testing.T) { + // alice inherits admin; the sub position genuinely matched, so the + // diagnosis must credit it via g and blame act instead. + e := newEnforcer(t, rbacModel, "p, admin, data2, write\ng, alice, admin\n") + ct := &captureT{} + AssertAllow(ct, e, "alice", "data2", "read") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + msg := ct.errs[0] + if !strings.Contains(msg, "expected ALLOW, got DENY") { + t.Fatalf("unexpected failure mode: %s", msg) + } + if !strings.Contains(msg, "inherits 'admin' via g") { + t.Errorf("role-matched subject should be credited, not blamed: %s", msg) + } + if strings.Contains(msg, "does not match 'admin'") { + t.Errorf("subject matched via role; must not be reported as mismatch: %s", msg) + } + if !strings.Contains(msg, "act: 'read' does not match 'write'") { + t.Errorf("the actually failing position should be named: %s", msg) + } +} + +func TestWildcardPolicyIsCredited(t *testing.T) { + e := newEnforcer(t, wildcardModel, "p, alice, data1, *\n") + ct := &captureT{} + AssertDeny(ct, e, "alice", "data1", "delete") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + msg := ct.errs[0] + if !strings.Contains(msg, "act: ok (delete matches wildcard)") { + t.Errorf("wildcard match should be explained: %s", msg) + } +} + +func TestDomainsModelAttribution(t *testing.T) { + policy := "p, admin, domain1, data1, read\ng, alice, admin, domain1\n" + e := newEnforcer(t, domainsModel, policy) + + // Right tenant: allowed via role inheritance. + ct := &captureT{} + AssertAllow(ct, e, "alice", "domain1", "data1", "read") + if len(ct.errs) != 0 { + t.Fatalf("alice should read domain1/data1 via admin: %v", ct.errs) + } + + // Wrong tenant: dom position is the failure, sub still credited. + ct = &captureT{} + AssertAllow(ct, e, "alice", "domain2", "data1", "read") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + msg := ct.errs[0] + if !strings.Contains(msg, "dom: 'domain2' does not match 'domain1'") { + t.Errorf("diagnosis should blame the tenant mismatch: %s", msg) + } + if !strings.Contains(msg, "inherits 'admin' via g") { + t.Errorf("subject matched via domain-scoped g: %s", msg) + } +} + +func TestExplainRanksClosestFirst(t *testing.T) { + policy := "p, alice, data1, read\np, bob, data2, write\n" + e := newEnforcer(t, basicModel, policy) + misses, err := Explain(e, "bob", "data1", "read") + if err != nil { + t.Fatalf("Explain: %v", err) + } + if len(misses) != 2 { + t.Fatalf("expected 2 near misses, got %d", len(misses)) + } + // p, bob, data2, write matches 1 position (sub); the alice line matches 2. + if got := strings.Join(misses[0].Policy, ", "); got != "alice, data1, read" { + t.Errorf("closest policy should be ranked first, got %q", got) + } +} + +func TestArityMismatchIsReported(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1\n") // 3 fields declared, 2 given + misses, err := Explain(e, "alice", "data1", "read") + if err != nil { + t.Fatalf("Explain: %v", err) + } + if len(misses) == 0 { + t.Fatal("expected at least one near miss") + } + msg := strings.Join(misses[0].Failed, " ") + if !strings.Contains(msg, "arity") { + t.Errorf("arity mismatch should be surfaced: %v", misses[0]) + } +} + +func TestEnforceErrorIsReported(t *testing.T) { + e := newEnforcer(t, basicModel, "p, alice, data1, read\n") + ct := &captureT{} + // Wrong arity: casbin returns an error instead of a boolean. + AssertAllow(ct, e, "only-one-value") + if len(ct.errs) != 1 { + t.Fatalf("expected exactly one error, got %d: %v", len(ct.errs), ct.errs) + } + if !strings.Contains(ct.errs[0], "returned error") { + t.Errorf("enforce errors should be reported as errors, not misattributed: %s", ct.errs[0]) + } +} diff --git a/authztest/example_test.go b/authztest/example_test.go new file mode 100644 index 00000000..718eeb80 --- /dev/null +++ b/authztest/example_test.go @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package authztest_test + +import ( + "fmt" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/authztest" + "github.com/casbin/casbin/v3/model" +) + +// The classic failure: a request is denied and you don't know which field +// to look at. Diagnose attributes the failure position by position, +// crediting role inheritance via g rules. +func ExampleDiagnose() { + m, _ := model.NewModelFromString(` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +`) + e, _ := casbin.NewEnforcer(m) + e.AddPolicy("admin", "data2", "write") + e.AddGroupingPolicy("alice", "admin") + + fmt.Println(authztest.Diagnose(e, "alice", "data2", "read")) + // Output: + // no policy matched; closest candidates: + // p, admin, data2, write + // sub: ok ('alice' inherits 'admin' via g) + // obj: ok ('data2') + // act: 'read' does not match 'write' (not a wildcard) +} diff --git a/authztest/helpers.go b/authztest/helpers.go new file mode 100644 index 00000000..07d3bf97 --- /dev/null +++ b/authztest/helpers.go @@ -0,0 +1,101 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package authztest + +import ( + "fmt" + "sort" + "strings" + + "github.com/casbin/casbin/v3/model" +) + +// tokens extracts the declared field names ("sub", "obj", "act", ...) of a +// definition section, in declaration order. Casbin stores r/p tokens with a +// section prefix ("p_sub", "r_obj"); the prefix is stripped so callers see +// the names as written in the model file. +func tokens(m model.Model, sec, key string) []string { + am := m[sec] + if am == nil { + return nil + } + a := am[key] + if a == nil { + return nil + } + prefix := key + "_" + out := make([]string, len(a.Tokens)) + for i, t := range a.Tokens { + out[i] = strings.TrimPrefix(t, prefix) + } + return out +} + +func indexOf(ss []string, want string) int { + for i, s := range ss { + if s == want { + return i + } + } + return -1 +} + +// pad grows rvals to n entries with "?" so position labels stay aligned +// when the caller passes too few values. +func pad(rvals []interface{}, n int) []interface{} { + if len(rvals) >= n { + return rvals + } + out := make([]interface{}, n) + copy(out, rvals) + for i := len(rvals); i < n; i++ { + out[i] = "?" + } + return out +} + +func toStrings(rvals []interface{}) []string { + out := make([]string, len(rvals)) + for i, v := range rvals { + out[i] = fmt.Sprint(v) + } + return out +} + +func sortMisses(ms []NearMiss) { + sort.SliceStable(ms, func(i, j int) bool { + si, sj := ms[i].Score(), ms[j].Score() + if si != sj { + return si > sj + } + // Fewer failed positions means closer; fully matched first. + fi, fj := ms[i].FullyMatched(), ms[j].FullyMatched() + if fi != fj { + return fi + } + return len(ms[i].Failed) < len(ms[j].Failed) + }) +} + +func formatRequest(rvals []interface{}) string { + parts := make([]string, len(rvals)) + for i, v := range rvals { + parts[i] = fmt.Sprint(v) + } + return "(" + strings.Join(parts, ", ") + ")" +}