Files
Aegis/backend/app/services/user_service.py
T
kitos 504dfc52f5
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(auth): make email the unique login identifier
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.

- User.email is now unique + NOT NULL (migration b067 backfills any
  missing/blank email from username first, so existing rows — notably
  the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
  authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
  create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
  duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
  docker-compose.prod.yml) for the initial admin; falls back to an
  email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
  operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
  skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
  'username' — that's the OAuth2PasswordRequestForm spec, not the value).
2026-07-23 14:39:36 +02:00

279 lines
10 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: 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