RBAC Permission System
Hierarchical permissions with Composite groups, Chain of Responsibility for evaluation cascading, Proxy for resource guarding, and Flyweight for shared permission objects.
Key Abstractions
Flyweight : shared immutable objects like READ, WRITE, DELETE per resource
Composite : contains permissions and sub-groups. 'Admin' includes 'Editor' includes 'Viewer'
Chain of Responsibility : user override → role → group → default policy
Proxy : wraps protected resources, checks authorization before delegating
Decorator : TimeBasedDecorator restricts to business hours, IPBasedDecorator restricts by network. All wrap a PermissionEvaluator.
Domain objects with assigned permission groups
Class Diagram
How It Works
An RBAC system controls who can do what to which resource. Instead of attaching permissions directly to users (which does not scale), you create roles, which are named bundles of permissions, and assign roles to users. The system evaluates access by walking from the user through their roles to the underlying permission groups.
The core flow: a user tries to access a resource. The ResourceProxy intercepts the call and hands the user and requested permission to the evaluation chain. The chain checks user-level overrides first (for cases like temporary elevated access), then walks through the user's roles checking each role's PermissionGroup, and finally falls back to a default deny policy. If any link in the chain grants permission, access is allowed. If no link grants, the default policy applies.
The Composite pattern is what makes permission inheritance work. A PermissionGroup can contain both direct permissions and child groups. The Admin group does not duplicate all of Editor's and Viewer's permissions. It contains Editor as a child, and Editor contains Viewer as a child. When you call hasPermission(), it recursively checks the entire tree. Add a new permission to Viewer and every group that inherits from Viewer automatically gains it.
The Flyweight pattern prevents memory waste. In a system with thousands of users and hundreds of resources, the permission READ:document could be referenced millions of times. Instead of creating a new Permission object every time, Permission.of("document", "READ") returns the same cached instance. Identity comparison works because there is only ever one object per resource-action pair.
Requirements
Functional
- Define permissions as resource-action pairs (e.g., document:READ, user:MANAGE)
- Group permissions into hierarchical groups where child group permissions are inherited
- Assign permission groups to named roles, and roles to users
- Support user-level permission overrides that bypass role-based evaluation
- Evaluate access requests through a cascading chain: user overrides, then role permissions, then default policy
- Guard resources through a proxy that checks authorization transparently
Non-Functional
- Permission objects must be shared (Flyweight) to avoid memory duplication across thousands of users
- Evaluation must be fast: no database calls in the hot path, tree traversal only
- Constraints (time, IP) must be composable without modifying the core evaluation logic
- Default policy must be deny. Failing open in an authorization system is a security vulnerability.
Design Decisions
What's wrong with plain string permission checks?
String-based permission checks like if role == "admin" are brittle. Typos compile fine and fail silently. There is no way to enumerate all permissions in the system. You cannot attach metadata to a string.
Flyweight Permission objects give you type safety, identity comparison, and a central registry of all permissions that exist. Permission.of("document", "READ") always returns the same object, so you can use is (Python) or == (Java reference equality) for fast comparison. The cache also means thousands of users referencing READ:document share one object instead of thousands of identical strings scattered across the heap.
Couldn't we just map each role to a flat list of permissions?
A flat map means the Admin role has to list every permission explicitly: READ, WRITE, DELETE, COMMENT, LIST, MANAGE. When you add a new permission to the Viewer level, you have to remember to add it to Editor and Admin too. Miss one and you have a subtle access bug.
The Composite pattern models the actual inheritance hierarchy. Admin contains Editor contains Viewer. Add LIST to Viewer and it automatically propagates up. The hasPermission() method is a simple tree walk. The allPermissions() method gives you the complete flattened set for debugging and audit. The tree structure matches how organizations actually think about role hierarchies.
Does a single evaluate() method work here?
A single method that checks overrides, then roles, then defaults in one function works until you need to change the evaluation order. Maybe some deployments want to skip user overrides entirely. Maybe you need to add a "team-level permissions" check between user overrides and role evaluation.
The chain pattern lets you compose evaluation order at construction time. Each evaluator either handles the request or passes it to the next link. Adding a new evaluation layer means writing a new class and inserting it at the right position in the chain. The existing evaluators do not change. The same pattern shows up in servlet filters, middleware stacks, and approval workflows.
What if we added time and IP parameters directly to evaluators?
Adding a time_window parameter to RoleEvaluator and an allowed_ips parameter to UserOverrideEvaluator creates a combinatorial explosion. Every evaluator now knows about every constraint. Adding a geographic constraint means modifying every evaluator class.
Decorators wrap the entire evaluation chain and add one constraint each. TimeBasedDecorator checks the clock and either denies or delegates. IPBasedDecorator checks the network and either denies or delegates. You can stack them: IP check wrapping time check wrapping the core chain. Each decorator is independent and reusable. A new GeoBasedDecorator requires zero changes to existing code.
Interview Follow-ups
- "How would you implement ABAC (Attribute-Based Access Control) on top of this?" Add an AttributeEvaluator to the chain that evaluates policies based on user attributes (department, clearance level), resource attributes (classification, owner), and environment attributes (time, location). The chain becomes: user override → attribute policy → role → default. ABAC policies are more expressive but harder to audit. Most production systems use RBAC as the base with ABAC for fine-grained exceptions.
- "How would you cache permission evaluation results?" Add a cache keyed by (user_id, permission) with a short TTL. Invalidate on role assignment changes, permission group modifications, or user override updates. The cache sits as a decorator wrapping the chain : check cache first, on miss evaluate the chain and store the result. Be careful with time-based decorators: cached results may go stale when the time window boundary crosses.
- "How would you add audit logging for access decisions?" Wrap the evaluation chain in an AuditDecorator that logs every access decision : who, what resource, what action, allow/deny, and which link in the chain made the decision. This creates a complete access trail for compliance. The decorator pattern means adding auditing requires zero changes to the evaluators themselves.
- "How would you handle multi-tenant permissions?" Add a tenant dimension to Permission (tenant_id, resource, action) and partition the PermissionGroup tree per tenant. The evaluator chain first resolves the tenant from the request context, then evaluates against that tenant's permission tree. Cross-tenant access requires an explicit cross-tenant permission, never implicit inheritance. The Flyweight cache becomes per-tenant to prevent permission leakage between tenants.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from datetime import datetime
5
6
7 # --------------- Flyweight: Permission ---------------
8
9 class Permission:
10 """Flyweight - cached immutable permission objects."""
11 _cache: dict[tuple[str, str], "Permission"] = {}
12
13 def __init__(self, resource: str, action: str):
14 self.resource = resource
15 self.action = action
16
17 @classmethod
18 def of(cls, resource: str, action: str) -> "Permission":
19 key = (resource, action)
20 if key not in cls._cache:
21 cls._cache[key] = cls(resource, action)
22 return cls._cache[key]
23
24 def __repr__(self) -> str:
25 return f"{self.action}:{self.resource}"
26
27 def __eq__(self, other: object) -> bool:
28 if not isinstance(other, Permission):
29 return False
30 return self.resource == other.resource and self.action == other.action
31
32 def __hash__(self) -> int:
33 return hash((self.resource, self.action))
34
35
36 # --------------- Composite: PermissionGroup ---------------
37
38 class PermissionGroup:
39 """Composite - groups contain direct permissions and child groups."""
40
41 def __init__(self, name: str):
42 self.name = name
43 self._permissions: set[Permission] = set()
44 self._children: list["PermissionGroup"] = []
45
46 def add_permission(self, perm: Permission) -> None:
47 self._permissions.add(perm)
48
49 def add_child_group(self, group: "PermissionGroup") -> None:
50 self._children.append(group)
51
52 def has_permission(self, perm: Permission) -> bool:
53 if perm in self._permissions:
54 return True
55 return any(child.has_permission(perm) for child in self._children)
56
57 def all_permissions(self) -> set[Permission]:
58 result = set(self._permissions)
59 for child in self._children:
60 result |= child.all_permissions()
61 return result
62
63 def __repr__(self) -> str:
64 return f"Group({self.name})"
65
66
67 # --------------- Domain: Role and User ---------------
68
69 @dataclass
70 class Role:
71 name: str
72 group: PermissionGroup
73
74 @dataclass
75 class User:
76 name: str
77 roles: list[Role] = field(default_factory=list)
78 overrides: set[Permission] = field(default_factory=set)
79 ip_address: str = "10.0.0.1"
80
81
82 # --------------- Chain of Responsibility: Evaluators ---------------
83
84 class PermissionEvaluator(ABC):
85 @abstractmethod
86 def has_permission(self, user: User, perm: Permission) -> bool | None:
87 ...
88
89
90 class UserOverrideEvaluator(PermissionEvaluator):
91 """First in chain - checks user-level overrides."""
92
93 def __init__(self, next_eval: PermissionEvaluator):
94 self._next = next_eval
95
96 def has_permission(self, user: User, perm: Permission) -> bool | None:
97 if perm in user.overrides:
98 return True
99 return self._next.has_permission(user, perm)
100
101
102 class RoleEvaluator(PermissionEvaluator):
103 """Second in chain - checks all roles assigned to the user."""
104
105 def __init__(self, next_eval: PermissionEvaluator):
106 self._next = next_eval
107
108 def has_permission(self, user: User, perm: Permission) -> bool | None:
109 for role in user.roles:
110 if role.group.has_permission(perm):
111 return True
112 return self._next.has_permission(user, perm)
113
114
115 class DefaultPolicyEvaluator(PermissionEvaluator):
116 """Terminal in chain - returns the default policy."""
117
118 def __init__(self, default_allow: bool = False):
119 self._default = default_allow
120
121 def has_permission(self, user: User, perm: Permission) -> bool | None:
122 return self._default
123
124
125 # --------------- Decorator: Constraint Wrappers ---------------
126
127 class PermissionCheckDecorator(PermissionEvaluator):
128 """Base decorator wrapping another evaluator."""
129
130 def __init__(self, wrapped: PermissionEvaluator):
131 self._wrapped = wrapped
132
133 def has_permission(self, user: User, perm: Permission) -> bool | None:
134 return self._wrapped.has_permission(user, perm)
135
136
137 class TimeBasedDecorator(PermissionCheckDecorator):
138 """Restricts access to a time window (e.g., business hours)."""
139
140 def __init__(self, wrapped: PermissionEvaluator,
141 start_hour: int = 9, end_hour: int = 17):
142 super().__init__(wrapped)
143 self._start = start_hour
144 self._end = end_hour
145
146 def has_permission(self, user: User, perm: Permission) -> bool | None:
147 current_hour = datetime.now().hour
148 if not (self._start <= current_hour < self._end):
149 return False
150 return super().has_permission(user, perm)
151
152
153 class IPBasedDecorator(PermissionCheckDecorator):
154 """Restricts access to specific network prefixes."""
155
156 def __init__(self, wrapped: PermissionEvaluator,
157 allowed_networks: set[str]):
158 super().__init__(wrapped)
159 self._allowed = allowed_networks
160
161 def has_permission(self, user: User, perm: Permission) -> bool | None:
162 if not any(user.ip_address.startswith(net)
163 for net in self._allowed):
164 return False
165 return super().has_permission(user, perm)
166
167
168 # --------------- Proxy: Resource Guard ---------------
169
170 class Resource:
171 """A protected resource with actual business logic."""
172
173 def __init__(self, name: str, data: str):
174 self.name = name
175 self.data = data
176
177 def read(self) -> str:
178 return f"[{self.name}] {self.data}"
179
180 def write(self, new_data: str) -> str:
181 self.data = new_data
182 return f"[{self.name}] updated to: {new_data}"
183
184
185 class ResourceProxy:
186 """Proxy - checks authorization before delegating to the real resource."""
187
188 def __init__(self, resource: Resource,
189 evaluator: PermissionEvaluator):
190 self._resource = resource
191 self._evaluator = evaluator
192
193 def access(self, user: User, perm: Permission) -> str:
194 if self._evaluator.has_permission(user, perm):
195 if perm.action == "READ":
196 return self._resource.read()
197 elif perm.action == "WRITE":
198 return self._resource.write(f"edited by {user.name}")
199 return f"Action {perm.action} performed on {self._resource.name}"
200 return f"ACCESS DENIED: {user.name} lacks {perm} on {self._resource.name}"
201
202
203 # --------------- Demo ---------------
204
205 if __name__ == "__main__":
206 # 1. Flyweight - same object everywhere
207 print("=== Flyweight: Permission Identity ===")
208 p1 = Permission.of("document", "READ")
209 p2 = Permission.of("document", "READ")
210 print(f" Permission.of('document','READ') is same object: {p1 is p2}")
211 print(f" Cache size: {len(Permission._cache)}")
212
213 # 2. Composite - build permission hierarchy
214 print("\n=== Composite: Permission Groups ===")
215 viewer = PermissionGroup("Viewer")
216 viewer.add_permission(Permission.of("document", "READ"))
217 viewer.add_permission(Permission.of("document", "LIST"))
218
219 editor = PermissionGroup("Editor")
220 editor.add_permission(Permission.of("document", "WRITE"))
221 editor.add_permission(Permission.of("document", "COMMENT"))
222 editor.add_child_group(viewer) # Editor inherits Viewer
223
224 admin = PermissionGroup("Admin")
225 admin.add_permission(Permission.of("document", "DELETE"))
226 admin.add_permission(Permission.of("user", "MANAGE"))
227 admin.add_child_group(editor) # Admin inherits Editor (which inherits Viewer)
228
229 print(f" Viewer permissions: {viewer.all_permissions()}")
230 print(f" Editor permissions: {editor.all_permissions()}")
231 print(f" Admin permissions: {admin.all_permissions()}")
232 print(f" Admin has READ (inherited): {admin.has_permission(Permission.of('document', 'READ'))}")
233
234 # 3. Build roles and users
235 viewer_role = Role("viewer", viewer)
236 editor_role = Role("editor", editor)
237 admin_role = Role("admin", admin)
238
239 alice = User("Alice", roles=[admin_role])
240 bob = User("Bob", roles=[editor_role])
241 carol = User("Carol", roles=[viewer_role])
242 dave = User("Dave", roles=[viewer_role],
243 overrides={Permission.of("document", "DELETE")})
244
245 # 4. Chain of Responsibility - evaluation cascade
246 print("\n=== Chain of Responsibility: Evaluation ===")
247 chain = UserOverrideEvaluator(
248 RoleEvaluator(
249 DefaultPolicyEvaluator(default_allow=False)
250 )
251 )
252
253 checks = [
254 (alice, Permission.of("document", "DELETE"), "Alice DELETE (admin role)"),
255 (bob, Permission.of("document", "WRITE"), "Bob WRITE (editor role)"),
256 (bob, Permission.of("document", "DELETE"), "Bob DELETE (no permission)"),
257 (carol, Permission.of("document", "READ"), "Carol READ (viewer role)"),
258 (carol, Permission.of("document", "WRITE"), "Carol WRITE (no permission)"),
259 (dave, Permission.of("document", "DELETE"), "Dave DELETE (user override)"),
260 ]
261 for user, perm, label in checks:
262 result = chain.has_permission(user, perm)
263 status = "ALLOW" if result else "DENY"
264 print(f" {label}: {status}")
265
266 # 5. Proxy - resource guarding
267 print("\n=== Proxy: Resource Guard ===")
268 doc = Resource("ProjectPlan", "Q4 roadmap details")
269 proxy = ResourceProxy(doc, chain)
270
271 print(f" {proxy.access(alice, Permission.of('document', 'READ'))}")
272 print(f" {proxy.access(bob, Permission.of('document', 'WRITE'))}")
273 print(f" {proxy.access(carol, Permission.of('document', 'WRITE'))}")
274 print(f" {proxy.access(dave, Permission.of('document', 'DELETE'))}")
275
276 # 6. Decorator - time and IP constraints
277 print("\n=== Decorator: Time-Based Constraint ===")
278 time_chain = TimeBasedDecorator(chain, start_hour=0, end_hour=24)
279 print(f" Alice DELETE (within hours): "
280 f"{'ALLOW' if time_chain.has_permission(alice, Permission.of('document', 'DELETE')) else 'DENY'}")
281
282 restricted_chain = TimeBasedDecorator(chain, start_hour=23, end_hour=23)
283 print(f" Alice DELETE (outside hours): "
284 f"{'ALLOW' if restricted_chain.has_permission(alice, Permission.of('document', 'DELETE')) else 'DENY'}")
285
286 print("\n=== Decorator: IP-Based Constraint ===")
287 ip_chain = IPBasedDecorator(chain, allowed_networks={"10.0.", "192.168."})
288 alice_internal = User("Alice", roles=[admin_role], ip_address="10.0.0.5")
289 alice_external = User("Alice", roles=[admin_role], ip_address="85.214.1.1")
290 print(f" Alice from 10.0.0.5 (internal): "
291 f"{'ALLOW' if ip_chain.has_permission(alice_internal, Permission.of('document', 'DELETE')) else 'DENY'}")
292 print(f" Alice from 85.214.1.1 (external): "
293 f"{'ALLOW' if ip_chain.has_permission(alice_external, Permission.of('document', 'DELETE')) else 'DENY'}")
294
295 # 7. Composed decorators - IP + Time
296 print("\n=== Composed Decorators: IP + Time ===")
297 composed = IPBasedDecorator(
298 TimeBasedDecorator(chain, start_hour=0, end_hour=24),
299 allowed_networks={"10.0."}
300 )
301 print(f" Internal + business hours: "
302 f"{'ALLOW' if composed.has_permission(alice_internal, Permission.of('document', 'DELETE')) else 'DENY'}")
303 print(f" External + business hours: "
304 f"{'ALLOW' if composed.has_permission(alice_external, Permission.of('document', 'DELETE')) else 'DENY'}")
305
306 print("\nAll operations completed successfully.")Common Mistakes
- ✗Checking permissions with string comparison ('admin' == role) instead of structured evaluation
- ✗Not handling permission inheritance: you must check parent groups recursively
- ✗Creating new Permission objects per check instead of reusing Flyweights: wastes memory in large systems
- ✗Hardcoding time/IP constraints in the evaluator: Decorator lets you compose constraints flexibly
Key Points
- ✓Flyweight: Permission.READ is the same object everywhere. Thousands of users reference the same Permission instances, not clones.
- ✓Composite: PermissionGroup.hasPermission() recursively checks self and all child groups. 'Admin' → 'Editor' → 'Viewer' inheritance is tree traversal.
- ✓Chain of Responsibility: evaluation order matters. User-level overrides beat role-level which beats group defaults.
- ✓Proxy: ResourceProxy.access() checks permissions transparently. The resource itself has zero security logic.