Skip to content

Commit ff40def

Browse files
[feature] Implement Policy Filtering
A new interface, `FilteredAdapter`, extends the `Adapter` interface with the ability to load a filtered subset of the backend policy. This allows Casbin to more effectively scale when enforcing a very large number of policies, such as a busy multi-tenant system. There is also a second built-in file adapter supporting this feature. To prevent accidental data loss, the `SavePolicy` method is disabled when a filtered policy is loaded. For maximum compatibility, whether a given `Adapter` implements the new feature is checked at runtime.
1 parent f418a84 commit ff40def

File tree

6 files changed

+323
-12
lines changed

6 files changed

+323
-12
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,33 @@ Adapter | Type | Author | Description
194194
195195
For details of adapters, please refer to the documentation: https://github.com/casbin/casbin/wiki/Policy-persistence
196196
197+
## Policy enforcement at scale
198+
199+
Some adapters support filtered policy management. This means that the policy loaded by Casbin is a subset of the policy in storage based on a given filter. This allows for efficient policy enforcement in large, multi-tenant environments when parsing the entire policy becomes a performance bottleneck.
200+
201+
To use filtered policies with a supported adapter, simply call the `LoadFilteredPolicy` method. The valid format for the filter parameter depends on the adapter used. To prevent accidental data loss, the `SavePolicy` method is disabled when a filtered policy is loaded.
202+
203+
For example, the following code snippet uses the built-in filtered file adapter and the RBAC model with domains. In this case, the filter limits the policy to a single domain. Any policy lines for domains other than `"domain1"` are omitted from the loaded policy:
204+
205+
```go
206+
import (
207+
"github.com/casbin/casbin"
208+
)
209+
210+
enforcer := casbin.NewEnforcer()
211+
212+
adapter := fileadapter.NewFilteredAdapter("examples/rbac_with_domains_policy.csv")
213+
enforcer.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
214+
215+
filter := &fileadapter.Filter{
216+
P: []string{"", "domain1"},
217+
G: []string{"", "", "domain1"},
218+
}
219+
enforcer.LoadFilteredPolicy(filter)
220+
221+
// The loaded policy now only contains the entries pertaining to "domain1".
222+
```
223+
197224
## Policy consistence between multiple nodes
198225
199226
We support to use distributed messaging systems like [etcd](https://github.com/coreos/etcd) to keep consistence between multiple Casbin enforcer instances. So our users can concurrently use multiple Casbin enforcers to handle large number of permission checking requests.

enforcer.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,43 @@ func (e *Enforcer) LoadPolicy() error {
225225
return nil
226226
}
227227

228+
// LoadFilteredPolicy reloads a filtered policy from file/database.
229+
func (e *Enforcer) LoadFilteredPolicy(filter interface{}) error {
230+
e.model.ClearPolicy()
231+
232+
// Attempt to cast the Adapter as a FilteredAdapter
233+
a := reflect.ValueOf(e.adapter)
234+
filteredAdapter, ok := a.Interface().(persist.FilteredAdapter)
235+
if !ok {
236+
return errors.New("filtered policies are not supported by this adapter")
237+
}
238+
err := filteredAdapter.LoadFilteredPolicy(e.model, filter)
239+
if err != nil {
240+
return err
241+
}
242+
243+
e.model.PrintPolicy()
244+
if e.autoBuildRoleLinks {
245+
e.BuildRoleLinks()
246+
}
247+
return nil
248+
}
249+
250+
// IsFiltered returns true if the loaded policy has been filtered.
251+
func (e *Enforcer) IsFiltered() bool {
252+
a := reflect.ValueOf(e.adapter)
253+
filteredAdapter, ok := a.Interface().(persist.FilteredAdapter)
254+
if !ok {
255+
return false
256+
}
257+
return filteredAdapter.IsFiltered()
258+
}
259+
228260
// SavePolicy saves the current policy (usually after changed with Casbin API) back to file/database.
229261
func (e *Enforcer) SavePolicy() error {
262+
if e.IsFiltered() {
263+
return errors.New("cannot save a filtered policy")
264+
}
230265
err := e.adapter.SavePolicy(e.model)
231266
if err == nil {
232267
if e.watcher != nil {

enforcer_test.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,4 +400,95 @@ func TestInitEmpty(t *testing.T) {
400400
e.LoadPolicy()
401401

402402
testEnforce(t, e, "alice", "/alice_data/resource1", "GET", true)
403-
}
403+
}
404+
405+
func TestLoadFilteredPolicy(t *testing.T) {
406+
e := NewEnforcer()
407+
408+
adapter := fileadapter.NewFilteredAdapter("examples/rbac_with_domains_policy.csv")
409+
e.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
410+
411+
// validate initial conditions
412+
testHasPolicy(t, e, []string{"admin", "domain1", "data1", "read"}, true)
413+
testHasPolicy(t, e, []string{"admin", "domain2", "data2", "read"}, true)
414+
415+
if err := e.LoadFilteredPolicy(&fileadapter.Filter{
416+
P: []string{"", "domain1"},
417+
G: []string{"", "", "domain1"},
418+
}); err != nil {
419+
t.Errorf("unexpected error in LoadFilteredPolicy: %v", err)
420+
}
421+
if !e.IsFiltered() {
422+
t.Errorf("adapter did not set the filtered flag correctly")
423+
}
424+
425+
// only policies for domain1 should be loaded
426+
testHasPolicy(t, e, []string{"admin", "domain1", "data1", "read"}, true)
427+
testHasPolicy(t, e, []string{"admin", "domain2", "data2", "read"}, false)
428+
429+
if err := e.SavePolicy(); err == nil {
430+
t.Errorf("enforcer did not prevent saving filtered policy")
431+
}
432+
if err := e.GetAdapter().SavePolicy(); err == nil {
433+
t.Errorf("adapter did not prevent saving filtered policy")
434+
}
435+
}
436+
437+
func TestFilteredPolicyInvalidFilter(t *testing.T) {
438+
e := NewEnforcer()
439+
440+
adapter := fileadapter.NewFilteredAdapter("examples/rbac_with_domains_policy.csv")
441+
e.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
442+
443+
if err := e.LoadFilteredPolicy([]string{"", "domain1"}); err == nil {
444+
t.Errorf("expected error in LoadFilteredPolicy, but got nil")
445+
}
446+
}
447+
448+
func TestFilteredPolicyEmptyFilter(t *testing.T) {
449+
e := NewEnforcer()
450+
451+
adapter := fileadapter.NewFilteredAdapter("examples/rbac_with_domains_policy.csv")
452+
e.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
453+
454+
if err := e.LoadFilteredPolicy(nil); err != nil {
455+
t.Errorf("unexpected error in LoadFilteredPolicy: %v", err)
456+
}
457+
if e.IsFiltered() {
458+
t.Errorf("adapter did not reset the filtered flag correctly")
459+
}
460+
}
461+
462+
func TestUnsupportedFilteredPolicy(t *testing.T) {
463+
e := NewEnforcer("examples/rbac_with_domains_model.conf", "examples/rbac_with_domains_policy.csv")
464+
465+
err := e.LoadFilteredPolicy(&fileadapter.Filter{
466+
P: []string{"", "domain1"},
467+
G: []string{"", "", "domain1"},
468+
})
469+
if err == nil {
470+
t.Errorf("encorcer should have reported incompatibility error")
471+
}
472+
}
473+
474+
func TestFilteredAdapterEmptyFilepath(t *testing.T) {
475+
e := NewEnforcer()
476+
477+
adapter := fileadapter.NewFilteredAdapter("")
478+
e.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
479+
480+
if err := e.LoadFilteredPolicy(nil); err == nil {
481+
t.Errorf("expected error in LoadFilteredPolicy, but got nil")
482+
}
483+
}
484+
485+
func TestFilteredAdapterInvalidFilepath(t *testing.T) {
486+
e := NewEnforcer()
487+
488+
adapter := fileadapter.NewFilteredAdapter("examples/does_not_exist_policy.csv")
489+
e.InitWithAdapter("examples/rbac_with_domains_model.conf", adapter)
490+
491+
if err := e.LoadFilteredPolicy(nil); err == nil {
492+
t.Errorf("expected error in LoadFilteredPolicy, but got nil")
493+
}
494+
}

persist/adapter_filtered.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2017 The casbin Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package persist
16+
17+
import (
18+
"github.com/casbin/casbin/model"
19+
)
20+
21+
// FilteredAdapter is the interface for Casbin adapters supporting filtered policies.
22+
type FilteredAdapter interface {
23+
Adapter
24+
25+
// LoadFilteredPolicy loads only policy rules that match the filter.
26+
LoadFilteredPolicy(model model.Model, filter interface{}) error
27+
// IsFiltered returns true if the loaded policy has been filtered.
28+
IsFiltered() bool
29+
}

persist/file-adapter/adapter.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"bufio"
1919
"bytes"
2020
"errors"
21-
"io"
2221
"os"
2322
"strings"
2423

@@ -85,18 +84,12 @@ func (a *Adapter) loadPolicyFile(model model.Model, handler func(string, model.M
8584
}
8685
defer f.Close()
8786

88-
buf := bufio.NewReader(f)
89-
for {
90-
line, err := buf.ReadString('\n')
91-
line = strings.TrimSpace(line)
87+
scanner := bufio.NewScanner(f)
88+
for scanner.Scan() {
89+
line := strings.TrimSpace(scanner.Text())
9290
handler(line, model)
93-
if err != nil {
94-
if err == io.EOF {
95-
return nil
96-
}
97-
return err
98-
}
9991
}
92+
return scanner.Err()
10093
}
10194

10295
func (a *Adapter) savePolicyFile(text string) error {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright 2017 The casbin Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package fileadapter
16+
17+
import (
18+
"bufio"
19+
"errors"
20+
"os"
21+
"strings"
22+
23+
"github.com/casbin/casbin/model"
24+
"github.com/casbin/casbin/persist"
25+
)
26+
27+
// FilteredAdapter is the filtered file adapter for Casbin. It can load policy
28+
// from file or save policy to file and supports loading of filtered policies.
29+
type FilteredAdapter struct {
30+
*Adapter
31+
filtered bool
32+
}
33+
34+
// Filter defines the filtering rules for a FilteredAdapter's policy. Empty values
35+
// are ignored, but all others must match the filter.
36+
type Filter struct {
37+
P []string
38+
G []string
39+
}
40+
41+
// NewFilteredAdapter is the constructor for FilteredAdapter.
42+
func NewFilteredAdapter(filePath string) *FilteredAdapter {
43+
a := FilteredAdapter{}
44+
a.Adapter = NewAdapter(filePath)
45+
return &a
46+
}
47+
48+
func (a *FilteredAdapter) LoadPolicy(model model.Model) error {
49+
a.filtered = false
50+
return a.Adapter.LoadPolicy(model)
51+
}
52+
53+
// LoadPolicy loads all policy rules from the storage.
54+
func (a *FilteredAdapter) LoadFilteredPolicy(model model.Model, filter interface{}) error {
55+
if filter == nil {
56+
return a.LoadPolicy(model)
57+
}
58+
if a.filePath == "" {
59+
return errors.New("invalid file path, file path cannot be empty")
60+
}
61+
62+
filterValue, ok := filter.(*Filter)
63+
if !ok {
64+
return errors.New("invalid filter type")
65+
}
66+
err := a.loadFilteredPolicyFile(model, filterValue, persist.LoadPolicyLine)
67+
if err == nil {
68+
a.filtered = true
69+
}
70+
return err
71+
}
72+
73+
func (a *FilteredAdapter) loadFilteredPolicyFile(model model.Model, filter *Filter, handler func(string, model.Model)) error {
74+
f, err := os.Open(a.filePath)
75+
if err != nil {
76+
return err
77+
}
78+
defer f.Close()
79+
80+
scanner := bufio.NewScanner(f)
81+
for scanner.Scan() {
82+
line := strings.TrimSpace(scanner.Text())
83+
84+
if filterLine(line, filter) {
85+
continue
86+
}
87+
88+
handler(line, model)
89+
}
90+
return scanner.Err()
91+
}
92+
93+
// IsFiltered returns true if the loaded policy has been filtered.
94+
func (a *FilteredAdapter) IsFiltered() bool {
95+
return a.filtered
96+
}
97+
98+
// SavePolicy saves all policy rules to the storage.
99+
func (a *FilteredAdapter) SavePolicy(model model.Model) error {
100+
if a.filtered == true {
101+
return errors.New("cannot save a filtered policy")
102+
}
103+
return a.Adapter.SavePolicy(model)
104+
}
105+
106+
func filterLine(line string, filter *Filter) bool {
107+
if filter == nil {
108+
return false
109+
}
110+
p := strings.Split(line, ",")
111+
if len(p) == 0 {
112+
return true
113+
}
114+
var filterSlice []string
115+
switch strings.TrimSpace(p[0]) {
116+
case "p":
117+
filterSlice = filter.P
118+
case "g":
119+
filterSlice = filter.G
120+
}
121+
return filterWords(p, filterSlice)
122+
}
123+
124+
func filterWords(line []string, filter []string) bool {
125+
if len(line) < len(filter)+1 {
126+
return true
127+
}
128+
var skipLine bool
129+
for i, v := range filter {
130+
if len(v) > 0 && strings.TrimSpace(v) != strings.TrimSpace(line[i+1]) {
131+
skipLine = true
132+
break
133+
}
134+
}
135+
return skipLine
136+
}

0 commit comments

Comments
 (0)