Skip to content

Commit e0c5cdd

Browse files
committed
Add changes according to review comments
1 parent 5c3bc60 commit e0c5cdd

File tree

2 files changed

+111
-13
lines changed

2 files changed

+111
-13
lines changed

ansible_base/rbac/api/views.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import uuid
23
from collections import OrderedDict
34
from typing import Type
@@ -9,7 +10,7 @@
910
from django.db.models import Model
1011
from django.utils.translation import gettext_lazy as _
1112
from rest_framework import permissions
12-
from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError
13+
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
1314
from rest_framework.generics import GenericAPIView
1415
from rest_framework.response import Response
1516
from rest_framework.viewsets import GenericViewSet, ModelViewSet, mixins
@@ -43,6 +44,8 @@
4344
from ..sync import maybe_reverse_sync_assignment, maybe_reverse_sync_role_definition, maybe_reverse_sync_unassignment
4445
from .queries import assignment_qs_user_to_obj, assignment_qs_user_to_obj_perm
4546

47+
logger = logging.getLogger(__name__)
48+
4649

4750
def list_combine_values(data: dict[Type[Model], list[str]]) -> list[str]:
4851
"Utility method to merge everything in .values() into a single list"
@@ -195,25 +198,41 @@ def remote_sync_unassignment(self, role_definition, actor, content_object):
195198
def perform_create(self, serializer):
196199
# Wrap the entire operation in a single atomic transaction
197200
# This ensures that if remote sync fails, the local assignment creation is rolled back
198-
# eliminating the race condition where assignments briefly exist before manual deletion
201+
# automatically. Without this, there was a race condition where:
202+
# 1. Local assignment is committed to the database
203+
# 2. Remote sync fails
204+
# 3. Assignment must be manually deleted, but may be visible to users briefly
205+
# The transaction.atomic() ensures step 1 only commits if step 2 succeeds.
199206
try:
200207
with transaction.atomic():
201-
super().perform_create(serializer)
208+
ret = super().perform_create(serializer)
202209
self.remote_sync_assignment(serializer.instance)
210+
return ret
203211
except requests.exceptions.HTTPError as exc:
204212
# Transaction has already rolled back automatically
205213
# No need to manually delete - assignment was never committed
206214
status_code = getattr(exc.response, 'status_code', None)
207-
error_data = {"detail": str(exc)}
215+
216+
# Preserve structured error data from gateway response
217+
error_data = {"detail": str(exc)} # Fallback
218+
try:
219+
if exc.response and hasattr(exc.response, 'json'):
220+
response_data = exc.response.json()
221+
# Use structured data if available, fall back to string
222+
error_data = response_data if isinstance(response_data, dict) else error_data
223+
except (ValueError, AttributeError):
224+
pass # Keep fallback error_data
208225

209226
if status_code == 400:
210227
raise ValidationError(error_data) from exc
211228
elif status_code in [401, 403]:
212229
raise PermissionDenied(error_data) from exc
213230
else:
214-
# For any other HTTP error (500) or HTTPError with no response
215-
# Raise APIException to get a 500 Internal Server Error
216-
raise APIException(error_data) from exc
231+
# Add logging for debugging before re-raising
232+
logger.error(f"Unexpected HTTP error during remote sync: {exc}")
233+
# Re-raise the original HTTPError to preserve full stack trace
234+
# This avoids creating a redundant 500 error and maintains debugging context
235+
raise
217236

218237
def perform_destroy(self, instance):
219238
check_can_remove_assignment(self.request.user, instance)

test_app/tests/rbac/api/test_rbac_views.py

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.test.utils import override_settings
66

77
from ansible_base.lib.utils.response import get_relative_url
8-
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment
8+
from ansible_base.rbac.models import RoleDefinition, RoleTeamAssignment, RoleUserAssignment
99

1010

