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
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).
279 lines
10 KiB
Python
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
|