diff --git a/MULTILINE_MATCHER.md b/MULTILINE_MATCHER.md new file mode 100644 index 000000000..ca585cd22 --- /dev/null +++ b/MULTILINE_MATCHER.md @@ -0,0 +1,121 @@ +# Multi-line Matcher Support + +Casbin now supports multi-line matchers with block-style syntax, allowing you to write more complex and readable matcher expressions. + +## Features + +- **Let statements**: Define intermediate variables to break down complex expressions +- **Early returns**: Use `if` statements with `return` for conditional logic +- **Block syntax**: Write matchers within `{}` braces with multiple lines + +## Syntax + +### Basic Block Syntax + +```ini +[matchers] +m = { \ + return r.sub == p.sub && r.obj == p.obj && r.act == p.act \ +} +``` + +### With Let Statements + +```ini +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + let obj_match = r.obj == p.obj \ + let act_match = r.act == p.act \ + return role_match && obj_match && act_match \ +} +``` + +### With Nested Variables + +```ini +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + let obj_direct_match = r.obj == p.obj \ + let obj_inherit_match = g2(r.obj, p.obj) \ + let obj_match = obj_direct_match || obj_inherit_match \ + let act_match = r.act == p.act \ + return role_match && obj_match && act_match \ +} +``` + +### With Early Returns + +```ini +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + if !role_match { \ + return false \ + } \ + if r.act != p.act { \ + return false \ + } \ + if r.obj == p.obj { \ + return true \ + } \ + if g2(r.obj, p.obj) { \ + return true \ + } \ + return false \ +} +``` + +## How It Works + +The multi-line matcher syntax is automatically transformed into a single-line expression that can be evaluated by the underlying govaluate engine. This transformation: + +1. **Extracts let statements**: Variable definitions are identified and their expressions are stored +2. **Substitutes variables**: All variable references are replaced with their actual expressions +3. **Converts early returns**: `if` statements with returns are transformed into conditional logic using boolean operators + +For example, the matcher: +``` +{ + let role_match = g(r.sub, p.sub) + let obj_match = r.obj == p.obj + return role_match && obj_match +} +``` + +Is transformed into: +``` +(g(r.sub, p.sub)) && (r.obj == p.obj) +``` + +## Important Notes + +1. **Semicolons**: Do NOT use semicolons (`;`) at the end of statements. The config parser treats semicolons as comment markers and will strip them out. + +2. **Line continuation**: Use backslash (`\`) at the end of each line to continue the matcher across multiple lines in the config file. + +3. **Backward compatibility**: Traditional single-line matchers continue to work without any changes. + +4. **In-memory models**: You can use multi-line matchers in code when creating models programmatically: + ```go + m := model.NewModel() + m.AddDef("m", "m", `{ + let role_match = g(r.sub, p.sub) + let obj_match = r.obj == p.obj + return role_match && obj_match + }`) + ``` + +## Examples + +See the `examples/` directory for complete working examples: +- `rbac_with_hierarchy_multiline_model.conf` - Multi-line matcher with let statements +- `rbac_with_early_return_model.conf` - Multi-line matcher with early returns + +## Testing + +Run the multi-line matcher tests: +```bash +go test -v -run TestMultiLineMatcher +``` diff --git a/examples/issue_example_model.conf b/examples/issue_example_model.conf new file mode 100644 index 000000000..3b765e8a4 --- /dev/null +++ b/examples/issue_example_model.conf @@ -0,0 +1,22 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + let obj_direct_match = r.obj == p.obj \ + let obj_inherit_match = g2(r.obj, p.obj) \ + let obj_match = obj_direct_match || obj_inherit_match \ + let act_match = r.act == p.act \ + return role_match && obj_match && act_match \ +} diff --git a/examples/rbac_with_early_return_model.conf b/examples/rbac_with_early_return_model.conf new file mode 100644 index 000000000..a807cbdb7 --- /dev/null +++ b/examples/rbac_with_early_return_model.conf @@ -0,0 +1,30 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + if !role_match { \ + return false \ + } \ + if r.act != p.act { \ + return false \ + } \ + if r.obj == p.obj { \ + return true \ + } \ + if g2(r.obj, p.obj) { \ + return true \ + } \ + return false \ +} diff --git a/examples/rbac_with_hierarchy_multiline_model.conf b/examples/rbac_with_hierarchy_multiline_model.conf new file mode 100644 index 000000000..3b765e8a4 --- /dev/null +++ b/examples/rbac_with_hierarchy_multiline_model.conf @@ -0,0 +1,22 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = { \ + let role_match = g(r.sub, p.sub) \ + let obj_direct_match = r.obj == p.obj \ + let obj_inherit_match = g2(r.obj, p.obj) \ + let obj_match = obj_direct_match || obj_inherit_match \ + let act_match = r.act == p.act \ + return role_match && obj_match && act_match \ +} diff --git a/examples/rbac_with_hierarchy_multiline_policy.csv b/examples/rbac_with_hierarchy_multiline_policy.csv new file mode 100644 index 000000000..55bc5bd2e --- /dev/null +++ b/examples/rbac_with_hierarchy_multiline_policy.csv @@ -0,0 +1,7 @@ +p, alice, data1, read +p, bob, data2, write +p, data_group_admin, data_group, write + +g, alice, data_group_admin +g2, data1, data_group +g2, data2, data_group diff --git a/model/model.go b/model/model.go index 3f76010db..d797398dc 100644 --- a/model/model.go +++ b/model/model.go @@ -93,6 +93,9 @@ func (model Model) AddDef(sec string, key string, value string) bool { } if sec == "m" { + // Transform block-style matchers to single-line expressions + ast.Value = util.TransformBlockMatcher(ast.Value) + // Escape backslashes in string literals to match CSV parsing behavior ast.Value = util.EscapeStringLiterals(ast.Value) diff --git a/multiline_matcher_test.go b/multiline_matcher_test.go new file mode 100644 index 000000000..29b66bd30 --- /dev/null +++ b/multiline_matcher_test.go @@ -0,0 +1,134 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed 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 casbin + +import ( + "testing" + + "github.com/casbin/casbin/v3/model" +) + +func TestMultiLineMatcherWithLetStatements(t *testing.T) { + e, err := NewEnforcer("examples/rbac_with_hierarchy_multiline_model.conf", "examples/rbac_with_hierarchy_multiline_policy.csv") + if err != nil { + t.Fatalf("Failed to create enforcer: %v", err) + } + + // alice has direct permission on data1 for read + testEnforce(t, e, "alice", "data1", "read", true) + + // alice doesn't have direct permission on data1 for write, but has via role and resource hierarchy + testEnforce(t, e, "alice", "data1", "write", true) + + // bob has direct permission on data2 for write + testEnforce(t, e, "bob", "data2", "write", true) + + // bob doesn't have direct permission on data1 + testEnforce(t, e, "bob", "data1", "read", false) + testEnforce(t, e, "bob", "data1", "write", false) + + // Test with inherited permissions through data_group + testEnforce(t, e, "alice", "data2", "write", true) +} + +func TestMultiLineMatcherWithEarlyReturn(t *testing.T) { + e, err := NewEnforcer("examples/rbac_with_early_return_model.conf", "examples/rbac_with_hierarchy_multiline_policy.csv") + if err != nil { + t.Fatalf("Failed to create enforcer: %v", err) + } + + // alice has direct permission on data1 for read + testEnforce(t, e, "alice", "data1", "read", true) + + // alice doesn't have direct permission on data1 for write, but has via role + testEnforce(t, e, "alice", "data1", "write", true) + + // bob has direct permission on data2 for write + testEnforce(t, e, "bob", "data2", "write", true) + + // bob doesn't have permission on data1 + testEnforce(t, e, "bob", "data1", "read", false) + testEnforce(t, e, "bob", "data1", "write", false) + + // alice can write to data2 through role and resource hierarchy + testEnforce(t, e, "alice", "data2", "write", true) +} + +func TestMultiLineMatcherInMemory(t *testing.T) { + m := model.NewModel() + m.AddDef("r", "r", "sub, obj, act") + m.AddDef("p", "p", "sub, obj, act") + m.AddDef("g", "g", "_, _") + m.AddDef("e", "e", "some(where (p.eft == allow))") + m.AddDef("m", "m", `{ + let role_match = g(r.sub, p.sub) + let obj_match = r.obj == p.obj + let act_match = r.act == p.act + return role_match && obj_match && act_match + }`) + + e, err := NewEnforcer(m) + if err != nil { + t.Fatalf("Failed to create enforcer: %v", err) + } + + // Add policies + _, _ = e.AddPolicy("alice", "data1", "read") + _, _ = e.AddPolicy("data_admin", "data2", "write") + _, _ = e.AddGroupingPolicy("bob", "data_admin") + + // Test enforcement + testEnforce(t, e, "alice", "data1", "read", true) + testEnforce(t, e, "alice", "data1", "write", false) + testEnforce(t, e, "bob", "data2", "write", true) + testEnforce(t, e, "bob", "data1", "read", false) +} + +func TestSimpleBlockMatcher(t *testing.T) { + m := model.NewModel() + m.AddDef("r", "r", "sub, obj, act") + m.AddDef("p", "p", "sub, obj, act") + m.AddDef("e", "e", "some(where (p.eft == allow))") + m.AddDef("m", "m", `{ + return r.sub == p.sub && r.obj == p.obj && r.act == p.act + }`) + + e, err := NewEnforcer(m) + if err != nil { + t.Fatalf("Failed to create enforcer: %v", err) + } + + _, _ = e.AddPolicy("alice", "data1", "read") + _, _ = e.AddPolicy("bob", "data2", "write") + + testEnforce(t, e, "alice", "data1", "read", true) + testEnforce(t, e, "alice", "data1", "write", false) + testEnforce(t, e, "bob", "data2", "write", true) + testEnforce(t, e, "bob", "data1", "read", false) +} + +func TestIssueExampleMatcher(t *testing.T) { + // This test demonstrates the exact use case from the issue + e, err := NewEnforcer("examples/issue_example_model.conf", "examples/rbac_with_hierarchy_multiline_policy.csv") + if err != nil { + t.Fatalf("Failed to create enforcer: %v", err) + } + + // Verify the multi-line matcher with let statements works correctly + testEnforce(t, e, "alice", "data1", "read", true) + testEnforce(t, e, "alice", "data1", "write", true) // via role + resource hierarchy + testEnforce(t, e, "bob", "data2", "write", true) + testEnforce(t, e, "alice", "data2", "write", true) // via role + resource hierarchy +} diff --git a/util/matcher_transform.go b/util/matcher_transform.go new file mode 100644 index 000000000..6938e587e --- /dev/null +++ b/util/matcher_transform.go @@ -0,0 +1,296 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed 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 util + +import ( + "regexp" + "strings" +) + +var ( + // Regex to detect block-style matcher (starts with {). + blockMatcherRegex = regexp.MustCompile(`^\s*\{`) +) + +const ( + // maxSubstitutionPasses defines the maximum number of variable substitution passes + // to prevent infinite loops in case of circular references. + maxSubstitutionPasses = 10 +) + +// TransformBlockMatcher transforms a block-style matcher to a single-line expression +// that can be evaluated by govaluate. +// +// Example transformation: +// Input: +// +// { +// let role_match = g(r.sub, p.sub) +// let obj_match = r.obj == p.obj +// return role_match && obj_match +// } +// +// Output: +// +// g(r.sub, p.sub) && r.obj == p.obj +func TransformBlockMatcher(matcher string) string { + matcher = strings.TrimSpace(matcher) + + // Check if this is a block-style matcher + if !blockMatcherRegex.MatchString(matcher) { + return matcher + } + + // Remove outer braces + matcher = strings.TrimPrefix(matcher, "{") + matcher = strings.TrimSuffix(strings.TrimSpace(matcher), "}") + matcher = strings.TrimSpace(matcher) + + // Parse the block into statements + statements := parseStatements(matcher) + + // Build a map of variable substitutions from let statements + varMap := make(map[string]string) + var ifStatements []ifStatement + var finalReturn string + + for _, stmt := range statements { + if stmt.stmtType == stmtTypeLet { + varMap[stmt.varName] = stmt.expression + } else if stmt.stmtType == stmtTypeIf { + ifStatements = append(ifStatements, ifStatement{ + condition: stmt.condition, + returnValue: stmt.expression, + }) + } else if stmt.stmtType == stmtTypeReturn { + finalReturn = stmt.expression + } + } + + // Substitute variables in all expressions + substituteVars := func(expr string) string { + // Perform multiple passes to handle nested variable references + for i := 0; i < maxSubstitutionPasses; i++ { + changed := false + for varName, varExpr := range varMap { + // Use word boundaries to avoid partial matches + pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(varName) + `\b`) + newExpr := pattern.ReplaceAllString(expr, "("+varExpr+")") + if newExpr != expr { + changed = true + expr = newExpr + } + } + if !changed { + break + } + } + return expr + } + + // Substitute variables in if conditions and return values + for i := range ifStatements { + ifStatements[i].condition = substituteVars(ifStatements[i].condition) + ifStatements[i].returnValue = substituteVars(ifStatements[i].returnValue) + } + finalReturn = substituteVars(finalReturn) + + // Build the final expression + // Handle early returns by converting them to conditional logic + result := finalReturn + for i := len(ifStatements) - 1; i >= 0; i-- { + condition := ifStatements[i].condition + returnValue := ifStatements[i].returnValue + // Transform: if condition { return returnValue } else { ... rest ... } + // to: (condition && returnValue) || (!condition && rest) + result = "((" + condition + ") && (" + returnValue + ")) || (!(" + condition + ") && (" + result + "))" + } + + return result +} + +type statementType int + +const ( + stmtTypeLet statementType = iota + stmtTypeIf + stmtTypeReturn +) + +type statement struct { + stmtType statementType + varName string + expression string + condition string +} + +type ifStatement struct { + condition string + returnValue string +} + +func parseStatements(block string) []statement { + var statements []statement + + i := 0 + for i < len(block) { + i = skipWhitespace(block, i) + if i >= len(block) { + break + } + + // Check for keywords + if strings.HasPrefix(block[i:], "let ") { + stmt, newPos := parseLetStatement(block, i) + statements = append(statements, stmt) + i = newPos + } else if strings.HasPrefix(block[i:], "if ") { + stmt, newPos := parseIfStatement(block, i) + statements = append(statements, stmt) + i = newPos + } else if strings.HasPrefix(block[i:], "return ") { + stmt, newPos := parseReturnStatement(block, i) + statements = append(statements, stmt) + i = newPos + } else { + // Skip whitespace or unknown characters + i = skipUnknownToken(block, i) + } + } + + return statements +} + +func skipWhitespace(block string, i int) int { + for i < len(block) && (block[i] == ' ' || block[i] == '\t' || block[i] == '\n' || block[i] == '\r') { + i++ + } + return i +} + +func skipUnknownToken(block string, i int) int { + if i < len(block) && (block[i] == ' ' || block[i] == '\t' || block[i] == '\n' || block[i] == '\r') { + return i + 1 + } + if i < len(block) { + // Unknown token - skip the character and continue parsing + return i + 1 + } + return i +} + +func parseLetStatement(block string, i int) (statement, int) { + i += 4 // skip "let " + + // Find variable name + varStart := i + for i < len(block) && (isLetterOrDigit(block[i]) || block[i] == '_') { + i++ + } + varName := block[varStart:i] + + // Skip whitespace and '=' + for i < len(block) && (block[i] == ' ' || block[i] == '\t' || block[i] == '=') { + i++ + } + + // Find expression (until next keyword or end) + exprStart := i + depth := 0 + for i < len(block) { + if block[i] == '(' || block[i] == '[' || block[i] == '{' { + depth++ + } else if block[i] == ')' || block[i] == ']' || block[i] == '}' { + depth-- + } + + if depth == 0 && isAtKeyword(block, i) { + break + } + i++ + } + expression := strings.TrimSpace(block[exprStart:i]) + + return statement{ + stmtType: stmtTypeLet, + varName: varName, + expression: expression, + }, i +} + +func parseIfStatement(block string, i int) (statement, int) { + i += 3 // skip "if " + + // Find condition (until '{') + condStart := i + for i < len(block) && block[i] != '{' { + i++ + } + condition := strings.TrimSpace(block[condStart:i]) + + // Skip '{' + if i < len(block) && block[i] == '{' { + i++ + } + + // Skip whitespace and "return" + i = skipWhitespace(block, i) + if strings.HasPrefix(block[i:], "return ") { + i += 7 // skip "return " + } + + // Find return value (until '}') + valueStart := i + for i < len(block) && block[i] != '}' { + i++ + } + returnValue := strings.TrimSpace(block[valueStart:i]) + + // Skip '}' + if i < len(block) && block[i] == '}' { + i++ + } + + return statement{ + stmtType: stmtTypeIf, + condition: condition, + expression: returnValue, + }, i +} + +func parseReturnStatement(block string, i int) (statement, int) { + i += 7 // skip "return " + + // Find expression (until end) + exprStart := i + i = len(block) + expression := strings.TrimSpace(block[exprStart:i]) + + return statement{ + stmtType: stmtTypeReturn, + expression: expression, + }, i +} + +func isAtKeyword(block string, i int) bool { + remaining := block[i:] + return strings.HasPrefix(remaining, "let ") || + strings.HasPrefix(remaining, "if ") || + strings.HasPrefix(remaining, "return ") +} + +func isLetterOrDigit(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} diff --git a/util/matcher_transform_test.go b/util/matcher_transform_test.go new file mode 100644 index 000000000..0dd58ee46 --- /dev/null +++ b/util/matcher_transform_test.go @@ -0,0 +1,117 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed 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 util + +import ( + "strings" + "testing" +) + +func TestTransformBlockMatcher(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "non-block matcher", + input: "g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act", + expected: "g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act", + }, + { + name: "simple block with let statements", + input: `{ + let role_match = g(r.sub, p.sub) + let obj_match = r.obj == p.obj + let act_match = r.act == p.act + return role_match && obj_match && act_match + }`, + expected: "(g(r.sub, p.sub)) && (r.obj == p.obj) && (r.act == p.act)", + }, + { + name: "block with nested let expressions", + input: `{ + let role_match = g(r.sub, p.sub) + let obj_direct_match = r.obj == p.obj + let obj_inherit_match = g2(r.obj, p.obj) + let obj_match = obj_direct_match || obj_inherit_match + let act_match = r.act == p.act + return role_match && obj_match && act_match + }`, + expected: "(g(r.sub, p.sub)) && ((r.obj == p.obj) || (g2(r.obj, p.obj))) && (r.act == p.act)", + }, + { + name: "block with single early return", + input: `{ + let role_match = g(r.sub, p.sub) + if !role_match { + return false + } + return r.obj == p.obj + }`, + expected: "((!(g(r.sub, p.sub))) && (false)) || (!(!(g(r.sub, p.sub))) && (r.obj == p.obj))", + }, + { + name: "block with multiple early returns", + input: `{ + let role_match = g(r.sub, p.sub) + if !role_match { + return false + } + if r.act != p.act { + return false + } + if r.obj == p.obj { + return true + } + if g2(r.obj, p.obj) { + return true + } + return false + }`, + expected: "((!(g(r.sub, p.sub))) && (false)) || (!(!(g(r.sub, p.sub))) && (((r.act != p.act) && (false)) || (!(r.act != p.act) && (((r.obj == p.obj) && (true)) || (!(r.obj == p.obj) && (((g2(r.obj, p.obj)) && (true)) || (!(g2(r.obj, p.obj)) && (false))))))))", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TransformBlockMatcher(tt.input) + // Normalize whitespace for comparison + result = strings.Join(strings.Fields(result), " ") + expected := strings.Join(strings.Fields(tt.expected), " ") + + if result != expected { + t.Errorf("TransformBlockMatcher() = %v, want %v", result, expected) + } + }) + } +} + +func TestTransformBlockMatcherEdgeCases(t *testing.T) { + // Test block with only return + input := `{ return true }` + result := TransformBlockMatcher(input) + expected := "true" + if strings.TrimSpace(result) != expected { + t.Errorf("Block with only return should be transformed to %v, got: %v", expected, result) + } + + // Test expression without braces + input = "r.sub == p.sub" + result = TransformBlockMatcher(input) + if result != input { + t.Errorf("Expression without braces should be unchanged, got: %v", result) + } +}