1111
@pytest.mark.django_db
@@ -203,8 +203,8 @@ def test_role_definitions_post_disabled_by_settings(admin_api_client):
203203
(400, 400), # ValidationError
204204
(401, 403), # PermissionDenied
205205
(403, 403), # PermissionDenied
206-
(500, 500), # Re-raised HTTPError → Internal Server Error
207-
(None, 500), # HTTPError with no response → Internal Server Error
206+
(500, 500), # Re-raised original HTTPError preserves stack trace
207+
(None, 500), # Re-raised HTTPError with no response preserves context
208208
],
209209
)
210210
@pytest.mark.django_db
@@ -228,9 +228,16 @@ def test_user_assignment_remote_sync_error_handling(admin_api_client, inv_rd, ra
228228
with patch('ansible_base.rbac.api.views.BaseAssignmentViewSet.remote_sync_assignment') as mock_sync:
229229
mock_sync.side_effect = http_error
230230

231-
response = admin_api_client.post(url, data=data, format="json")
232-
assert response.status_code == expected_status
233-
assert 'detail' in response.data
231+
if sync_error_status in [500, None]:
232+
# For 500 errors and HTTPError with no response, we re-raise the original HTTPError
233+
# which will be an unhandled exception in the test client
234+
with pytest.raises(requests.exceptions.HTTPError):
235+
admin_api_client.post(url, data=data, format="json")
236+
else:
237+
# For 400/401/403 errors, we convert to DRF exceptions with proper HTTP responses
238+
response = admin_api_client.post(url, data=data, format="json")
239+
assert response.status_code == expected_status
240+
assert 'detail' in response.data
234241

235242
# Verify that no assignment was created due to transaction rollback
236243
assert RoleUserAssignment.objects.count() == initial_count
@@ -260,3 +267,75 @@ def test_user_assignment_remote_sync_connection_error(admin_api_client, inv_rd,
260267
# Verify that no assignment was created due to transaction rollback
261268
# This confirms that our transaction.atomic() fix works correctly
262269
assert RoleUserAssignment.objects.count() == initial_count
270+
271+
272+
@pytest.mark.parametrize(
273+
"sync_error_status, expected_status",
274+
[
275+
(400, 400), # ValidationError
276+
(401, 403), # PermissionDenied
277+
(403, 403), # PermissionDenied
278+
(500, 500), # Re-raised original HTTPError preserves stack trace
279+
(None, 500), # Re-raised HTTPError with no response preserves context
280+
],
281+
)
282+
@pytest.mark.django_db
283+
def test_team_assignment_remote_sync_error_handling(admin_api_client, inv_rd, team, inventory, sync_error_status, expected_status):
284+
"""Test that remote sync HTTP errors are handled correctly for team assignments"""
285+
url = get_relative_url('roleteamassignment-list')
286+
data = dict(role_definition=inv_rd.id, team=team.id, object_id=inventory.id)
287+
288+
# Track initial count to verify transaction rollback
289+
initial_count = RoleTeamAssignment.objects.count()
290+
291+
# Mock the remote sync to raise HTTPError with specified status
292+
if sync_error_status is not None:
293+
mock_response = requests.Response()
294+
mock_response.status_code = sync_error_status
295+
http_error = requests.exceptions.HTTPError(response=mock_response)
296+
else:
297+
# Simulate HTTPError with no response (e.g., connection failed)
298+
http_error = requests.exceptions.HTTPError(response=None)
299+
300+
with patch('ansible_base.rbac.api.views.BaseAssignmentViewSet.remote_sync_assignment') as mock_sync:
301+
mock_sync.side_effect = http_error
302+
303+
if sync_error_status in [500, None]:
304+
# For 500 errors and HTTPError with no response, we re-raise the original HTTPError
305+
# which will be an unhandled exception in the test client
306+
with pytest.raises(requests.exceptions.HTTPError):
307+
admin_api_client.post(url, data=data, format="json")
308+
else:
309+
# For 400/401/403 errors, we convert to DRF exceptions with proper HTTP responses
310+
response = admin_api_client.post(url, data=data, format="json")
311+
assert response.status_code == expected_status
312+
assert 'detail' in response.data
313+
314+
# Verify that no assignment was created due to transaction rollback
315+
assert RoleTeamAssignment.objects.count() == initial_count
316+
317+
318+
@pytest.mark.django_db
319+
def test_team_assignment_remote_sync_connection_error(admin_api_client, inv_rd, team, inventory):
320+
"""Test that connection errors during remote sync cause unhandled exceptions but transaction rollback works"""
321+
url = get_relative_url('roleteamassignment-list')
322+
data = dict(role_definition=inv_rd.id, team=team.id, object_id=inventory.id)
323+
324+
# Track initial count to verify transaction rollback
325+
initial_count = RoleTeamAssignment.objects.count()
326+
327+
# Mock the remote sync to raise ConnectionError (no HTTP response)
328+
connection_error = requests.exceptions.ConnectionError("Connection failed")
329+
330+
with patch('ansible_base.rbac.api.views.BaseAssignmentViewSet.remote_sync_assignment') as mock_sync:
331+
mock_sync.side_effect = connection_error
332+
333+
# ConnectionError is not currently caught, so it escapes as an unhandled exception
334+
# The Django test client converts this to a 500 response, but we can also test
335+
# by catching the exception directly to verify the rollback behavior
336+
with pytest.raises(requests.exceptions.ConnectionError):
337+
admin_api_client.post(url, data=data, format="json")
338+
339+
# Verify that no assignment was created due to transaction rollback
340+
# This confirms that our transaction.atomic() fix works correctly
341+
assert RoleTeamAssignment.objects.count() == initial_count

0 commit comments

Comments
 (0)