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
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user