mirror of
https://github.com/ansible/awx.git
synced 2026-10-31 14:36:17 -02:30
AAP-82996 Unify activity stream format for old and new role assignment APIs (#16616)
Unify activity stream format for role assignments across old and new APIs The old API (/api/v2/users/<pk>/roles/) and new API (/api/v2/role_user_assignments/) were producing different activity stream entries for the same logical action. The old API created entries via the Role.members m2m_changed signal in the legacy format, while the new API used the new-format handler added in AAP-82996. This change makes the old API produce the same new-format entry by: - Skipping the legacy activity_stream_associate for Role_members changes - Letting the mirrored RoleUserAssignment creation fire the new-format record_role_assignment_activity_stream handler instead Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
efed57ce8a
commit
882d2df7f3
@@ -667,8 +667,6 @@ def give_creator_permissions(user, obj):
|
|||||||
|
|
||||||
|
|
||||||
def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
||||||
from awx.main.signals import disable_activity_stream
|
|
||||||
|
|
||||||
if action.startswith('pre_'):
|
if action.startswith('pre_'):
|
||||||
return
|
return
|
||||||
if not rbac_sync_enabled.enabled:
|
if not rbac_sync_enabled.enabled:
|
||||||
@@ -692,10 +690,6 @@ def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
|
|||||||
else:
|
else:
|
||||||
user = get_user_model().objects.get(pk=user_or_role_id)
|
user = get_user_model().objects.get(pk=user_or_role_id)
|
||||||
rd = get_role_definition(role)
|
rd = get_role_definition(role)
|
||||||
# This mirrors an already-recorded legacy Role.members change into the new RBAC
|
|
||||||
# system. Suppress activity stream here so the mirrored RoleUserAssignment/
|
|
||||||
# RoleTeamAssignment write isn't recorded a second time (see record_role_assignment_activity_stream).
|
|
||||||
with disable_activity_stream():
|
|
||||||
assignment = give_or_remove_permission(role, user, giving=is_giving, rd=rd)
|
assignment = give_or_remove_permission(role, user, giving=is_giving, rd=rd)
|
||||||
|
|
||||||
# sync to resource server
|
# sync to resource server
|
||||||
@@ -707,8 +701,6 @@ def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
|
|||||||
|
|
||||||
|
|
||||||
def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
||||||
from awx.main.signals import disable_activity_stream
|
|
||||||
|
|
||||||
if action.startswith('pre_'):
|
if action.startswith('pre_'):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -749,9 +741,6 @@ def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
|
|||||||
|
|
||||||
team = Team.objects.get(pk=parent_role.object_id)
|
team = Team.objects.get(pk=parent_role.object_id)
|
||||||
rd = get_role_definition(child_role)
|
rd = get_role_definition(child_role)
|
||||||
# See comment in sync_members_to_new_rbac: suppress activity stream for this
|
|
||||||
# mirrored write to avoid double-recording an already-recorded legacy change.
|
|
||||||
with disable_activity_stream():
|
|
||||||
assignment = give_or_remove_permission(child_role, team, giving=is_giving, rd=rd)
|
assignment = give_or_remove_permission(child_role, team, giving=is_giving, rd=rd)
|
||||||
|
|
||||||
# sync to resource server
|
# sync to resource server
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ from awx.main.models import (
|
|||||||
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps
|
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps
|
||||||
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
|
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
|
||||||
from awx.main.tasks.system import update_inventory_computed_fields, handle_removed_image
|
from awx.main.tasks.system import update_inventory_computed_fields, handle_removed_image
|
||||||
from awx.main.fields import is_implicit_parent
|
|
||||||
|
|
||||||
from awx.main import consumers
|
from awx.main import consumers
|
||||||
|
|
||||||
from awx.conf.utils import conf_to_dict
|
from awx.conf.utils import conf_to_dict
|
||||||
@@ -155,34 +153,6 @@ def sync_rbac_to_superuser_status(instance, sender, **kwargs):
|
|||||||
user.save(update_fields=['is_superuser'])
|
user.save(update_fields=['is_superuser'])
|
||||||
|
|
||||||
|
|
||||||
def rbac_activity_stream(instance, sender, **kwargs):
|
|
||||||
# Only if we are associating/disassociating
|
|
||||||
if kwargs['action'] in ['pre_add', 'pre_remove']:
|
|
||||||
if hasattr(instance, 'content_type'): # Duck typing, migration-independent isinstance(instance, Role)
|
|
||||||
if instance.content_type_id is None and instance.singleton_name == ROLE_SINGLETON_SYSTEM_ADMINISTRATOR:
|
|
||||||
# Skip entries for the system admin role because user serializer covers it
|
|
||||||
# System auditor role is shown in the serializer, but its relationship is
|
|
||||||
# managed separately, its value is incorrect, and a correction entry is needed
|
|
||||||
return
|
|
||||||
# This juggles which role to use, because could be A->B or B->A association
|
|
||||||
if sender.__name__ == 'Role_parents':
|
|
||||||
role = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']).first()
|
|
||||||
# don't record implicit creation / parents in activity stream
|
|
||||||
if role is not None and is_implicit_parent(parent_role=role, child_role=instance):
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
role = instance
|
|
||||||
# If a singleton role is the instance, the singleton role is acted on
|
|
||||||
# otherwise the related object is considered to be acted on
|
|
||||||
if instance.content_object:
|
|
||||||
instance = instance.content_object
|
|
||||||
else:
|
|
||||||
# Association with actor, like role->user
|
|
||||||
role = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']).first()
|
|
||||||
|
|
||||||
activity_stream_associate(sender, instance, role=role, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_role_assignment_activity_stream(instance, operation):
|
def _record_role_assignment_activity_stream(instance, operation):
|
||||||
if not activity_stream_enabled:
|
if not activity_stream_enabled:
|
||||||
return
|
return
|
||||||
@@ -284,8 +254,6 @@ def connect_computed_field_signals():
|
|||||||
connect_computed_field_signals()
|
connect_computed_field_signals()
|
||||||
|
|
||||||
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
||||||
m2m_changed.connect(rbac_activity_stream, Role.members.through)
|
|
||||||
m2m_changed.connect(rbac_activity_stream, Role.parents.through)
|
|
||||||
post_save.connect(sync_superuser_status_to_rbac, sender=User)
|
post_save.connect(sync_superuser_status_to_rbac, sender=User)
|
||||||
m2m_changed.connect(sync_rbac_to_superuser_status, Role.members.through)
|
m2m_changed.connect(sync_rbac_to_superuser_status, Role.members.through)
|
||||||
pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJob)
|
pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJob)
|
||||||
@@ -572,17 +540,11 @@ def activity_stream_associate(sender, instance, **kwargs):
|
|||||||
getattr(activity_entry, object1).add(obj1.pk)
|
getattr(activity_entry, object1).add(obj1.pk)
|
||||||
getattr(activity_entry, object2).add(obj2_actual.pk)
|
getattr(activity_entry, object2).add(obj2_actual.pk)
|
||||||
|
|
||||||
# Record the role for RBAC changes
|
|
||||||
if 'role' in kwargs:
|
if 'role' in kwargs:
|
||||||
role = kwargs['role']
|
role = kwargs['role']
|
||||||
if role.content_object is not None:
|
if role.content_object is not None:
|
||||||
obj_rel = '.'.join([role.content_object.__module__, role.content_object.__class__.__name__, role.role_field])
|
obj_rel = '.'.join([role.content_object.__module__, role.content_object.__class__.__name__, role.role_field])
|
||||||
|
|
||||||
# If the m2m is from the User side we need to
|
|
||||||
# set the content_object of the Role for our entry.
|
|
||||||
if type(instance) == User and role.content_object is not None:
|
|
||||||
getattr(activity_entry, role.content_type.name.replace(' ', '_')).add(role.content_object)
|
|
||||||
|
|
||||||
activity_entry.role.add(role)
|
activity_entry.role.add(role)
|
||||||
activity_entry.object_relationship_type = obj_rel
|
activity_entry.object_relationship_type = obj_rel
|
||||||
activity_entry.save()
|
activity_entry.save()
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ def test_rbac_stream_resource_roles(activity_stream_entry, organization, org_adm
|
|||||||
settings.ACTIVITY_STREAM_ENABLED = True
|
settings.ACTIVITY_STREAM_ENABLED = True
|
||||||
assert activity_stream_entry.user.first() == org_admin
|
assert activity_stream_entry.user.first() == org_admin
|
||||||
assert activity_stream_entry.organization.first() == organization
|
assert activity_stream_entry.organization.first() == organization
|
||||||
assert activity_stream_entry.role.first() == organization.admin_role
|
assert activity_stream_entry.object1 == 'organization'
|
||||||
assert activity_stream_entry.object_relationship_type == 'awx.main.models.organization.Organization.admin_role'
|
assert activity_stream_entry.object2 == 'user'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -73,8 +73,8 @@ def test_rbac_stream_user_roles(activity_stream_entry, organization, org_admin,
|
|||||||
settings.ACTIVITY_STREAM_ENABLED = True
|
settings.ACTIVITY_STREAM_ENABLED = True
|
||||||
assert activity_stream_entry.user.first() == org_admin
|
assert activity_stream_entry.user.first() == org_admin
|
||||||
assert activity_stream_entry.organization.first() == organization
|
assert activity_stream_entry.organization.first() == organization
|
||||||
assert activity_stream_entry.role.first() == organization.admin_role
|
assert activity_stream_entry.object1 == 'organization'
|
||||||
assert activity_stream_entry.object_relationship_type == 'awx.main.models.organization.Organization.admin_role'
|
assert activity_stream_entry.object2 == 'user'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -169,11 +169,9 @@ def test_stream_user_direct_role_updates(get, post, organization_factory):
|
|||||||
url = reverse('api:user_roles_list', kwargs={'pk': objects.users.test.pk})
|
url = reverse('api:user_roles_list', kwargs={'pk': objects.users.test.pk})
|
||||||
post(url, dict(id=objects.inventories.inv1.read_role.pk), objects.superusers.admin)
|
post(url, dict(id=objects.inventories.inv1.read_role.pk), objects.superusers.admin)
|
||||||
|
|
||||||
activity_stream = ActivityStream.objects.filter(
|
activity_stream = ActivityStream.objects.filter(inventory__pk=objects.inventories.inv1.pk, user__pk=objects.users.test.pk, operation='associate').first()
|
||||||
inventory__pk=objects.inventories.inv1.pk, user__pk=objects.users.test.pk, role__pk=objects.inventories.inv1.read_role.pk
|
|
||||||
).first()
|
|
||||||
url = reverse('api:activity_stream_detail', kwargs={'pk': activity_stream.pk})
|
url = reverse('api:activity_stream_detail', kwargs={'pk': activity_stream.pk})
|
||||||
response = get(url, objects.users.test)
|
response = get(url, objects.users.test)
|
||||||
|
|
||||||
assert response.data['object1'] == 'user'
|
assert response.data['object1'] == 'inventory'
|
||||||
assert response.data['object2'] == 'inventory'
|
assert response.data['object2'] == 'user'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest import mock
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# AWX models
|
# AWX models
|
||||||
from awx.main.models import ActivityStream, Organization, JobTemplate, Credential, CredentialType, Inventory, InventorySource, Project, User
|
from awx.main.models import ActivityStream, Organization, JobTemplate, Credential, CredentialType, Inventory, InventorySource, User
|
||||||
|
|
||||||
# other AWX
|
# other AWX
|
||||||
from awx.main.utils import model_to_dict, model_instance_diff
|
from awx.main.utils import model_to_dict, model_instance_diff
|
||||||
@@ -22,8 +22,6 @@ class TestImplicitRolesOmitted:
|
|||||||
"""
|
"""
|
||||||
Test that there is exactly 1 "create" entry in the activity stream for
|
Test that there is exactly 1 "create" entry in the activity stream for
|
||||||
common items in the system.
|
common items in the system.
|
||||||
These tests will fail if `rbac_activity_stream` creates
|
|
||||||
false-positive entries.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -68,31 +66,8 @@ class TestImplicitRolesOmitted:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestRolesAssociationEntries:
|
|
||||||
"""
|
|
||||||
Test that non-implicit role associations have a corresponding
|
|
||||||
activity stream entry.
|
|
||||||
These tests will fail if `rbac_activity_stream` skipping logic
|
|
||||||
in signals is wrong.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_non_implicit_associations_are_recorded(self, project):
|
|
||||||
org2 = Organization.objects.create(name='test-organization2')
|
|
||||||
# check that duplicate adds do not get recorded in 2nd loop
|
|
||||||
for i in range(2):
|
|
||||||
# Not supported, should not be possible via API
|
|
||||||
# org2.admin_role.children.add(project.admin_role)
|
|
||||||
project.admin_role.parents.add(org2.admin_role)
|
|
||||||
assert ActivityStream.objects.filter(role=org2.admin_role, organization=org2, project=project).count() == 1, 'In loop %s' % i
|
|
||||||
|
|
||||||
def test_model_associations_are_recorded(self, organization):
|
|
||||||
proj1 = Project.objects.create(name='proj1', organization=organization)
|
|
||||||
proj2 = Project.objects.create(name='proj2', organization=organization)
|
|
||||||
proj2.use_role.parents.add(proj1.admin_role)
|
|
||||||
assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('value', [True, False])
|
@pytest.mark.parametrize('value', [True, False])
|
||||||
def test_auditor_is_recorded(self, post, value):
|
def test_auditor_is_recorded(post, value):
|
||||||
u = User.objects.create(username='foouser')
|
u = User.objects.create(username='foouser')
|
||||||
assert not u.is_system_auditor
|
assert not u.is_system_auditor
|
||||||
u.is_system_auditor = value
|
u.is_system_auditor = value
|
||||||
@@ -112,7 +87,9 @@ class TestRolesAssociationEntries:
|
|||||||
assert json.loads(entry.changes) == {'is_system_auditor': [False, True]}
|
assert json.loads(entry.changes) == {'is_system_auditor': [False, True]}
|
||||||
assert entry.object1 == 'user'
|
assert entry.object1 == 'user'
|
||||||
|
|
||||||
def test_user_no_op_api(self, system_auditor):
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_user_no_op_api(system_auditor):
|
||||||
as_ct = ActivityStream.objects.count()
|
as_ct = ActivityStream.objects.count()
|
||||||
system_auditor.is_system_auditor = True # already auditor
|
system_auditor.is_system_auditor = True # already auditor
|
||||||
assert ActivityStream.objects.count() == as_ct
|
assert ActivityStream.objects.count() == as_ct
|
||||||
|
|||||||
Reference in New Issue
Block a user