AccessController & Permission

On this page

Purpose

A simple in-memory RBAC (role-based access control) layer for gating (user_id, tenant_id, Permission) triples. AIMemoryManager constructs one AccessController internally and calls into it via _check() before every save_interaction/build_context call — see the important caveat about its actual default-deny/allow behavior below.

Permission

Python · 5 lines
class Permission(Enum):
    READ = "read"
    WRITE = "write"
    DELETE = "delete"
    ADMIN = "admin"

Built-in roles

Python · 6 lines
BUILT_IN_ROLES: Dict[str, Set[Permission]] = {
    "reader": {Permission.READ},
    "writer": {Permission.READ, Permission.WRITE},
    "owner":  {Permission.READ, Permission.WRITE, Permission.DELETE},
    "admin":  {Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN},
}

Each role is a strict superset of the one before it in this list.

Constructor

Python
def __init__(self) -> None

No parameters. Initializes empty _assignments (user_id → tenant_id → set[role_name]) and _custom_roles (role_name → set[Permission]) dicts.

Public methods

define_role(role_name: str, permissions: Set[Permission]) -> None

Registers a custom role. Raises ValueError if role_name collides with a built-in role name ("reader", "writer", "owner", "admin") — but does not check for collision with an already-defined custom role, so calling define_role twice with the same custom name silently overwrites the first definition.

assign_role(user_id: str, tenant_id: str, role: str) -> None

Raises ValueError(f"Unknown role '{role}'.") if role isn't a built-in or previously-defined custom role. Otherwise adds it to the user's role set for that tenant (a set, so re-assigning the same role is a no-op).

revoke_role(user_id: str, tenant_id: str, role: str) -> None

set.discard() semantics — no error if the user never had that role, or never had any roles for that tenant at all.

has_permission(user_id: str, tenant_id: str, permission: Permission) -> bool

True if any of the user's assigned roles for that tenant include the given permission. Returns False (not an error) for a user with zero roles assigned.

require(user_id: str, tenant_id: str, permission: Permission) -> None

Raises PermissionError if has_permission is False. This is a strict, deny-by-default check when used directly. However, see the important note below about how AIMemoryManager actually wraps this method.

list_permissions(user_id: str, tenant_id: str) -> Set[Permission]

Union of permissions across all of the user's assigned roles for that tenant. Empty set if no roles assigned.

Raises

ExceptionWhen
ValueErrordefine_role with a built-in role name; assign_role with an unregistered role name.
PermissionErrorrequire() when the user lacks the permission (only if called directly — see below).

Thread safety

Not thread-safe — _assignments and _custom_roles are plain dicts/sets mutated without any lock. Concurrent assign_role/revoke_role calls for the same user_id/tenant_id from different threads are a data race (though Python's GIL prevents low-level corruption of the dict/set structures themselves, the higher-level read-modify-write sequences in setdefault(...).add(...) are not atomic as a whole).

Complexity

All operations are O(1) to O(r) where r is the number of roles a user has for a tenant (typically small, ≤ 4).

Important: how AIMemoryManager actually uses this class

AccessController.require() is genuinely deny-by-default — but AIMemoryManager._check() wraps it with a "fail open if no roles assigned at all" fallback:

Python · 9 lines
def _check(self, user_id: str, tenant_id: str, perm: Permission) -> None:
    try:
        self._access_ctrl.require(user_id, tenant_id, perm)
    except PermissionError:
        perms = self._access_ctrl.list_permissions(user_id, tenant_id)
        if not perms:
            pass  # Open by default — caller can tighten with grant()
        else:
            raise

In practice this means:

  • A user with zero roles assigned for a tenant is allowed to do anything (save_interaction, build_context) — the PermissionError is caught and swallowed.
  • A user who has at least one role, but that role lacks the specific permission being checked, is correctly denied with PermissionError.

[!DANGER] Access control is opt-in, not opt-out, when used via AIMemoryManager If your application never calls mgr.grant(user_id, tenant_id, role=...) for a given user, AIMemoryManager will permit that user full read/write access to that tenant's memory by default. AccessController itself is deny-by-default in isolation, but AIMemoryManager._check()'s "open by default" fallback effectively disables enforcement until you explicitly assign at least one (even minimal) role to a user. If you're relying on AccessController/Permission for real multi-tenant isolation, you must call grant() for every user you want restricted — an ungranted user is not denied, they're wide open. See Known Issues.

Examples

Python · 29 lines
from zerda.memory import AccessController, Permission

# 1. Basic
ctrl = AccessController()
ctrl.assign_role("alice", "acme-corp", "writer")
print(ctrl.has_permission("alice", "acme-corp", Permission.WRITE))  # True
print(ctrl.has_permission("alice", "acme-corp", Permission.DELETE)) # False

# 2. Intermediate — custom role
ctrl.define_role("auditor", {Permission.READ})
ctrl.assign_role("bob", "acme-corp", "auditor")

# 3. Advanced — revoke
ctrl.revoke_role("alice", "acme-corp", "writer")
print(ctrl.list_permissions("alice", "acme-corp"))  # set()

# 4. Error handling
try:
    ctrl.define_role("admin", {Permission.READ})  # collides with built-in
except ValueError as e:
    print(e)

try:
    ctrl.require("charlie", "acme-corp", Permission.READ)  # charlie has no roles
except PermissionError as e:
    print(e)  # raised here, in direct use — unlike AIMemoryManager's wrapped _check()

# 5. Production — explicit deny-by-default pattern if you don't want AIMemoryManager's open-by-default fallback
ctrl.assign_role("every_known_user", "acme-corp", "reader")  # bare-minimum role for anyone you want restricted

Best practices

  • If you're using AIMemoryManager and need real multi-tenant enforcement, call mgr.grant(user_id, tenant_id, role="reader") (or stricter) for every user — don't rely on the absence of a grant to mean "denied."
  • Use AccessController directly (bypassing AIMemoryManager._check's fallback) if you need strict deny-by-default semantics.

Common mistakes

  • Assuming an un-granted user is denied access when going through AIMemoryManager — they are not.
  • Calling define_role twice with the same custom name expecting an error — it silently overwrites.

Related pages