Skip to content

Commit 3355ad0

Browse files
committed
Introduce a bulk re-computation context manager for RBAC
1 parent daf39a8 commit 3355ad0

File tree

5 files changed

+272
-3
lines changed

5 files changed

+272
-3
lines changed

ansible_base/rbac/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,13 @@
22

33
__all__ = [
44
'permission_registry',
5+
'bulk_rbac_caching',
56
]
7+
8+
9+
def __getattr__(name):
10+
if name == 'bulk_rbac_caching':
11+
from ansible_base.rbac.triggers import bulk_rbac_caching
12+
13+
return bulk_rbac_caching
14+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

ansible_base/rbac/models/role.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ def give_or_remove_permission(self, actor, content_object, giving=True, sync_act
281281

282282
from ansible_base.rbac.triggers import needed_updates_on_assignment, update_after_assignment
283283

284-
update_teams, to_update = needed_updates_on_assignment(self, actor, object_role, created=created, giving=True)
284+
update_teams, to_update = needed_updates_on_assignment(self, actor, object_role, created=created, giving=giving)
285285

286286
assignment = None
287287
if actor._meta.model_name == 'user':

ansible_base/rbac/triggers.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from contextlib import contextmanager
3+
from threading import local
34
from typing import Optional, Union
45
from uuid import UUID
56

@@ -26,6 +27,68 @@
2627
dab_post_migrate = Signal()
2728

2829

30+
# Thread-local storage for bulk caching state
31+
_bulk_cache_state = local()
32+
33+
34+
def _get_bulk_cache_state():
35+
"""Get or initialize the bulk cache state for the current thread"""
36+
if not hasattr(_bulk_cache_state, 'active'):
37+
_bulk_cache_state.active = False
38+
_bulk_cache_state.needs_team_update = False
39+
_bulk_cache_state.object_roles_to_update = set()
40+
return _bulk_cache_state
41+
42+
43+
@contextmanager
44+
def bulk_rbac_caching():
45+
"""
46+
Context manager that defers expensive RBAC cache updates during bulk operations.
47+
48+
Instead of calling update_after_assignment for each permission change,
49+
this collects all the updates and performs them once when exiting the context.
50+
51+
Usage:
52+
with bulk_rbac_caching():
53+
# Multiple permission assignments/removals
54+
role_def.give_permission(user1, obj1)
55+
role_def.give_permission(user2, obj2)
56+
role_def.remove_permission(user3, obj3)
57+
# Cache updates happen here when exiting the context
58+
"""
59+
state = _get_bulk_cache_state()
60+
61+
if state.active:
62+
# Already in a bulk context, just yield (nested calls)
63+
yield
64+
return
65+
66+
# Enter bulk mode
67+
state.active = True
68+
state.needs_team_update = False
69+
state.object_roles_to_update = set()
70+
71+
try:
72+
yield
73+
finally:
74+
# Exit bulk mode and perform deferred updates
75+
needs_team_update = state.needs_team_update
76+
object_roles_to_update = state.object_roles_to_update.copy()
77+
78+
# Reset state
79+
state.active = False
80+
state.needs_team_update = False
81+
state.object_roles_to_update = set()
82+
83+
# Perform the global update
84+
if needs_team_update or object_roles_to_update:
85+
logger.info(f'Performing bulk RBAC cache update: teams={needs_team_update}, object_roles={len(object_roles_to_update)}')
86+
if needs_team_update:
87+
compute_team_member_roles()
88+
if object_roles_to_update:
89+
compute_object_role_permissions(object_roles=object_roles_to_update)
90+
91+
2992
def team_ancestor_roles(team):
3093
"""
3194
Return a queryset of all roles that directly or indirectly grant any form of permission to a team.
@@ -85,6 +148,17 @@ def needed_updates_on_assignment(role_definition, actor, object_role, created=Fa
85148

86149
def update_after_assignment(update_teams, to_update):
87150
"Call this with the output of needed_updates_on_assignment"
151+
state = _get_bulk_cache_state()
152+
153+
# If we're in bulk mode, defer the updates
154+
if state.active:
155+
if update_teams:
156+
state.needs_team_update = True
157+
if to_update:
158+
state.object_roles_to_update.update(to_update)
159+
return
160+
161+
# Normal mode - perform updates immediately
88162
if update_teams:
89163
compute_team_member_roles()
90164

test_app/tests/rbac/test_triggers.py

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from unittest.mock import MagicMock
1+
from unittest.mock import MagicMock, patch
22

33
import pytest
44
from django.apps import apps
55
from django.test.utils import override_settings
66

77
from ansible_base.rbac.models import ObjectRole, RoleEvaluation, RoleTeamAssignment, RoleUserAssignment
88
from ansible_base.rbac.permission_registry import permission_registry
9-
from ansible_base.rbac.triggers import dab_post_migrate, post_migration_rbac_setup
9+
from ansible_base.rbac.triggers import bulk_rbac_caching, dab_post_migrate, post_migration_rbac_setup
1010
from test_app.models import Inventory, Organization
1111

1212

@@ -172,3 +172,177 @@ def test_delete_signals_team_organization(organization, inventory, team, org_inv
172172
assert not RoleEvaluation.objects.filter(**org_gfk).exists()
173173

174174
assert not RoleEvaluation.objects.filter(**inv_gfk).exists()
175+
176+
177+
@pytest.mark.django_db
178+
class TestBulkRBACCaching:
179+
"""Tests for the bulk_rbac_caching context manager"""
180+
181+
def test_bulk_caching_defers_updates(self, rando, inv_rd, inventory):
182+
"""Test that updates are deferred during bulk operations"""
183+
with (
184+
patch('ansible_base.rbac.triggers.compute_team_member_roles') as mock_team_update,
185+
patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update,
186+
):
187+
188+
with bulk_rbac_caching():
189+
# Multiple permission assignments
190+
inv_rd.give_permission(rando, inventory)
191+
# During bulk mode, the expensive cache functions should not be called
192+
mock_team_update.assert_not_called()
193+
mock_obj_update.assert_not_called()
194+
195+
# After exiting context, they should be called once
196+
mock_obj_update.assert_called_once()
197+
198+
def test_bulk_caching_collects_object_roles(self, rando, team, inv_rd, org_inv_rd, inventory, organization):
199+
"""Test that object roles are properly collected and updated in bulk"""
200+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
201+
202+
with bulk_rbac_caching():
203+
# Multiple assignments that affect different object roles
204+
assignment1 = inv_rd.give_permission(rando, inventory)
205+
assignment2 = org_inv_rd.give_permission(team, organization)
206+
207+
# Should not be called during bulk mode
208+
mock_obj_update.assert_not_called()
209+
210+
# Should be called once with collected object roles
211+
mock_obj_update.assert_called_once()
212+
call_args = mock_obj_update.call_args
213+
object_roles = call_args.kwargs['object_roles']
214+
215+
# Should contain both object roles
216+
assert assignment1.object_role in object_roles
217+
assert assignment2.object_role in object_roles
218+
219+
def test_bulk_caching_handles_team_updates(self, rando, team, member_rd):
220+
"""Test that team updates are properly deferred and executed"""
221+
with (
222+
patch('ansible_base.rbac.triggers.compute_team_member_roles') as mock_team_update,
223+
patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update,
224+
):
225+
226+
with bulk_rbac_caching():
227+
# Assignment that affects team membership
228+
member_rd.give_permission(rando, team)
229+
230+
# Should not be called during bulk mode
231+
mock_team_update.assert_not_called()
232+
mock_obj_update.assert_not_called()
233+
234+
# Both should be called after exiting context
235+
mock_team_update.assert_called_once()
236+
mock_obj_update.assert_called_once()
237+
238+
def test_bulk_caching_nested_contexts(self, rando, inv_rd, inventory):
239+
"""Test that nested bulk contexts work correctly"""
240+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
241+
242+
with bulk_rbac_caching():
243+
inv_rd.give_permission(rando, inventory)
244+
245+
# Nested context
246+
with bulk_rbac_caching():
247+
# Another assignment in nested context
248+
# Should still not trigger updates
249+
pass
250+
251+
# Still in outer context, should not be called yet
252+
mock_obj_update.assert_not_called()
253+
254+
# Only called once when exiting outermost context
255+
mock_obj_update.assert_called_once()
256+
257+
def test_bulk_caching_with_multiple_assignments(self, rando, team, inv_rd, org_inv_rd, inventory, organization):
258+
"""Test bulk caching works with multiple assignments that require updates"""
259+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
260+
261+
with bulk_rbac_caching():
262+
# Multiple operations that create new object roles
263+
inv_rd.give_permission(rando, inventory)
264+
org_inv_rd.give_permission(team, organization)
265+
mock_obj_update.assert_not_called()
266+
267+
# Should be called once after all operations
268+
mock_obj_update.assert_called_once()
269+
270+
def test_bulk_caching_with_object_role_deletion(self, rando, inv_rd, inventory):
271+
"""Test bulk caching when object role gets deleted during removal"""
272+
# First give permission normally
273+
inv_rd.give_permission(rando, inventory)
274+
275+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
276+
277+
with bulk_rbac_caching():
278+
# Remove permission in bulk mode - this will delete the object role
279+
inv_rd.remove_permission(rando, inventory)
280+
mock_obj_update.assert_not_called()
281+
282+
# Should not be called since object role was deleted (nothing to update)
283+
mock_obj_update.assert_not_called()
284+
285+
def test_bulk_caching_performance_benefit(self, rando, team, inv_rd, inventory):
286+
"""Test that bulk operations actually reduce cache update calls"""
287+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
288+
289+
# Without bulk caching - each assignment triggers an update
290+
inv_rd.give_permission(rando, inventory)
291+
inv_rd.remove_permission(rando, inventory)
292+
293+
# Should have been called multiple times
294+
assert mock_obj_update.call_count >= 2
295+
296+
mock_obj_update.reset_mock()
297+
298+
# With bulk caching - should only be called once
299+
with bulk_rbac_caching():
300+
inv_rd.give_permission(rando, inventory)
301+
inv_rd.give_permission(team, inventory)
302+
inv_rd.remove_permission(rando, inventory)
303+
304+
# Should only be called once
305+
mock_obj_update.assert_called_once()
306+
307+
def test_bulk_caching_exception_handling(self, rando, inv_rd, inventory):
308+
"""Test that bulk caching state is reset even if exception occurs"""
309+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
310+
311+
try:
312+
with bulk_rbac_caching():
313+
inv_rd.give_permission(rando, inventory)
314+
raise ValueError("Test exception")
315+
except ValueError:
316+
pass
317+
318+
# Should still be called even though exception occurred
319+
mock_obj_update.assert_called_once()
320+
321+
mock_obj_update.reset_mock()
322+
323+
# State should be reset, next operation should work normally
324+
inv_rd.give_permission(rando, inventory)
325+
mock_obj_update.assert_called()
326+
327+
def test_no_bulk_caching_without_context(self, rando, inv_rd, inventory):
328+
"""Test that normal operations still work when not in bulk context"""
329+
with patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update:
330+
331+
# Normal assignment should trigger immediate update
332+
inv_rd.give_permission(rando, inventory)
333+
mock_obj_update.assert_called()
334+
335+
def test_bulk_caching_empty_context(self):
336+
"""Test that empty bulk context doesn't call cache functions"""
337+
with (
338+
patch('ansible_base.rbac.triggers.compute_team_member_roles') as mock_team_update,
339+
patch('ansible_base.rbac.triggers.compute_object_role_permissions') as mock_obj_update,
340+
):
341+
342+
with bulk_rbac_caching():
343+
# No operations performed
344+
pass
345+
346+
# Should not call expensive functions if no changes were made
347+
mock_team_update.assert_not_called()
348+
mock_obj_update.assert_not_called()

test_app/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ def api_root(request, format=None):
178178
list_endpoints['role-metadata'] = get_fully_qualified_url('role-metadata')
179179
list_endpoints['timeout-view'] = get_fully_qualified_url('test-timeout-view')
180180

181+
from uuid import uuid4
182+
183+
from ansible_base.rbac.models import DABContentType, RoleDefinition
184+
from test_app.models import Organization, User
185+
186+
DABContentType.objects.warm_cache()
187+
188+
rd = RoleDefinition.objects.get(name='Organization Admin')
189+
user = User.objects.create(username=str(uuid4()))
190+
org, _ = Organization.objects.get_or_create(name='Default')
191+
rd.give_permission(user, org)
192+
181193
return Response(list_endpoints)
182194

183195

0 commit comments

Comments
 (0)