Skip to content

Commit 2b2b0e0

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

File tree

3 files changed

+193
-3
lines changed

3 files changed

+193
-3
lines changed
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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
61+
# Delete assignment and verify DELETE entry
62+
assignment_id = assignment.id
63+
with impersonate(admin_user):
64+
assignment.delete()
65+
66+
# Query entries directly since assignment pk=None after delete
67+
from django.contrib.contenttypes.models import ContentType
68+
69+
from ansible_base.activitystream.models import Entry
70+
71+
assignment_ct = ContentType.objects.get_for_model(RoleUserAssignment)
72+
assignment_entries = Entry.objects.filter(content_type=assignment_ct, object_id=str(assignment_id)).order_by('id')
73+
74+
assert assignment_entries.count() == 2
75+
delete_entry = assignment_entries.last()
76+
verify_activity_entry_fields(delete_entry, 'delete', admin_user, test_user.id, role_def.id, 'user')
77+
78+
79+
@pytest.mark.skipif(not apps.is_installed('ansible_base.activitystream'), reason="Activity stream tests only run when activitystream app is installed")
80+
@pytest.mark.django_db
81+
def test_role_team_assignment_activity_stream(admin_user, team, organization):
82+
"""Test team role assignment creates activity entries."""
83+
# Create unique role and org names for isolation
84+
test_uuid = str(uuid.uuid4())[:8]
85+
unique_role_name = f'TestTeamRole_{test_uuid}'
86+
unique_org_name = f'TestTeamOrg_{test_uuid}'
87+
88+
# Create unique organization for this test
89+
from test_app.models import Organization
90+
91+
test_org = Organization.objects.create(name=unique_org_name)
92+
93+
ct = DABContentType.objects.get_for_model(test_org)
94+
role_def = RoleDefinition.objects.create(name=unique_role_name, content_type=ct)
95+
96+
# Create team assignment
97+
with impersonate(admin_user):
98+
assignment = RoleTeamAssignment.objects.create(team=team, role_definition=role_def, content_object=test_org, created_by=admin_user)
99+
100+
# Verify CREATE entry
101+
assert assignment.activity_stream_entries.count() == 1
102+
create_entry = assignment.activity_stream_entries.last()
103+
verify_activity_entry_fields(create_entry, 'create', admin_user, team.id, role_def.id, 'team')
104+
105+
# Delete assignment and verify DELETE entry
106+
assignment_id = assignment.id
107+
with impersonate(admin_user):
108+
assignment.delete()
109+
110+
# Query entries directly since assignment pk=None after delete
111+
from django.contrib.contenttypes.models import ContentType
112+
113+
from ansible_base.activitystream.models import Entry
114+
115+
assignment_ct = ContentType.objects.get_for_model(RoleTeamAssignment)
116+
assignment_entries = Entry.objects.filter(content_type=assignment_ct, object_id=str(assignment_id)).order_by('id')
117+
118+
assert assignment_entries.count() == 2
119+
delete_entry = assignment_entries.last()
120+
verify_activity_entry_fields(delete_entry, 'delete', admin_user, team.id, role_def.id, 'team')

0 commit comments

Comments
 (0)