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
class Permission(Enum):
READ = "read"
WRITE = "write"
DELETE = "delete"
ADMIN = "admin"
Built-in roles
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
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
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:
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) — thePermissionErroris 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,AIMemoryManagerwill permit that user full read/write access to that tenant's memory by default.AccessControlleritself is deny-by-default in isolation, butAIMemoryManager._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 onAccessController/Permissionfor real multi-tenant isolation, you must callgrant()for every user you want restricted — an ungranted user is not denied, they're wide open. See Known Issues.
Examples
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
AIMemoryManagerand need real multi-tenant enforcement, callmgr.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
AccessControllerdirectly (bypassingAIMemoryManager._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_roletwice with the same custom name expecting an error — it silently overwrites.
Related APIs
AIMemoryManager— the primary consumer, with the fail-open wrapper described above.SensitiveDataMasker,MemoryEncryptor— the other two security primitives.