feat(users): admin can permanently delete users, fix set-password redirect bug
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- New DELETE /users/{id}: hard-deletes a user only if they have zero
  activity footprint (no tests, evidence, worklogs, audit entries, Jira
  links, procedure/template suggestions, reviewed imports) — otherwise
  rejects with a clear message to deactivate instead, to keep the audit
  trail intact. Cleans up the user's own notifications/password-setup
  tokens as part of deletion. Self-delete is blocked. Wired to a trash
  icon in the Users page with a confirm dialog.
- Registered the EvaluationImport model in app/models/__init__.py — it
  was missing, so its table never existed in the SQLite test DB; only
  surfaced once the new delete-user footprint check queried it.
- Fixed a redirect bug: the axios response interceptor's on-401 redirect
  to /login didn't exempt /set-password, so the auth provider's routine
  mount-time /auth/me check (401 for a logged-out visitor) bounced anyone
  opening their emailed set-password link away before they could use it.
This commit is contained in:
kitos
2026-07-23 08:58:17 +02:00
parent 545a84b137
commit d932134975
7 changed files with 242 additions and 2 deletions
+67
View File
@@ -180,6 +180,73 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
return user
def delete_user(db: Session, user_id: uuid.UUID, actor_id: uuid.UUID) -> None:
"""Permanently delete a user — only if they have no activity footprint.
Raises EntityNotFoundError if the user doesn't exist. Raises
BusinessRuleViolation if the caller is trying to delete themselves, or
if the user has any historical footprint (tests, evidence, worklogs,
audit entries, Jira links, procedure/template suggestions, reviewed
imports) — deleting those users would either violate FK constraints or
silently erase audit trail we're required to keep; deactivate them
instead. Notifications and pending password-setup tokens are personal-
only records and are cleaned up as part of the deletion.
Does not commit; caller commits.
"""
user = get_user_or_raise(db, user_id)
if user_id == actor_id:
raise BusinessRuleViolation("You cannot delete your own account.")
from app.models.audit import AuditLog
from app.models.evaluation_import import EvaluationImport
from app.models.evidence import Evidence
from app.models.jira_link import JiraLink
from app.models.notification import Notification
from app.models.password_setup_token import PasswordSetupToken
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.worklog import Worklog
has_footprint = any(
db.query(model).filter(condition).first() is not None
for model, condition in [
(
Test,
(Test.created_by == user_id)
| (Test.red_validated_by == user_id)
| (Test.blue_validated_by == user_id)
| (Test.remediation_assignee == user_id)
| (Test.red_tech_assignee == user_id)
| (Test.blue_tech_assignee == user_id)
| (Test.red_reviewer_assignee == user_id)
| (Test.blue_reviewer_assignee == user_id),
),
(Evidence, Evidence.uploaded_by == user_id),
(AuditLog, AuditLog.user_id == user_id),
(JiraLink, JiraLink.created_by == user_id),
(Worklog, Worklog.user_id == user_id),
(ProcedureSuggestion, (ProcedureSuggestion.submitted_by == user_id) | (ProcedureSuggestion.reviewed_by == user_id)),
(TemplateSuggestion, (TemplateSuggestion.submitted_by == user_id) | (TemplateSuggestion.reviewed_by == user_id)),
(TestRoundHistory, TestRoundHistory.reviewed_by == user_id),
(EvaluationImport, EvaluationImport.imported_by == user_id),
]
)
if has_footprint:
raise BusinessRuleViolation(
"This user has activity history (tests, evidence, worklogs, audit "
"entries, or similar) and can't be permanently deleted — "
"deactivate them instead to keep the audit trail intact."
)
db.query(Notification).filter(Notification.user_id == user_id).delete()
db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == user_id).delete()
db.delete(user)
def switch_active_role(db: Session, user: User, new_role: str) -> User:
"""Swap *user*'s currently-active role with one from their extra_roles.