Skip to content

Commit 9cbfae5

Browse files
authored
Merge pull request #321 from browserstack/security/enigma-races
security(races): serialize approval transitions with select_for_update + idempotency guard (F-027/028/029)
2 parents 4a13154 + 70e5734 commit 9cbfae5

3 files changed

Lines changed: 124 additions & 96 deletions

File tree

Access/accessrequest_helper.py

Lines changed: 61 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -632,64 +632,74 @@ def is_request_valid(request_id, access_mapping):
632632
def accept_user_access_requests(auth_user, request_id):
633633
""" Grant for individual user access requests """
634634
json_response = {}
635-
access_mapping = UserAccessMapping.get_access_request(request_id)
636-
if not is_request_valid(request_id, access_mapping):
637-
json_response["error"] = USER_REQUEST_IN_PROCESS_ERR_MSG.format(
638-
request_id=request_id,
639-
)
640-
return json_response
641-
642-
requester = access_mapping.user_identity.user
643-
if auth_user.user == requester:
644-
json_response["error"] = SELF_APPROVAL_ERROR_MSG
645-
return json_response
635+
try:
636+
with transaction.atomic():
637+
# F-027: lock the mapping row so the validity/approver-state check and
638+
# the processing() status write are atomic. Without the lock two
639+
# concurrent approvers can both pass the check and double-dispatch the
640+
# grant task. The Celery dispatch inside run_accept_request_task now
641+
# only fires for the single request that wins the lock.
642+
access_mapping = (
643+
UserAccessMapping.objects.select_for_update()
644+
.filter(request_id=request_id)
645+
.first()
646+
)
647+
if not is_request_valid(request_id, access_mapping):
648+
json_response["error"] = USER_REQUEST_IN_PROCESS_ERR_MSG.format(
649+
request_id=request_id,
650+
)
651+
return json_response
646652

647-
access_label = access_mapping.access.access_label
653+
requester = access_mapping.user_identity.user
654+
if auth_user.user == requester:
655+
json_response["error"] = SELF_APPROVAL_ERROR_MSG
656+
return json_response
648657

649-
try:
650-
permissions = _get_approver_permissions(
651-
access_mapping.access.access_tag, access_label
652-
)
653-
approver_permissions = permissions["approver_permissions"]
654-
if not helpers.check_user_permissions(
655-
auth_user, list(approver_permissions.values())
656-
):
657-
logger.debug(USER_REQUEST_PERMISSION_DENIED_ERR_MSG)
658-
json_response["error"] = USER_REQUEST_PERMISSION_DENIED_ERR_MSG
659-
return json_response
658+
access_label = access_mapping.access.access_label
660659

661-
is_primary_approver = (
662-
access_mapping.is_pending()
663-
and auth_user.user.has_permission(approver_permissions["1"])
664-
)
665-
is_secondary_approver = (
666-
access_mapping.is_secondary_pending()
667-
and auth_user.user.has_permission(approver_permissions["2"])
668-
)
660+
permissions = _get_approver_permissions(
661+
access_mapping.access.access_tag, access_label
662+
)
663+
approver_permissions = permissions["approver_permissions"]
664+
if not helpers.check_user_permissions(
665+
auth_user, list(approver_permissions.values())
666+
):
667+
logger.debug(USER_REQUEST_PERMISSION_DENIED_ERR_MSG)
668+
json_response["error"] = USER_REQUEST_PERMISSION_DENIED_ERR_MSG
669+
return json_response
669670

