Files
Aegis/backend/app/services/user_service.py
T
kitos d932134975
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
feat(users): admin can permanently delete users, fix set-password redirect bug
- 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.
2026-07-23 08:58:17 +02:00

270 lines
9.8 KiB
Python

"""User management service — framework-agnostic CRUD for users.
Uses domain exceptions from app.domain.errors. The router handles
HTTP concerns, auth, audit logging, and commit.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import secrets
import secrets
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import hash_password from app.auth
from app.auth import hash_password
# Import from app.domain.errors
from app.domain.errors import (
BusinessRuleViolation,
DuplicateEntityError,
EntityNotFoundError,
)
# Import User from app.models.user
from app.models.user import User
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
# Define function list_users
def list_users(db: Session) -> list[User]:
"""Return a list of all users ordered by username."""
# Return db.query(User).order_by(User.username).all()
return db.query(User).order_by(User.username).all()
# Define function create_user
def create_user(
# Entry: db
db: Session,
*,
# Entry: username
username: str,
# Entry: email
email: str | None,
# Entry: password
password: str,
# Entry: role
role: str,
) -> User:
"""Create a new user.
Raises DuplicateEntityError if username already exists.
Raises BusinessRuleViolation if role is invalid.
Does not commit; the router handles that.
"""
# Assign existing = db.query(User).filter(User.username == username).first()
existing = db.query(User).filter(User.username == username).first()
# Check: existing
if existing:
# Raise DuplicateEntityError
raise DuplicateEntityError("User", "username", username)
# Check: role not in VALID_ROLES
if role not in VALID_ROLES:
# Raise BusinessRuleViolation
raise BusinessRuleViolation(
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
# Assign user = User(
user = User(
# Keyword argument: username
username=username,
# Keyword argument: email
email=email,
# Keyword argument: hashed_password
hashed_password=hash_password(password),
# Keyword argument: role
role=role,
)
# Stage new record(s) for database insertion
db.add(user)
# Return user
return user
def create_user_without_password(db: Session, *, full_name: str, email: str, role: str) -> User:
"""Create a new user without a password — they set their own via an
admin-triggered, emailed one-time link (see password_setup_service).
``username`` is derived from ``email`` since it's still needed
internally as the login identifier, but it's never surfaced in the UI.
Raises DuplicateEntityError if a user with this email/username already
exists. Raises BusinessRuleViolation if role is invalid. Does not
commit; the router handles that.
"""
existing = db.query(User).filter(User.username == email).first()
if existing:
raise DuplicateEntityError("User", "email", email)
if role not in VALID_ROLES:
raise BusinessRuleViolation(
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
user = User(
username=email,
email=email,
full_name=full_name,
# Unusable random password — the user can only ever get in via the
# emailed set-password link, never by guessing/being told a value.
hashed_password=hash_password(secrets.token_urlsafe(32)),
role=role,
must_change_password=True,
)
db.add(user)
return user
# Define function get_user_or_raise
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
"""Return a user by ID or raise EntityNotFoundError."""
# Assign user = db.query(User).filter(User.id == user_id).first()
user = db.query(User).filter(User.id == user_id).first()
# Check: user is None
if user is None:
# Raise EntityNotFoundError
raise EntityNotFoundError("User", str(user_id))
# Return user
return user
# Define function update_user
def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
"""Update one or more fields of an existing user.
Raises EntityNotFoundError if user does not exist.
Raises BusinessRuleViolation if role is invalid.
Handles 'password' by hashing and storing as 'hashed_password'.
Does not commit; the router handles that.
"""
# Assign user = get_user_or_raise(db, user_id)
user = get_user_or_raise(db, user_id)
# Assign update_data = dict(fields)
update_data = dict(fields)
# Check: "role" in update_data and update_data["role"] not in VALID_ROLES
if "role" in update_data and update_data["role"] not in VALID_ROLES:
# Raise BusinessRuleViolation
raise BusinessRuleViolation(
f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
if update_data.get("extra_roles") is not None:
invalid = [r for r in update_data["extra_roles"] if r not in VALID_ROLES]
if invalid:
raise BusinessRuleViolation(
f"Invalid role(s) {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
# Check: "password" in update_data
if "password" in update_data:
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
# Iterate over update_data.items()
for field, value in update_data.items():
# Call setattr()
setattr(user, field, value)
# Return 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.
Roles never mix — the user acts under exactly one role at a time, this
just changes which one. Raises BusinessRuleViolation if new_role isn't
one this user has been granted. Does not commit; caller commits.
"""
available = {user.role, *(user.extra_roles or [])}
if new_role not in available:
raise BusinessRuleViolation(f"Role '{new_role}' is not available to this user")
if new_role == user.role:
return user
old_role = user.role
remaining = [r for r in (user.extra_roles or []) if r != new_role]
remaining.append(old_role)
user.role = new_role
user.extra_roles = remaining
return user