Fix: Deletion Deadlocks (Owner, User)
This commit is contained in:
@@ -220,48 +220,73 @@ def update_organization(org_id):
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_owner
|
||||
@full_access_required
|
||||
def delete_organization(org_id):
|
||||
"""
|
||||
Delete organization (soft delete).
|
||||
|
||||
The owner may only delete the organization if they are the *sole* remaining
|
||||
member. If other active members exist they must first transfer ownership
|
||||
(or remove all other members) before deleting the organization.
|
||||
Only the OWNER of the organization may call this endpoint.
|
||||
|
||||
When the organization has other active members the caller must explicitly
|
||||
confirm the deletion by sending ``{"confirm": true}`` in the request body.
|
||||
All members (and their memberships) are soft-deleted together with the org
|
||||
in a single atomic transaction so no orphaned data is left behind.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Request body (JSON, optional):
|
||||
confirm (bool): Required when the org has other active members.
|
||||
|
||||
Returns:
|
||||
200: Organization deleted successfully
|
||||
400: Organization has other members but confirm was not true
|
||||
401: Not authenticated
|
||||
403: Not the owner
|
||||
404: Organization not found
|
||||
409: Organization still has other members — transfer ownership first
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember as _OrgMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole as _OrgRole
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Guard: block deletion while non-owner members still exist so ownership
|
||||
# can be transferred rather than silently orphaning them.
|
||||
active_member_count = org.get_member_count()
|
||||
if active_member_count > 1:
|
||||
# Only the owner may delete the organization.
|
||||
caller_membership = _OrgMember.query.filter_by(
|
||||
user_id=caller.id,
|
||||
organization_id=org.id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
|
||||
if not caller_membership or caller_membership.role != _OrgRole.OWNER:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
"This organization still has other members. "
|
||||
"Please transfer ownership to another member or remove all "
|
||||
"other members before deleting the organization."
|
||||
),
|
||||
status=409,
|
||||
error_type="ORG_HAS_MEMBERS",
|
||||
error_details={"member_count": active_member_count},
|
||||
message="Only the organization owner can delete the organization.",
|
||||
status=403,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
OrganizationService.delete_organization(
|
||||
# If other members exist, require explicit confirmation to avoid accidents.
|
||||
active_member_count = org.get_member_count()
|
||||
if active_member_count > 1:
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not data.get("confirm"):
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"This organization has {active_member_count} active members. "
|
||||
"Deleting it will remove all members and their data. "
|
||||
'Send {"confirm": true} to confirm.'
|
||||
),
|
||||
status=400,
|
||||
error_type="CONFIRMATION_REQUIRED",
|
||||
error_details={"member_count": active_member_count},
|
||||
)
|
||||
|
||||
OrganizationService.force_delete_organization(
|
||||
org=org,
|
||||
user_id=g.current_user.id,
|
||||
soft=True,
|
||||
user_id=caller.id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
|
||||
@@ -73,50 +73,62 @@ def delete_me():
|
||||
"""
|
||||
Delete current user account (soft delete).
|
||||
|
||||
Blocked if the user is the sole owner of any organization that has other
|
||||
active members — they must transfer ownership or dissolve those organizations
|
||||
first.
|
||||
Behaviour for owned organizations:
|
||||
- If the org has other active members → blocked; user must transfer ownership first.
|
||||
- If they are the sole member → org is automatically cascade-deleted (no orphan risk).
|
||||
|
||||
Returns:
|
||||
200: Account deleted successfully
|
||||
200: Account deleted successfully (sole-member orgs auto-deleted)
|
||||
401: Not authenticated
|
||||
409: User is sole owner of one or more organizations with other members
|
||||
409: USER_IS_SOLE_OWNER — user owns orgs that still have other members
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
user = g.current_user
|
||||
|
||||
# Find orgs where this user is the sole owner AND other members exist.
|
||||
# Find all orgs where this user is the owner.
|
||||
owned_memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
role=OrganizationRole.OWNER,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
blocked_orgs = []
|
||||
# Separate into two buckets depending on whether other members exist.
|
||||
transfer_needed = [] # org has other members → must transfer ownership first
|
||||
auto_delete = [] # user is sole member → safe to cascade-delete automatically
|
||||
|
||||
for membership in owned_memberships:
|
||||
org = membership.organization
|
||||
if org.deleted_at is not None:
|
||||
continue
|
||||
member_count = org.get_member_count()
|
||||
if member_count > 1:
|
||||
blocked_orgs.append(org.name)
|
||||
transfer_needed.append(org.name)
|
||||
else:
|
||||
auto_delete.append(org)
|
||||
|
||||
if blocked_orgs:
|
||||
names = ", ".join(f'"{n}"' for n in blocked_orgs)
|
||||
# Hard block: user owns orgs with other members — must transfer first.
|
||||
if transfer_needed:
|
||||
names = ", ".join(f'"{n}"' for n in transfer_needed)
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"You are the sole owner of {len(blocked_orgs)} organization"
|
||||
f"{'s' if len(blocked_orgs) > 1 else ''}: {names}. "
|
||||
"Transfer ownership or delete those organizations before deleting your account."
|
||||
f"You are the owner of {len(transfer_needed)} organization"
|
||||
f"{'s' if len(transfer_needed) > 1 else ''} that still "
|
||||
f"{'have' if len(transfer_needed) > 1 else 'has'} other members "
|
||||
f"({names}). Transfer ownership to another member first."
|
||||
),
|
||||
status=409,
|
||||
error_type="USER_IS_SOLE_OWNER",
|
||||
error_details={"organizations": blocked_orgs},
|
||||
error_details={"transfer_ownership": transfer_needed},
|
||||
)
|
||||
|
||||
# Auto-delete any sole-member orgs so no orphaned org rows can ever be left behind.
|
||||
for org in auto_delete:
|
||||
OrganizationService.force_delete_organization(org, user_id=user.id)
|
||||
|
||||
UserService.delete_user(user, soft=True)
|
||||
|
||||
return api_response(
|
||||
|
||||
Reference in New Issue
Block a user