670-
if not (is_primary_approver or is_secondary_approver):
671-
logger.debug(USER_REQUEST_PERMISSION_DENIED_ERR_MSG)
672-
json_response["error"] = USER_REQUEST_PERMISSION_DENIED_ERR_MSG
673-
return json_response
674-
if is_primary_approver and "2" in approver_permissions:
675-
access_mapping.approver_1 = auth_user.user
676-
access_mapping.update_access_status("SecondaryPending")
677-
json_response["msg"] = USER_REQUEST_SECONDARY_PENDING_MSG.format(
678-
request_id=request_id, approved_by=auth_user.username
671+
is_primary_approver = (
672+
access_mapping.is_pending()
673+
and auth_user.user.has_permission(approver_permissions["1"])
679674
)
680-
logger.debug(
681-
USER_REQUEST_SECONDARY_PENDING_MSG.format(
675+
is_secondary_approver = (
676+
access_mapping.is_secondary_pending()
677+
and auth_user.user.has_permission(approver_permissions["2"])
678+
)
679+
680+
if not (is_primary_approver or is_secondary_approver):
681+
logger.debug(USER_REQUEST_PERMISSION_DENIED_ERR_MSG)
682+
json_response["error"] = USER_REQUEST_PERMISSION_DENIED_ERR_MSG
683+
return json_response
684+
if is_primary_approver and "2" in approver_permissions:
685+
access_mapping.approver_1 = auth_user.user
686+
access_mapping.update_access_status("SecondaryPending")
687+
json_response["msg"] = USER_REQUEST_SECONDARY_PENDING_MSG.format(
682688
request_id=request_id, approved_by=auth_user.username
683689
)
684-
)
685-
else:
686-
json_response = run_accept_request_task(
687-
is_primary_approver,
688-
access_mapping,
689-
auth_user=auth_user,
690-
request_id=request_id,
691-
access_label=access_label,
692-
)
690+
logger.debug(
691+
USER_REQUEST_SECONDARY_PENDING_MSG.format(
692+
request_id=request_id, approved_by=auth_user.username
693+
)
694+
)
695+
else:
696+
json_response = run_accept_request_task(
697+
is_primary_approver,
698+
access_mapping,
699+
auth_user=auth_user,
700+
request_id=request_id,
701+
access_label=access_label,
702+
)
693703
except Exception as exception:
694704
return process_error_response(exception)
695705

Access/background_task_manager.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ def background_task(func, *args):
5656
)
5757
def run_access_grant(request_id):
5858
user_access_mapping = UserAccessMapping.get_access_request(request_id=request_id)
59+
# F-029: idempotency guard. autoretry_for=(Exception,) can re-run this task;
60+
# if the request is already approved, do not call the access module again
61+
# (which would create duplicate external grants).
62+
if user_access_mapping is None:
63+
logger.warning("run_access_grant: request %s not found; skipping", request_id)
64+
return False
65+
if user_access_mapping.is_approved():
66+
logger.info(
67+
"run_access_grant: request %s already approved; skipping re-grant",
68+
request_id,
69+
)
70+
return True
5971
access_tag = user_access_mapping.access.access_tag
6072
user = user_access_mapping.user_identity.user
6173
approver = user_access_mapping.approver_1.user

Access/group_helper.py

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -547,57 +547,63 @@ def is_user_in_group(user_email, group_members_email):
547547

548548

549549
def accept_member(auth_user, requestId, shouldRender=True):
550+
context = {}
550551
try:
551-
membership = MembershipV2.get_membership(membership_id=requestId)
552-
if not membership:
553-
logger.error("Error request not found OR Invalid request type")
554-
context = {}
555-
context["error"] = REQUEST_NOT_FOUND_ERROR + str(e)
556-
return context
552+
with transaction.atomic():
553+
# F-028: lock the membership row so the is_pending() check and the
554+
# approve() write are atomic. Without the lock two concurrent
555+
# approvers can both pass is_pending() and double-grant the group.
556+
membership = (
557+
MembershipV2.objects.select_for_update()
558+
.filter(membership_id=requestId)
559+
.first()
560+
)
561+
if not membership:
562+
logger.error("Error request not found OR Invalid request type")
563+
context["error"] = REQUEST_NOT_FOUND_ERROR
564+
return context
565+
if not membership.is_pending():
566+
logger.warning(
567+
"An Already Approved/Declined/Processing Request was accessed by - "
568+
+ auth_user.username
569+
)
570+
context["error"] = REQUEST_PROCESSED_BY.format(
571+
requestId=requestId, user=membership.approver.user.username
572+
)
573+
return context
574+
if membership.is_self_approval(approver=auth_user.user):
575+
context["error"] = SELF_APPROVAL_ERROR
576+
return context
577+
578+
context["msg"] = REQUEST_PROCESSING.format(requestId=requestId)
579+
membership.approve(auth_user.user)
580+
group = membership.group
581+
user = membership.user
582+
user_mappings_list = views_helper.generate_user_mappings(
583+
user, group, membership
584+
)
585+
586+
# Lock released after commit. External grants and notifications must run
587+
# outside the transaction so we never hold a row lock across them.
588+
execute_group_access(user_mappings_list)
589+
590+
notifications.send_membership_accepted_notification(
591+
user=user, group=group, membership=membership
592+
)
593+
logger.debug(
594+
"Process has been started for the Approval of request - "
595+
+ requestId
596+
+ " - Approver="
597+
+ auth_user.username
598+
)
599+
return context
557600
except Exception as e:
601+
logger.exception("Error while accepting group membership: %s", str(e))
558602
context["error"] = {
559603
"error_msg": INTERNAL_ERROR_MESSAGE["error_msg"],
560604
"msg": INTERNAL_ERROR_MESSAGE["msg"],
561605
}
562-
563-
try:
564-
if not membership.is_pending():
565-
logger.warning(
566-
"An Already Approved/Declined/Processing Request was accessed by - "
567-
+ auth_user.username
568-
)
569-
context = {}
570-
context["error"] = REQUEST_PROCESSED_BY.format(
571-
requestId=requestId, user=membership.approver.user.username
572-
)
573-
return context
574-
elif membership.is_self_approval(approver=auth_user.user):
575-
context = {}
576-
context["error"] = SELF_APPROVAL_ERROR
577-
return context
578-
else:
579-
context = {}
580-
context["msg"] = REQUEST_PROCESSING.format(requestId=requestId)
581-
with transaction.atomic():
582-
membership.approve(auth_user.user)
583-
group = membership.group
584-
user = membership.user
585-
user_mappings_list = views_helper.generate_user_mappings(
586-
user, group, membership
587-
)
588-
589-
execute_group_access(user_mappings_list)
590-
591-
notifications.send_membership_accepted_notification(
592-
user=user, group=group, membership=membership
593-
)
594-
logger.debug(
595-
"Process has been started for the Approval of request - "
596-
+ requestId
597-
+ " - Approver="
598-
+ auth_user.username
599-
)
600-
return context
606+
return context
601607
except Exception as e:
602608
logger.exception(e)
603609
logger.error("Error in Accept of New Member in Group request.")

0 commit comments

Comments
 (0)