Skip to content

Commit 3ee0c76

Browse files
committed
AAP-52229 Add AuditableModel inheritance to RBAC assignment models
1 parent 964c531 commit 3ee0c76

File tree

4 files changed

+210
-3
lines changed

4 files changed

+210
-3
lines changed

ansible_base/activitystream/models/entry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ class Meta:
6363
)
6464

6565
def __str__(self):
66+
# Enhanced display for RBAC role assignments
67+
if self.content_type and self.content_type.model.lower() in ['roleuserassignment', 'roleteamassignment']:
68+
operation_text = self.get_operation_display()
69+
created_by_text = str(self.created_by) if self.created_by else "Unknown"
70+
return f'[{self.created}] Role assignment {operation_text.lower()} by {created_by_text}'
71+
72+
# Standard format for other entry types
6673
return f'[{self.created}] {self.get_operation_display()} by {self.created_by}: {self.content_type} {self.object_id}'
6774

6875
@functools.cached_property
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
Dummy models for optional django-ansible-base apps.
3+
4+
These provide no-op implementations when optional apps are not installed,
5+
preventing import crashes while maintaining interface compatibility.
6+
"""
7+
8+
from django.db import models
9+
10+
11+
class DummyAuditableModel(models.Model):
12+
"""
13+
Dummy AuditableModel for services without activitystream app.
14+
15+
Provides the same interface as the real AuditableModel but with no
16+
activity logging functionality. This prevents import crashes in services
17+
like AWX/EDA that don't include 'ansible_base.activitystream' in INSTALLED_APPS.
18+
"""
19+
20+
activity_stream_excluded_field_names = []
21+
activity_stream_limit_field_names = []
22+
23+
@property
24+
def activity_stream_entries(self):
25+
"""Return empty queryset for dummy model."""
26+
27+
# Can't import Entry directly - would crash AWX/EDA
28+
# Return a minimal QuerySet-like object that supports count() and last()
29+
class EmptyActivityStream:
30+
def count(self):
31+
return 0
32+
33+
def last(self):
34+
return None
35+
36+
def all(self):
37+
return self
38+
39+
def order_by(self, *args):
40+
return self
41+
42+
def __iter__(self):
43+
return iter([])
44+
45+
return EmptyActivityStream()
46+
47+
def extra_related_fields(self, request):
48+
"""Return empty dict for dummy model."""
49+
return {}
50+
51+
class Meta:
52+
abstract = True

ansible_base/rbac/models/role.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from typing import Optional, Type, Union
44
from uuid import UUID
55

6+
from django.apps import apps
7+
68
# Django
79
from django.conf import settings
810
from django.db import connection, models, transaction
@@ -17,6 +19,17 @@
1719
# ansible_base lib functions
1820
from ansible_base.lib.abstract_models.common import CommonModel, ImmutableCommonModel
1921

22+
# Conditional import for activity stream support
23+
#
24+
# The activitystream app is optional - services choose whether to enable it:
25+
# - Gateway/test_app: Include 'ansible_base.activitystream' in INSTALLED_APPS
26+
# - AWX/EDA/Hub: Don't include activitystream app (use legacy activity systems)
27+
if apps.is_installed('ansible_base.activitystream'):
28+
from ansible_base.activitystream.models import AuditableModel
29+
else:
30+
from .dummy_models import DummyAuditableModel as AuditableModel
31+
32+
2033
# ansible_base RBAC logic imports
2134
from ansible_base.lib.utils.models import is_add_perm
2235
from ansible_base.rbac.permission_registry import permission_registry
@@ -128,7 +141,6 @@ def get_or_create(self, permissions=(), defaults=None, **kwargs):
128141
return super().get_or_create(defaults=defaults, **kwargs)
129142

130143
def create_from_permissions(self, permissions=(), **kwargs):
131-
"Create from a list of text-type permissions and do validation"
132144
perm_list: list[str] = []
133145
for str_perm in permissions:
134146
if '.' in str_perm:
@@ -434,7 +446,7 @@ def save(self, *args, **kwargs):
434446
return super().save(*args, **kwargs)
435447

436448

437-
class RoleUserAssignment(AssignmentBase):
449+
class RoleUserAssignment(AssignmentBase, AuditableModel):
438450
role_definition = models.ForeignKey(
439451
RoleDefinition,
440452
on_delete=models.CASCADE,
@@ -446,6 +458,9 @@ class RoleUserAssignment(AssignmentBase):
446458
)
447459
router_basename = 'roleuserassignment'
448460

461+
# Exclude object_role from activity stream - it's an internal implementation detail
462+
activity_stream_excluded_field_names = ['object_role']
463+
449464
class Meta:
450465
app_label = 'dab_rbac'
451466
ordering = ['id']
@@ -460,7 +475,7 @@ def actor(self):
460475
return self.user
461476

462477

463-
class RoleTeamAssignment(AssignmentBase):
478+
class RoleTeamAssignment(AssignmentBase, AuditableModel):
464479
role_definition = models.ForeignKey(
465480
RoleDefinition,
466481
on_delete=models.CASCADE,
@@ -472,6 +487,9 @@ class RoleTeamAssignment(AssignmentBase):
472487
)
473488
router_basename = 'roleteamassignment'
474489

490+
# Exclude object_role from activity stream - it's an internal implementation detail
491+
activity_stream_excluded_field_names = ['object_role']
492+
475493
class Meta:
476494
app_label = 'dab_rbac'
477495
ordering = ['id']
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Test RBAC activity stream functionality."""
2+
3+
import uuid
4+
5+
import pytest
6+
from crum import impersonate
7+
from django.apps import apps
8+
from django.contrib.auth import get_user_model
9+
10+
from ansible_base.rbac.models import DABContentType, RoleDefinition, RoleTeamAssignment, RoleUserAssignment
11+
12+
User = get_user_model()
13+
14+
15+
def verify_activity_entry_fields(entry, operation, admin_user, actor_id, role_def_id, actor_field):
16+
"""Helper to verify activity entry has correct fields."""
17+
assert entry.operation == operation
18+
assert entry.created_by == admin_user
19+
assert entry.changes, f"{operation.title()} entry should have changes recorded"
20+
21+
# Get the appropriate fields dict based on operation
22+
fields_dict = entry.changes['added_fields'] if operation == 'create' else entry.changes['removed_fields']
23+
24+
# Verify required fields are present with correct values
25+
assert actor_field in fields_dict
26+
assert 'role_definition' in fields_dict
27+
assert str(actor_id) == fields_dict[actor_field]
28+
assert str(role_def_id) == fields_dict['role_definition']
29+
30+
31+
@pytest.mark.skipif(not apps.is_installed('ansible_base.activitystream'), reason="Activity stream tests only run when activitystream app is installed")
32+
@pytest.mark.django_db
33+
def test_role_user_assignment_activity_stream_lifecycle(system_user, admin_user, organization):
34+
"""Test role assignment create and delete both create proper activity entries."""
35+
# Create unique test user, role, and org with distinctive names
36+
test_uuid = str(uuid.uuid4())[:8]
37+
unique_username = f'test_rbac_user_{test_uuid}'
38+
unique_role_name = f'TestRole_ActivityStream_{test_uuid}'
39+
unique_org_name = f'TestOrg_ActivityStream_{test_uuid}'
40+
41+
test_user = User.objects.create_user(username=unique_username, email=f'{unique_username}@example.com')
42+
43+
# Create unique organization for this test
44+
from test_app.models import Organization
45+
46+
test_org = Organization.objects.create(name=unique_org_name)
47+
48+
ct = DABContentType.objects.get_for_model(test_org)
49+
role_def = RoleDefinition.objects.create(name=unique_role_name, content_type=ct)
50+
51+
# Create assignment (admin assigns role to user)
52+
with impersonate(admin_user):
53+
assignment = RoleUserAssignment.objects.create(user=test_user, role_definition=role_def, content_object=test_org, created_by=admin_user)
54+
55+
# Verify CREATE entry
56+
assert assignment.activity_stream_entries.count() == 1
57+
create_entry = assignment.activity_stream_entries.last()
58+
verify_activity_entry_fields(create_entry, 'create', admin_user, test_user.id, role_def.id, 'user')
59+
60+
# Verify enhanced string representation
61+
entry_str = str(create_entry)
62+
assert "created" in entry_str.lower()
63+
assert str(admin_user) in entry_str
64+
65+
66+
# Delete assignment and verify DELETE entry
67+
assignment_id = assignment.id
68+
with impersonate(admin_user):
69+
assignment.delete()
70+
71+
# Query entries directly since assignment pk=None after delete
72+
from django.contrib.contenttypes.models import ContentType
73+
74+
from ansible_base.activitystream.models import Entry
75+
76+
assignment_ct = ContentType.objects.get_for_model(RoleUserAssignment)
77+
assignment_entries = Entry.objects.filter(content_type=assignment_ct, object_id=str(assignment_id)).order_by('id')
78+
79+
assert assignment_entries.count() == 2
80+
delete_entry = assignment_entries.last()
81+
verify_activity_entry_fields(delete_entry, 'delete', admin_user, test_user.id, role_def.id, 'user')
82+
83+
# Verify enhanced string representation for delete
84+
delete_str = str(delete_entry)
85+
assert "deleted" in delete_str.lower()
86+
assert str(admin_user) in delete_str
87+
88+
89+
@pytest.mark.skipif(not apps.is_installed('ansible_base.activitystream'), reason="Activity stream tests only run when activitystream app is installed")
90+
@pytest.mark.django_db
91+
def test_role_team_assignment_activity_stream(admin_user, team, organization):
92+
"""Test team role assignment creates activity entries."""
93+
# Create unique role and org names for isolation
94+
test_uuid = str(uuid.uuid4())[:8]
95+
unique_role_name = f'TestTeamRole_{test_uuid}'
96+
unique_org_name = f'TestTeamOrg_{test_uuid}'
97+
98+
# Create unique organization for this test
99+
from test_app.models import Organization
100+
101+
test_org = Organization.objects.create(name=unique_org_name)
102+
103+
ct = DABContentType.objects.get_for_model(test_org)
104+
role_def = RoleDefinition.objects.create(name=unique_role_name, content_type=ct)
105+
106+
# Create team assignment
107+
with impersonate(admin_user):
108+
assignment = RoleTeamAssignment.objects.create(team=team, role_definition=role_def, content_object=test_org, created_by=admin_user)
109+
110+
# Verify CREATE entry
111+
assert assignment.activity_stream_entries.count() == 1
112+
create_entry = assignment.activity_stream_entries.last()
113+
verify_activity_entry_fields(create_entry, 'create', admin_user, team.id, role_def.id, 'team')
114+
115+
# Delete assignment and verify DELETE entry
116+
assignment_id = assignment.id
117+
with impersonate(admin_user):
118+
assignment.delete()
119+
120+
# Query entries directly since assignment pk=None after delete
121+
from django.contrib.contenttypes.models import ContentType
122+
123+
from ansible_base.activitystream.models import Entry
124+
125+
assignment_ct = ContentType.objects.get_for_model(RoleTeamAssignment)
126+
assignment_entries = Entry.objects.filter(content_type=assignment_ct, object_id=str(assignment_id)).order_by('id')
127+
128+
assert assignment_entries.count() == 2
129+
delete_entry = assignment_entries.last()
130+
verify_activity_entry_fields(delete_entry, 'delete', admin_user, team.id, role_def.id, 'team')

0 commit comments

Comments
 (0)