Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ansible_base/rbac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@

__all__ = [
'permission_registry',
'bulk_rbac_caching',
]


def __getattr__(name):
if name == 'bulk_rbac_caching':
from ansible_base.rbac.triggers import bulk_rbac_caching

return bulk_rbac_caching
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
2 changes: 1 addition & 1 deletion ansible_base/rbac/models/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ def give_or_remove_permission(self, actor, content_object, giving=True, sync_act

from ansible_base.rbac.triggers import needed_updates_on_assignment, update_after_assignment

update_teams, to_update = needed_updates_on_assignment(self, actor, object_role, created=created, giving=True)
update_teams, to_update = needed_updates_on_assignment(self, actor, object_role, created=created, giving=giving)

assignment = None
if actor._meta.model_name == 'user':
Expand Down
104 changes: 104 additions & 0 deletions ansible_base/rbac/triggers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from contextlib import contextmanager
from threading import local
from typing import Optional, Union
from uuid import UUID

Expand All @@ -26,6 +27,93 @@
dab_post_migrate = Signal()


# Thread-local storage for bulk caching state
_bulk_cache_state = local()


def _get_bulk_cache_state():
"""Get or initialize the bulk cache state for the current thread"""
if not hasattr(_bulk_cache_state, 'active'):
_bulk_cache_state.active = False
_bulk_cache_state.needs_team_update = False
_bulk_cache_state.object_roles_to_update = set()
_bulk_cache_state.memory_safe = False
_bulk_cache_state.needs_object_role_update = False
return _bulk_cache_state


@contextmanager
def bulk_rbac_caching(memory_safe=False):
"""
Context manager that defers expensive RBAC cache updates during bulk operations.

Instead of calling update_after_assignment for each permission change,
this collects all the updates and performs them once when exiting the context.

Args:
memory_safe (bool): If True, doesn't store individual object roles to save memory.
Instead performs a full re-computation of all object roles
when exiting the context, similar to post_migrate behavior.

Usage:
with bulk_rbac_caching():
# Multiple permission assignments/removals
role_def.give_permission(user1, obj1)
role_def.give_permission(user2, obj2)
role_def.remove_permission(user3, obj3)
# Cache updates happen here when exiting the context

with bulk_rbac_caching(memory_safe=True):
# For very large bulk operations where memory usage is a concern
# This will recompute all object roles instead of tracking specific ones
# ... many operations ...
"""
state = _get_bulk_cache_state()

if state.active:
# Already in a bulk context, just yield (nested calls)
yield
return

# Enter bulk mode
state.active = True
state.needs_team_update = False
state.object_roles_to_update = set()
state.memory_safe = memory_safe
state.needs_object_role_update = False

try:
yield
finally:
# Exit bulk mode and perform deferred updates
needs_team_update = state.needs_team_update
object_roles_to_update = state.object_roles_to_update.copy()
is_memory_safe = state.memory_safe
needs_object_role_update = state.needs_object_role_update

# Reset state
state.active = False
state.needs_team_update = False
state.object_roles_to_update = set()
state.memory_safe = False
state.needs_object_role_update = False

# Perform the global update
if needs_team_update or object_roles_to_update or (is_memory_safe and needs_object_role_update):
if is_memory_safe and needs_object_role_update:
logger.info('Performing bulk RBAC cache update: memory_safe=True, recomputing all object roles')
if needs_team_update:
compute_team_member_roles()
# In memory-safe mode, always recompute all object roles
compute_object_role_permissions()
elif not is_memory_safe:
logger.info(f'Performing bulk RBAC cache update: teams={needs_team_update}, object_roles={len(object_roles_to_update)}')
if needs_team_update:
compute_team_member_roles()
if object_roles_to_update:
compute_object_role_permissions(object_roles=object_roles_to_update)


def team_ancestor_roles(team):
"""
Return a queryset of all roles that directly or indirectly grant any form of permission to a team.
Expand Down Expand Up @@ -85,6 +173,22 @@ def needed_updates_on_assignment(role_definition, actor, object_role, created=Fa

def update_after_assignment(update_teams, to_update):
"Call this with the output of needed_updates_on_assignment"
state = _get_bulk_cache_state()

# If we're in bulk mode, defer the updates
if state.active:
if update_teams:
state.needs_team_update = True
if to_update:
if state.memory_safe:
# In memory-safe mode, don't store individual object roles to save memory,
# but track that we need to do a full recomputation
state.needs_object_role_update = True
else:
state.object_roles_to_update.update(to_update)
return

# Normal mode - perform updates immediately
if update_teams:
compute_team_member_roles()

Expand Down
Loading
Loading