Added soft deletes to all deletion functions and added deleted_at filters as required

This commit is contained in:
2026-04-22 17:27:49 +09:30
parent 33a7fdac59
commit eb2fc6c8b3
18 changed files with 64 additions and 57 deletions
+4 -6
View File
@@ -21,7 +21,7 @@ def admin_list_app_providers():
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
PROVIDERS = [{"id": "google", "name": "Google"}, {"id": "github", "name": "GitHub"}, {"id": "microsoft", "name": "Microsoft"}]
db_configs = {c.provider_type: c for c in ApplicationProviderConfig.query.all()}
db_configs = {c.provider_type: c for c in ApplicationProviderConfig.query.filter_by(deleted_at=None).all()}
result = []
for p in PROVIDERS:
@@ -64,7 +64,7 @@ def admin_configure_app_provider(provider: str):
if not client_id:
return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR")
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider, deleted_at=None).first()
if cfg:
cfg.client_id = client_id
if client_secret:
@@ -90,7 +90,6 @@ def admin_delete_app_provider(provider: str):
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
@@ -100,10 +99,9 @@ def admin_delete_app_provider(provider: str):
if not admin_memberships:
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider, deleted_at=None).first()
if not cfg:
return api_response(success=False, message=f"Provider '{provider}' is not configured", status=404, error_type="NOT_FOUND")
db.session.delete(cfg)
db.session.commit()
cfg.delete()
return api_response(message=f"{provider.capitalize()} OAuth provider configuration removed")
+3 -2
View File
@@ -174,6 +174,7 @@ def select_organization():
auth_method = AuthenticationMethod.query.filter_by(
method_type=state_record.provider_type,
deleted_at=None,
).order_by(AuthenticationMethod.created_at.desc()).first()
if not auth_method:
@@ -181,11 +182,11 @@ def select_organization():
user = auth_method.user
org = Organization.query.get(organization_id)
org = Organization.query.filter_by(id=organization_id, deleted_at=None).first()
if not org:
return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id).first()
member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id, deleted_at=None).first()
if not member:
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN")
@@ -14,13 +14,13 @@ from gatehouse_app.api.v1.external_auth._helpers import get_provider_type, _get_
def list_providers():
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()}
app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True, deleted_at=None).all()}
user_orgs = g.current_user.get_organizations()
org_configs = {}
if user_orgs:
organization_id = user_orgs[0].id
org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id).all()
org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id, deleted_at=None).all()
org_configs = {c.provider_type.lower(): c for c in org_level}
def provider_info(provider_id, name):
@@ -50,11 +50,11 @@ def get_provider_config(provider: str):
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
organization_id = user_orgs[0].id
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first()
if not config:
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
@@ -74,7 +74,7 @@ def create_or_update_provider_config(provider: str):
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
organization_id = user_orgs[0].id
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
@@ -85,7 +85,7 @@ def create_or_update_provider_config(provider: str):
if not client_id:
return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR")
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first()
is_new = config is None
if config:
@@ -137,11 +137,11 @@ def delete_provider_config(provider: str):
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
organization_id = user_orgs[0].id
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first()
if not config:
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
+2 -2
View File
@@ -819,9 +819,9 @@ def oidc_register():
org_id = data.get("organization_id")
if org_id:
organization = Organization.query.get(org_id)
organization = Organization.query.filter_by(id=org_id, deleted_at=None).first()
else:
organization = Organization.query.filter_by(is_active=True).first()
organization = Organization.query.filter_by(is_active=True, deleted_at=None).first()
if not organization:
organization = Organization(
@@ -158,7 +158,7 @@ def send_mfa_reminder(org_id, user_id):
if not user:
return api_response(success=False, message="User not found", status=404)
compliance = MfaPolicyCompliance.query.filter_by(user_id=user_id, organization_id=org_id).first()
compliance = MfaPolicyCompliance.query.filter_by(user_id=user_id, organization_id=org_id, deleted_at=None).first()
policy = OrganizationSecurityPolicy.query.filter_by(organization_id=org_id).first()
if compliance and policy and compliance.deadline_at:
+1 -1
View File
@@ -68,7 +68,7 @@ def sign_certificate():
)
allowed_principal_names = set()
memberships = OrganizationMember.query.filter_by(user_id=user_id).all()
memberships = OrganizationMember.query.filter_by(user_id=user_id, deleted_at=None).all()
for om in memberships:
org = om.organization
if not org or org.deleted_at is not None:
@@ -374,7 +374,7 @@ class OAuthState(BaseModel):
def cleanup_expired(cls) -> None:
"""Remove expired OAuth states."""
now = datetime.now(timezone.utc)
cls.query.filter(cls.expires_at < now).delete()
cls.query.filter(cls.expires_at < now).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False)
db.session.commit()
def to_dict(self, exclude=None):
@@ -32,7 +32,8 @@ class EmailVerificationToken(BaseModel):
Any existing unused tokens for this user are invalidated first.
"""
cls.query.filter_by(user_id=user_id, used_at=None).delete()
now = datetime.now(timezone.utc)
cls.query.filter_by(user_id=user_id, used_at=None).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False)
db.session.flush()
token_value = secrets.token_urlsafe(48)
@@ -33,7 +33,8 @@ class PasswordResetToken(BaseModel):
Any existing unused tokens for this user are invalidated first.
"""
# Invalidate any existing unused tokens for this user
cls.query.filter_by(user_id=user_id, used_at=None).delete()
now = datetime.now(timezone.utc)
cls.query.filter_by(user_id=user_id, used_at=None).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False)
db.session.flush()
token_value = secrets.token_urlsafe(48)
+5 -6
View File
@@ -36,9 +36,9 @@ class AuthService:
Raises:
EmailAlreadyExistsError: If email is already registered
"""
# Check if email already exists
existing_user = User.query.filter_by(email=email.lower()).first()
if existing_user and existing_user.deleted_at is None:
# Check if email already exists
existing_user = User.query.filter_by(email=email.lower(), deleted_at=None).first()
if existing_user:
raise EmailAlreadyExistsError()
# Create user
@@ -280,12 +280,11 @@ class AuthService:
raise ConflictError("TOTP is already enabled for this account")
# Clean up any existing unverified TOTP enrollment attempts
# Use hard delete for unverified methods since they're incomplete enrollment attempts
# Soft delete for unverified methods since they're incomplete enrollment attempts
existing_totp_method = user.get_totp_method()
if existing_totp_method and not existing_totp_method.verified:
logger.debug(f"Removing existing unverified TOTP method for user {user.id}")
db.session.delete(existing_totp_method) # Hard delete - unverified methods are temporary
db.session.commit() # Commit to ensure deletion before creating new record
existing_totp_method.delete(soft=True) # Soft delete - unverified methods are temporary
# Generate TOTP secret
secret = TOTPService.generate_secret()
+6 -6
View File
@@ -50,8 +50,8 @@ class BillingService:
if not plan:
raise ValueError("Plan not found")
# Check if subscription already exists
existing = Subscription.query.filter_by(organization_id=organization_id).first()
# Check if subscription already exists
existing = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first()
if existing:
raise ValueError("Organization already has a subscription")
@@ -88,7 +88,7 @@ class BillingService:
Returns:
Updated subscription
"""
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first()
if not subscription:
raise ValueError("No subscription found for organization")
@@ -111,7 +111,7 @@ class BillingService:
Returns:
Updated subscription
"""
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first()
if not subscription:
raise ValueError("No subscription found for organization")
@@ -132,7 +132,7 @@ class BillingService:
Returns:
Updated subscription
"""
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first()
if not subscription:
raise ValueError("No subscription found for organization")
@@ -158,7 +158,7 @@ class BillingService:
Returns:
Overage calculation with details
"""
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first()
if not subscription:
return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0}
@@ -42,7 +42,7 @@ class ExternalAuthService:
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
app_config = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type_str
provider_type=provider_type_str, deleted_at=None
).first()
if not app_config:
@@ -64,6 +64,7 @@ class ExternalAuthService:
org_override_obj = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id,
provider_type=provider_type_str,
deleted_at=None,
).first()
if org_override_obj and not org_override_obj.is_enabled:
@@ -14,7 +14,7 @@ def create_app_provider_config(
**kwargs,
) -> ApplicationProviderConfig:
existing = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type
provider_type=provider_type, deleted_at=None
).first()
if existing:
@@ -51,7 +51,7 @@ def update_app_provider_config(
**updates,
) -> ApplicationProviderConfig:
config = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type
provider_type=provider_type, deleted_at=None
).first()
if not config:
@@ -90,7 +90,7 @@ def update_app_provider_config(
def get_app_provider_config(provider_type: str) -> ApplicationProviderConfig:
config = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type
provider_type=provider_type, deleted_at=None
).first()
if not config:
@@ -104,13 +104,13 @@ def get_app_provider_config(provider_type: str) -> ApplicationProviderConfig:
def list_app_provider_configs() -> list:
configs = ApplicationProviderConfig.query.all()
configs = ApplicationProviderConfig.query.filter_by(deleted_at=None).all()
return [config.to_dict() for config in configs]
def delete_app_provider_config(provider_type: str) -> bool:
config = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type
provider_type=provider_type, deleted_at=None
).first()
if not config:
@@ -219,10 +219,11 @@ def authenticate_with_provider(
auth_method = AuthenticationMethod.query.filter_by(
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
deleted_at=None,
).first()
if not auth_method:
existing_user = User.query.filter_by(email=user_info["email"]).first()
existing_user = User.query.filter_by(email=user_info["email"], deleted_at=None).first()
if existing_user:
AuditService.log_external_auth_login_failed(
@@ -286,12 +287,13 @@ def unlink_provider(
auth_method = AuthenticationMethod.query.filter_by(
user_id=user_id,
method_type=provider_type,
deleted_at=None,
).first()
if not auth_method:
raise ExternalAuthError("Provider not linked", "PROVIDER_NOT_LINKED", 400)
other_methods = AuthenticationMethod.query.filter_by(user_id=user_id).count()
other_methods = AuthenticationMethod.query.filter_by(user_id=user_id, deleted_at=None).count()
if other_methods <= 1:
raise ExternalAuthError(
"Cannot unlink the last authentication method",
@@ -16,7 +16,7 @@ def create_org_provider_override(
**kwargs,
) -> OrganizationProviderOverride:
app_config = ApplicationProviderConfig.query.filter_by(
provider_type=provider_type
provider_type=provider_type, deleted_at=None
).first()
if not app_config:
@@ -29,6 +29,7 @@ def create_org_provider_override(
existing = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id,
provider_type=provider_type,
deleted_at=None,
).first()
if existing:
@@ -69,6 +70,7 @@ def update_org_provider_override(
override = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id,
provider_type=provider_type,
deleted_at=None,
).first()
if not override:
@@ -110,6 +112,7 @@ def get_org_provider_override(
override = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id,
provider_type=provider_type,
deleted_at=None,
).first()
if not override:
@@ -124,7 +127,7 @@ def get_org_provider_override(
def list_org_provider_overrides(organization_id: str) -> list:
overrides = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id
organization_id=organization_id, deleted_at=None
).all()
return [override.to_dict() for override in overrides]
@@ -133,6 +136,7 @@ def delete_org_provider_override(organization_id: str, provider_type: str) -> bo
override = OrganizationProviderOverride.query.filter_by(
organization_id=organization_id,
provider_type=provider_type,
deleted_at=None,
).first()
if not override:
@@ -1024,17 +1024,17 @@ def revoke_membership_soft(
def hard_delete_membership(membership_id: str) -> None:
"""Hard delete a membership after ZeroTier has been cleaned up.
"""Soft delete a membership after ZeroTier has been cleaned up.
Called by the reconciliation job after successfully removing the member
from the ZeroTier controller. This is the final DB cleanup step.
from the ZeroTier controller. This marks the membership as deleted.
"""
membership = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.id == membership_id,
).first()
if not membership:
logger.warning(f"[hard_delete_membership] Membership {membership_id} not found, skipping.")
logger.warning(f"[hard_delete_membership] Membership {membership_id} not found or already deleted, skipping.")
return
device = Device.query.get(membership.device_id)
@@ -1048,7 +1048,7 @@ def hard_delete_membership(membership_id: str) -> None:
except Exception as exc:
logger.warning(f"[hard_delete_membership] ZT delete failed for {device.node_id}: {exc}")
db.session.delete(membership)
membership.delete(soft=True)
db.session.commit()
AuditService.log_action(
@@ -1061,6 +1061,6 @@ def hard_delete_membership(membership_id: str) -> None:
"device_node_id": device.node_id if device else None,
"network_id": network.zerotier_network_id if network else None,
},
description=f"Membership hard-deleted: device {device.node_id if device else 'unknown'} from network",
description=f"Membership deleted: device {device.node_id if device else 'unknown'} from network",
success=True,
)
@@ -111,7 +111,7 @@ def handle_register_callback(
access_token=tokens["access_token"],
)
existing_user = User.query.filter_by(email=user_info["email"]).first()
existing_user = User.query.filter_by(email=user_info["email"], deleted_at=None).first()
if existing_user:
raise OAuthFlowError(
f"An account with email {user_info['email']} already exists. "
@@ -283,9 +283,9 @@ def reconcile_deleted_memberships() -> dict:
if not device or not network:
logger.warning(
f"[Reconciliation] Membership {membership.id}: missing "
f"{'device' if not device else 'network'}hard-deleting record only."
f"{'device' if not device else 'network'}soft-deleting record only."
)
db.session.delete(membership)
membership.delete(soft=True)
db.session.commit()
results["deleted"] += 1
continue
@@ -304,20 +304,20 @@ def reconcile_deleted_memberships() -> dict:
except Exception as zt_exc:
logger.warning(
f"[Reconciliation] ZT delete failed for node {node_id} "
f"on {network_label}: {zt_exc} — proceeding with DB hard-delete."
f"on {network_label}: {zt_exc} — proceeding with DB soft-delete."
)
db.session.delete(membership)
membership.delete(soft=True)
db.session.commit()
results["deleted"] += 1
logger.debug(
f"[Reconciliation] Hard-deleted membership {membership.id} "
f"[Reconciliation] Soft-deleted membership {membership.id} "
f"(node={node_id}, network={network_label})."
)
except Exception as exc:
logger.error(
f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}",
f"[Reconciliation] Failed to soft-delete membership {membership.id}: {exc}",
exc_info=True,
)
results["errors"] += 1