8985eeaa03
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
Admin used to have to click 'Send Email' separately after creating a passwordless user. Now the first set-password email is sent right away as part of creation — best-effort: if the webhook isn't configured or the send fails, the user is still created successfully, and the existing 'Send Email' button remains available to retry with proper error surfacing.
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""User management router (admin only)."""
|
|
|
|
# Import logging
|
|
import logging
|
|
|
|
# Import uuid
|
|
import uuid
|
|
|
|
# Import APIRouter, Depends, status from fastapi
|
|
from fastapi import APIRouter, Depends, status
|
|
|
|
# Import Session from sqlalchemy.orm
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Import get_db from app.database
|
|
from app.database import get_db
|
|
|
|
# Import require_role from app.dependencies.auth
|
|
from app.dependencies.auth import require_role, require_any_role_strict
|
|
|
|
# Import UnitOfWork from app.domain.unit_of_work
|
|
from app.domain.unit_of_work import UnitOfWork
|
|
from app.domain.errors import BusinessRuleViolation
|
|
|
|
# Import User from app.models.user
|
|
from app.models.user import User
|
|
from app.dependencies.auth import get_current_user
|
|
from app.schemas.user import (
|
|
UserCreate,
|
|
UserUpdate,
|
|
UserOut,
|
|
UserPreferencesUpdate,
|
|
UserOperatorOut,
|
|
SendPasswordEmailOut,
|
|
SwitchRoleRequest,
|
|
)
|
|
from app.services.audit_service import log_action
|
|
|
|
# Import from app.services.user_service
|
|
from app.services.user_service import (
|
|
create_user_without_password,
|
|
delete_user,
|
|
get_user_or_raise,
|
|
list_users,
|
|
switch_active_role,
|
|
update_user,
|
|
)
|
|
from app.services.password_setup_service import send_password_setup_email
|
|
|
|
# Assign router = APIRouter(prefix="/users", tags=["users"])
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /users/me/preferences — update current user preferences
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/me/preferences", response_model=UserOut)
|
|
def update_my_preferences(
|
|
payload: UserPreferencesUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Update the current user's notification preferences, Jira account ID and Jira API token.
|
|
|
|
Send ``jira_api_token: ""`` to clear a previously stored token.
|
|
The token is never returned in any response.
|
|
"""
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
if field in ("jira_api_token", "jira_email", "tempo_api_token"):
|
|
# Empty string means "clear the value"
|
|
setattr(current_user, field, value if value else None)
|
|
else:
|
|
setattr(current_user, field, value)
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
return current_user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /users/me — get current user's own profile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
def get_me(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return the currently authenticated user's profile."""
|
|
return current_user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /users/me/switch-role — swap the caller's active role
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/me/switch-role", response_model=UserOut)
|
|
def switch_my_role(
|
|
payload: SwitchRoleRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> UserOut:
|
|
"""Switch the caller's active role to one of their granted extra_roles.
|
|
|
|
Roles never mix — this changes which single role is active, it never
|
|
grants permissions from more than one role at once. Takes effect
|
|
immediately since every permission check reads ``role`` fresh from the
|
|
DB on each request.
|
|
"""
|
|
with UnitOfWork(db) as uow:
|
|
user = switch_active_role(db, current_user, payload.role)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="switch_role",
|
|
entity_type="user",
|
|
entity_id=current_user.id,
|
|
details={"new_role": payload.role},
|
|
)
|
|
uow.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /users — list all users
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("", response_model=list[UserOut])
|
|
# Define function list_users_route
|
|
def list_users_route(
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> list[UserOut]:
|
|
"""Return a list of all users. **Requires admin role.**."""
|
|
# Return list_users(db)
|
|
return list_users(db)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /users — create a new user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
# Define function create_user_route
|
|
def create_user_route(
|
|
# Entry: payload
|
|
payload: UserCreate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> UserOut:
|
|
"""Create a new user. **Requires admin role.**.
|
|
|
|
No password is set — use ``POST /users/{id}/send-password-email``
|
|
afterward to let the user set their own via a one-time link.
|
|
"""
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
user = create_user_without_password(
|
|
db,
|
|
full_name=payload.full_name,
|
|
email=payload.email,
|
|
role=payload.role,
|
|
)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="create_user",
|
|
# Keyword argument: entity_type
|
|
entity_type="user",
|
|
# Keyword argument: entity_id
|
|
entity_id=user.id,
|
|
# Keyword argument: details
|
|
details={"full_name": user.full_name, "email": user.email, "role": user.role},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(user)
|
|
|
|
# Send the set-password email right away — best-effort. If the webhook
|
|
# isn't configured or rejects the request, the user is still created
|
|
# successfully; the admin can retry via the "Send Email" button, which
|
|
# surfaces the failure properly (unlike this automatic first attempt).
|
|
try:
|
|
with UnitOfWork(db) as uow:
|
|
send_password_setup_email(db, user)
|
|
uow.commit()
|
|
except Exception:
|
|
logger.warning("Automatic set-password email failed for new user %s", user.id, exc_info=True)
|
|
|
|
from app.services.notification_service import notify_roles_by_email
|
|
notify_roles_by_email(
|
|
db, roles=["admin"],
|
|
preference_key="email_on_new_users",
|
|
subject=f"New User Created: {user.full_name or user.email}",
|
|
message=(
|
|
f'A new user account was created: {user.full_name or user.email} '
|
|
f"({user.email}), role: {user.role}."
|
|
),
|
|
exclude_user_id=current_user.id,
|
|
)
|
|
|
|
# Return user
|
|
return user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /users/operators — minimal operator/lead list for assignment pickers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/operators", response_model=list[UserOperatorOut])
|
|
def list_operators_route(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
|
) -> list[UserOperatorOut]:
|
|
"""Return active red/blue operators and leads, for lead/manager assignment pickers.
|
|
|
|
Not reachable by admin — admin administers the site, leads/managers
|
|
coordinate operators, and admin cannot itself be assigned as an
|
|
operator either. Returns only id/username/role — no emails or
|
|
tokens — since this is reachable by non-admin leads.
|
|
"""
|
|
return (
|
|
db.query(User)
|
|
.filter(
|
|
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]),
|
|
User.is_active.is_(True),
|
|
)
|
|
.order_by(User.username)
|
|
.all()
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /users/{id} — get a single user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{user_id}", response_model=UserOut)
|
|
# Define function get_user
|
|
def get_user(
|
|
# Entry: user_id
|
|
user_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> UserOut:
|
|
"""Return a single user by ID. **Requires admin role.**."""
|
|
# Return get_user_or_raise(db, user_id)
|
|
return get_user_or_raise(db, user_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /users/{id} — update a user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{user_id}", response_model=UserOut)
|
|
# Define function update_user_route
|
|
def update_user_route(
|
|
# Entry: user_id
|
|
user_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: UserUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> UserOut:
|
|
"""Update one or more fields of an existing user. **Requires admin role.**."""
|
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
|
|
# An admin can never strip their own admin permission — otherwise a
|
|
# careless self-edit (or a compromised session) could lock every admin
|
|
# out of the platform with no way back in.
|
|
if user_id == current_user.id and current_user.role == "admin":
|
|
final_role = update_data.get("role", current_user.role)
|
|
final_extra_roles = update_data.get("extra_roles", current_user.extra_roles or [])
|
|
if "admin" not in {final_role, *(final_extra_roles or [])}:
|
|
raise BusinessRuleViolation("You cannot remove your own admin permission.")
|
|
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign user = update_user(db, user_id, **update_data)
|
|
user = update_user(db, user_id, **update_data)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_user",
|
|
# Keyword argument: entity_type
|
|
entity_type="user",
|
|
# Keyword argument: entity_id
|
|
entity_id=user.id,
|
|
# Keyword argument: details
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(user)
|
|
|
|
# Return user
|
|
return user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /users/{id} — permanently delete a user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_user_route(
|
|
user_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> None:
|
|
"""Permanently delete a user. **Requires admin role.**.
|
|
|
|
Only users with no activity footprint (no tests, evidence, worklogs,
|
|
audit entries, etc.) can be hard-deleted — anyone else must be
|
|
deactivated instead, to preserve the audit trail.
|
|
"""
|
|
with UnitOfWork(db) as uow:
|
|
delete_user(db, user_id, current_user.id)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="delete_user",
|
|
entity_type="user",
|
|
entity_id=user_id,
|
|
details={},
|
|
)
|
|
uow.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /users/{id}/send-password-email — set-password / reset link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut)
|
|
def send_password_email_route(
|
|
user_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> SendPasswordEmailOut:
|
|
"""Email a one-time set-password link to a user. **Requires admin role.**.
|
|
|
|
Works for both a freshly-created passwordless user and an existing
|
|
one whose password needs resetting — same link, same flow either way.
|
|
"""
|
|
user = get_user_or_raise(db, user_id)
|
|
with UnitOfWork(db) as uow:
|
|
send_password_setup_email(db, user)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="send_password_setup_email",
|
|
entity_type="user",
|
|
entity_id=user.id,
|
|
details={},
|
|
)
|
|
uow.commit()
|
|
return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")
|