Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
108 changes: 108 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,97 @@
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()
# When team memberships change, always recompute object role permissions
# to ensure the permission cache reflects new team relationships
compute_object_role_permissions()
elif object_roles_to_update:
# Only object roles changed, no team updates needed
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 +177,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