Skip to content

Commit da63dea

Browse files
committed
Handle object_created and created field transmissions
1 parent 4aeb0e6 commit da63dea

File tree

4 files changed

+141
-15
lines changed

4 files changed

+141
-15
lines changed

ansible_base/rbac/models/role.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,15 @@ def __str__(self):
192192
managed_str = ', managed=True'
193193
return f'RoleDefinition(pk={self.id}, name={self.name}{managed_str})'
194194

195-
def give_global_permission(self, actor, assignment_created=None):
196-
return self.give_or_remove_global_permission(actor, giving=True, assignment_created=assignment_created)
195+
def give_global_permission(self, actor, assignment_created=None, assignment_object_created=None):
196+
return self.give_or_remove_global_permission(
197+
actor, giving=True, assignment_created=assignment_created, assignment_object_created=assignment_object_created
198+
)
197199

198200
def remove_global_permission(self, actor):
199201
return self.give_or_remove_global_permission(actor, giving=False)
200202

201-
def give_or_remove_global_permission(self, actor, giving=True, assignment_created=None):
203+
def give_or_remove_global_permission(self, actor, giving=True, assignment_created=None, assignment_object_created=None):
202204
if giving and (self.content_type is not None):
203205
raise ValidationError('Role definition content type must be null to assign globally')
204206

