Skip to content

Bug: get_implicit_permissions_for_user returns references to internal policy rows, mutating the enforcer store #424

@killmesoonbaby044

Description

@killmesoonbaby044

Description

Calling get_implicit_permissions_for_user() (or its async counterpart) and then modifying the returned list elements silently corrupts the enforcer's in-memory policy store. Subsequent enforce() calls will produce incorrect results without any error or warning.

Steps to Reproduce

import casbin

e = casbin.Enforcer("model.conf", "policy.csv")
# policy: p, admin, data1, read
# policy: p, alice, data2, read
# g, alice, admin

perms = e.get_implicit_permissions_for_user("alice")
# perms == [["admin", "data1", "read"], ["alice", "data2", "read"]]

# Mutate the returned list — e.g. to transform/enrich data
perms[0][2] = "write"

# The enforcer's internal store is now corrupted:
print(e.get_policy())
# [["admin", "data1", "write"], ["alice", "data2", "read"]]  ← WRONG

Reproducible with both casbin.Enforcer (sync) and casbin.AsyncEnforcer (async).

Root Cause

The full call chain for the async case (same path exists in the sync enforcer):

AsyncEnforcer.get_implicit_permissions_for_user()
  → get_named_implicit_permissions_for_user()        # async_enforcer.py
      → get_named_permissions_for_user_in_domain()   # async_enforcer.py
          → get_filtered_named_policy()              # management_enforcer.py
              → get_filtered_policy()                # model/policy.py  ← root cause

The bug is in get_filtered_policy in casbin/model/policy.py:

def get_filtered_policy(self, sec, ptype, field_index, *field_values):
    return [
        rule  # ← returns the actual list object from internal storage, not a copy
        for rule in self[sec][ptype].policy
        if all(
            (callable(value) and value(rule[field_index + i])) or (value == "" or rule[field_index + i] == value)
            for i, value in enumerate(field_values)
        )
    ]

The list comprehension returns the same list objects that are stored in self[sec][ptype].policy. No copy is made at any point in the chain. As a result, the caller receives direct aliases into the enforcer's internal storage, and any in-place mutation immediately corrupts it.

This propagates up through get_named_implicit_permissions_for_user:

for role in roles:
    permissions = await self.get_named_permissions_for_user_in_domain(ptype, role, ...)
    res.extend(permissions)  # ← extends with direct references into self.model
return res

This affects all methods that go through get_filtered_policy:

  • get_implicit_permissions_for_user()
  • get_named_implicit_permissions_for_user()
  • get_permissions_for_user()
  • get_filtered_policy() (directly)

Expected Behavior

The returned list should be fully independent of the enforcer's internal state. Modifying it must not affect the enforcer.

Proposed Fix

The fix belongs in get_filtered_policy in casbin/model/policy.py, so all callers are protected automatically. Change rule to rule[:]:

def get_filtered_policy(self, sec, ptype, field_index, *field_values):
    return [
        rule[:]  # ← shallow copy; safe since policy rows are list[str] and str is immutable
        for rule in self[sec][ptype].policy
        if all(
            (callable(value) and value(rule[field_index + i])) or (value == "" or rule[field_index + i] == value)
            for i, value in enumerate(field_values)
        )
    ]

rule[:] (shallow slice copy) is sufficient — policy rows are list[str] and strings are immutable in Python, so a one-level copy fully isolates the caller. copy.deepcopy is not needed and would add unnecessary overhead on every policy query.

Fixing it here covers all affected methods at once without touching each caller individually.

Workaround (until fixed)

Deep-copy the result immediately after calling:

import copy
perms = copy.deepcopy(await enforcer.get_implicit_permissions_for_user(user))

Or, if you are transforming the result into typed objects anyway, simply never mutate the raw rows in-place:

# Safe — constructs new objects without touching the original rows
result = [MyDTO(sub=row[0], obj=row[1], act=row[2]) for row in raw]

Environment

  • pycasbin version: v2.8.0
  • Python version: v3.10.12
  • Async enforcer: yes
  • Async enforcer version: v1.17.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions