From 7480e9d62bac793f6d613a1dce77a12e94ccce75 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Fri, 10 Apr 2026 00:39:44 +0930 Subject: [PATCH] fix(user): filter out soft-deleted memberships and organizations Add get_active_memberships() method to User model that filters out soft-deleted memberships and memberships of deleted organizations. Update all usages of organization_memberships to use this method, ensuring consistent handling of soft-deleted records across the codebase. Also add deleted_at filters to CA queries in SSH helpers. --- gatehouse_app/api/v1/ssh/_helpers.py | 12 ++++++++---- gatehouse_app/api/v1/users/admin.py | 2 +- gatehouse_app/models/user/user.py | 19 +++++++++++++++++-- gatehouse_app/services/oidc/userinfo.py | 4 ++-- gatehouse_app/services/oidc_token_service.py | 4 ++-- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/gatehouse_app/api/v1/ssh/_helpers.py b/gatehouse_app/api/v1/ssh/_helpers.py index 4e244b1..a221e90 100644 --- a/gatehouse_app/api/v1/ssh/_helpers.py +++ b/gatehouse_app/api/v1/ssh/_helpers.py @@ -14,13 +14,17 @@ _logger = logging.getLogger(__name__) def _get_org_ca_for_user(user, ca_type: str = "user"): try: from gatehouse_app.models.ssh_ca.ca import CA, CaType - org_ids = [m.organization_id for m in user.organization_memberships] + + org_ids = [m.organization_id for m in user.get_active_memberships()] + if not org_ids: return None + return CA.query.filter( CA.organization_id.in_(org_ids), CA.ca_type == CaType(ca_type), - CA.is_active == True, # noqa: E712 + CA.is_active == True, + CA.deleted_at.is_(None), ).first() except Exception: return None @@ -34,7 +38,7 @@ def _get_or_create_system_ca(): import os try: - existing = CA.query.filter_by(name="system-config-ca").first() + existing = CA.query.filter_by(name="system-config-ca", deleted_at=None).first() if existing: return existing @@ -60,7 +64,7 @@ def _get_or_create_system_ca(): fingerprint = compute_ssh_fingerprint(pub_key) - existing_by_fp = CA.query.filter_by(fingerprint=fingerprint).first() + existing_by_fp = CA.query.filter_by(fingerprint=fingerprint, deleted_at=None).first() if existing_by_fp: return existing_by_fp diff --git a/gatehouse_app/api/v1/users/admin.py b/gatehouse_app/api/v1/users/admin.py index e0cec01..94cf12c 100644 --- a/gatehouse_app/api/v1/users/admin.py +++ b/gatehouse_app/api/v1/users/admin.py @@ -329,7 +329,7 @@ def admin_hard_delete_user(user_id): if target.id == caller.id: return api_response(success=False, message="Cannot delete your own account via this endpoint.", status=400, error_type="BAD_REQUEST") - target_org_ids = {m.organization_id for m in target.organization_memberships} + target_org_ids = {m.organization_id for m in target.get_active_memberships()} admin_in_shared_org = OrganizationMember.query.filter( OrganizationMember.user_id == caller.id, OrganizationMember.organization_id.in_(target_org_ids), diff --git a/gatehouse_app/models/user/user.py b/gatehouse_app/models/user/user.py index c2fb1c8..0236811 100644 --- a/gatehouse_app/models/user/user.py +++ b/gatehouse_app/models/user/user.py @@ -116,9 +116,24 @@ class User(BaseModel): is not None ) + def get_active_memberships(self): + """Get active (non-deleted) organization memberships with active organizations. + + Returns: + List of OrganizationMember instances where: + - membership.deleted_at is None + - organization exists and organization.deleted_at is None + """ + return [ + m for m in self.organization_memberships + if m.deleted_at is None + and m.organization + and m.organization.deleted_at is None + ] + def get_organizations(self): - """Get all organizations the user is a member of.""" - return [membership.organization for membership in self.organization_memberships] + """Get all active organizations the user is a member of.""" + return [membership.organization for membership in self.get_active_memberships()] def has_totp_enabled(self) -> bool: """Check if user has TOTP enabled and verified. diff --git a/gatehouse_app/services/oidc/userinfo.py b/gatehouse_app/services/oidc/userinfo.py index 2e46705..7a81e0b 100644 --- a/gatehouse_app/services/oidc/userinfo.py +++ b/gatehouse_app/services/oidc/userinfo.py @@ -55,9 +55,9 @@ def get_userinfo(access_token: str, validate_access_token_fn) -> Dict: def _get_user_roles(user: User) -> list: roles = [] - if not user or not user.organization_memberships: + if not user: return roles - for member in user.organization_memberships: + for member in user.get_active_memberships(): roles.append({ "organization_id": str(member.organization_id), "role": member.role.value, diff --git a/gatehouse_app/services/oidc_token_service.py b/gatehouse_app/services/oidc_token_service.py index 5605d5f..d32841e 100644 --- a/gatehouse_app/services/oidc_token_service.py +++ b/gatehouse_app/services/oidc_token_service.py @@ -324,8 +324,8 @@ class OIDCTokenService: List of role objects with organization_id and role """ roles = [] - if user and user.organization_memberships: - for member in user.organization_memberships: + if user: + for member in user.get_active_memberships(): roles.append({ "organization_id": str(member.organization_id), "role": member.role.value