@@ -218,6 +220,8 @@ def give_or_remove_global_permission(self, actor, giving=True, assignment_create
218220
if giving:
219221
if assignment_created:
220222
kwargs['created'] = assignment_created
223+
if assignment_object_created:
224+
kwargs['object_created'] = assignment_object_created
221225
assignment, _ = cls.objects.get_or_create(**kwargs)
222226
else:
223227
assignment = cls.objects.filter(**kwargs).first()
@@ -237,8 +241,10 @@ def give_or_remove_global_permission(self, actor, giving=True, assignment_create
237241

238242
return assignment
239243

240-
def give_permission(self, actor, content_object, assignment_created=None):
241-
return self.give_or_remove_permission(actor, content_object, giving=True, assignment_created=assignment_created)
244+
def give_permission(self, actor, content_object, assignment_created=None, assignment_object_created=None):
245+
return self.give_or_remove_permission(
246+
actor, content_object, giving=True, assignment_created=assignment_created, assignment_object_created=assignment_object_created
247+
)
242248

243249
def remove_permission(self, actor, content_object):
244250
return self.give_or_remove_permission(actor, content_object, giving=False)
@@ -263,7 +269,7 @@ def get_or_create_object_role(self, kwargs, defaults):
263269
object_role = ObjectRole.objects.create(**kwargs, **defaults)
264270
return (object_role, True)
265271

266-
def give_or_remove_permission(self, actor, content_object, giving=True, sync_action=False, assignment_created=None):
272+
def give_or_remove_permission(self, actor, content_object, giving=True, sync_action=False, assignment_created=None, assignment_object_created=None):
267273
"Shortcut method to do whatever needed to give user or team these permissions"
268274
validate_assignment(self, actor, content_object)
269275

@@ -295,9 +301,13 @@ def give_or_remove_permission(self, actor, content_object, giving=True, sync_act
295301
update_teams, to_update = needed_updates_on_assignment(self, actor, object_role, created=created, giving=True)
296302

297303
assignment_defaults = {}
298-
object_created = get_created_timestamp(content_object)
299-
if object_created:
300-
assignment_defaults['object_created'] = object_created
304+
# Use provided object_created if available, otherwise get from content_object
305+
if assignment_object_created:
306+
assignment_defaults['object_created'] = assignment_object_created
307+
else:
308+
object_created = get_created_timestamp(content_object)
309+
if object_created:
310+
assignment_defaults['object_created'] = object_created
301311
if assignment_created:
302312
assignment_defaults['created'] = assignment_created
303313

ansible_base/rbac/service_api/serializers.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def to_internal_value(self, value):
4747
return resource.object_id
4848

4949

50-
assignment_common_fields = ('created', 'created_by_ansible_id', 'object_id', 'object_ansible_id', 'content_type', 'role_definition')
50+
assignment_common_fields = ('created', 'object_created', 'created_by_ansible_id', 'object_id', 'object_ansible_id', 'content_type', 'role_definition')
5151

5252

5353
class BaseAssignmentSerializer(serializers.ModelSerializer):
@@ -59,6 +59,8 @@ class BaseAssignmentSerializer(serializers.ModelSerializer):
5959
from_service = serializers.CharField(write_only=True)
6060
# Force created field to be writable
6161
created = serializers.DateTimeField(required=False)
62+
# Force object_created field to be writable
63+
object_created = serializers.DateTimeField(required=False)
6264

6365
def to_representation(self, instance):
6466
# hack to surface content_object for ObjectIDAnsibleIDField
@@ -133,10 +135,14 @@ def create(self, validated_data):
133135
raise serializers.ValidationError({'object_id': _('Object must be specified for this role assignment')})
134136

135137
with transaction.atomic():
136-
assignment = rd.give_permission(actor, obj, assignment_created=validated_data.get('created'))
138+
assignment = rd.give_permission(
139+
actor, obj, assignment_created=validated_data.get('created'), assignment_object_created=validated_data.get('object_created')
140+
)
137141
else:
138142
with transaction.atomic():
139-
assignment = rd.give_global_permission(actor, assignment_created=validated_data.get('created'))
143+
assignment = rd.give_global_permission(
144+
actor, assignment_created=validated_data.get('created'), assignment_object_created=validated_data.get('object_created')
145+
)
140146

141147
return assignment
142148

test_app/tests/rbac/remote/test_remote_assignment.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ def test_object_created_field_remote_object(rando, foo_type, foo_rd):
185185
186186
This test should FAIL because the object_created field doesn't exist yet.
187187
"""
188-
from datetime import datetime, timezone
189-
190188
# Create a remote object stand-in
191189
remote_foo = RemoteObject(content_type=foo_type, object_id=42)
192190

test_app/tests/rbac/remote/test_service_api.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
from ansible_base.lib.utils.response import get_relative_url
6-
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition, RoleTeamAssignment, RoleUserAssignment
6+
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition, RoleUserAssignment
77
from test_app.models import Team, User
88

99

@@ -492,6 +492,7 @@ def test_serializer_allows_null_values_in_validation(self, admin_api_client, ran
492492
validated_data = serializer.validated_data
493493
assert 'created_by' not in validated_data or validated_data.get('created_by') is None
494494

495+
495496
def test_service_assignment_created_timestamp_sync(admin_api_client, rando, inv_rd, inventory):
496497
"""
497498
Test that demonstrates the field sync issue: the 'created' timestamp field is displayed
@@ -539,3 +540,114 @@ def test_service_assignment_created_timestamp_sync(admin_api_client, rando, inv_
539540
response_created = parse_datetime(response.data['created'])
540541
# Note: This will show the auto-generated timestamp, not our custom one
541542
assert response_created == expected_created, f"Response created timestamp should match: expected '{expected_created}' but got '{response_created}'"
543+
544+
545+
@pytest.mark.django_db
546+
def test_service_assignment_object_created_timestamp_sync(admin_api_client, rando, inv_rd, inventory):
547+
"""
548+
Test that the 'object_created' field can be synchronized in both directions:
549+
1. POST to /assign/ accepts a provided 'object_created' value
550+
2. Serializing local assignments includes the 'object_created' field from the DB
551+
"""
552+
from datetime import datetime, timezone
553+
554+
from django.utils.dateparse import parse_datetime
555+
556+
url = get_relative_url('serviceuserassignment-assign')
557+
558+
creator_user = User.objects.create(username='object_created_creator')
559+
560+
# Set a specific object_created timestamp that's different from the actual object's created time
561+
custom_object_created = datetime(2022, 6, 15, 14, 30, 0, tzinfo=timezone.utc)
562+
custom_object_created_str = custom_object_created.isoformat()
563+
564+
post_data = {
565+
"role_definition": inv_rd.name,
566+
"user_ansible_id": str(rando.resource.ansible_id),
567+
"object_id": str(inventory.pk),
568+
"created_by_ansible_id": str(creator_user.resource.ansible_id),
569+
"object_created": custom_object_created_str,
570+
"from_service": "test_service",
571+
}
572+
573+
# Test 1: POST accepts object_created value
574+
response = admin_api_client.post(url, data=post_data)
575+
assert response.status_code == 201, response.data
576+
577+
assignment = RoleUserAssignment.objects.get(user=rando, role_definition=inv_rd, object_id=inventory.pk)
578+
579+
# Verify the custom object_created timestamp was properly set
580+
expected_object_created = custom_object_created
581+
actual_object_created = assignment.object_created
582+
583+
assert (
584+
actual_object_created == expected_object_created
585+
), f"object_created should be synchronized: Expected '{expected_object_created}' but got '{actual_object_created}'"
586+
587+
# Test 2: Serializing local assignments includes object_created field
588+
list_url = get_relative_url('serviceuserassignment-list')
589+
response = admin_api_client.get(list_url + '?page_size=200', format="json")
590+
assert response.status_code == 200, response.data
591+
592+
# Find our assignment in the list
593+
assignments = [a for a in response.data['results'] if a['role_definition'] == inv_rd.name and str(a['object_id']) == str(inventory.pk)]
594+
assert len(assignments) >= 1, "Should find at least our assignment"
595+
596+
# Check that object_created is properly serialized
597+
assignment_data = assignments[0]
598+
assert 'object_created' in assignment_data, "object_created field should be present in serialized output"
599+
600+
response_object_created = parse_datetime(assignment_data['object_created'])
601+
assert (
602+
response_object_created == expected_object_created
603+
), f"Serialized object_created should match stored value: expected '{expected_object_created}' but got '{response_object_created}'"
604+
605+
606+
@pytest.mark.django_db
607+
def test_service_assignment_object_created_from_local_object(admin_api_client, rando, org_inv_rd, organization):
608+
"""
609+
Test that when no object_created is provided in POST, the field is automatically set
610+
from the local object's created timestamp and properly serialized.
611+
"""
612+
url = get_relative_url('serviceuserassignment-assign')
613+
614+
post_data = {
615+
"role_definition": org_inv_rd.name,
616+
"user_ansible_id": str(rando.resource.ansible_id),
617+
"object_id": str(organization.pk),
618+
"from_service": "test_service",
619+
# Note: no object_created provided - should default to organization.created
620+
}
621+
622+
# Create assignment without providing object_created
623+
response = admin_api_client.post(url, data=post_data)
624+
assert response.status_code == 201, response.data
625+
626+
assignment = RoleUserAssignment.objects.get(user=rando, role_definition=org_inv_rd, object_id=organization.pk)
627+
628+
# Verify object_created was set to the organization's created timestamp
629+
expected_object_created = organization.created
630+
actual_object_created = assignment.object_created
631+
632+
assert (
633+
actual_object_created == expected_object_created
634+
), f"object_created should default to organization.created: Expected '{expected_object_created}' but got '{actual_object_created}'"
635+
636+
# Verify serialization includes the correct object_created value
637+
list_url = get_relative_url('serviceuserassignment-list')
638+
response = admin_api_client.get(list_url + '?page_size=200', format="json")
639+
assert response.status_code == 200, response.data
640+
641+
# Find our assignment
642+
assignments = [a for a in response.data['results'] if a['role_definition'] == org_inv_rd.name and str(a['object_id']) == str(organization.pk)]
643+
assert len(assignments) >= 1, "Should find at least our assignment"
644+
645+
assignment_data = assignments[0]
646+
assert 'object_created' in assignment_data, "object_created field should be present in serialized output"
647+
648+
from django.utils.dateparse import parse_datetime
649+
650+
response_object_created = parse_datetime(assignment_data['object_created'])
651+
assert (
652+
response_object_created == expected_object_created
653+
), f"Serialized object_created should match organization.created: expected '{expected_object_created}' but got '{response_object_created}'"

0 commit comments

Comments
 (0)