"""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: email email: str, # Entry: password password: str, # Entry: role role: str, ) -> User: """Create a new user. Email is the unique login identifier. ``username`` is derived from ``email`` — it's kept internally (JWT, audit logs) but always mirrors email, never a separate value. Raises DuplicateEntityError if a user with this email already exists. Raises BusinessRuleViolation if role is invalid. Does not commit; the router handles that. """ # Assign existing = db.query(User).filter(User.email == email).first() existing = db.query(User).filter(User.email == email).first() # Check: existing if existing: # Raise DuplicateEntityError raise DuplicateEntityError("User", "email", email) # 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=email, # 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.email == 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"))) # Email is the unique login identifier — enforce uniqueness on change, # and keep username (the internal id) mirrored to it. if update_data.get("email") is not None and update_data["email"] != user.email: existing = db.query(User).filter(User.email == update_data["email"], User.id != user_id).first() if existing: raise DuplicateEntityError("User", "email", update_data["email"]) update_data["username"] = update_data["email"] # 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