Files
Aegis/backend/app/schemas/user.py
T
kitos 4dea19cae9
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): passwordless user creation via emailed set-password link, display full name instead of username
Admins now create users with a name + email (no password) and role;
a Send Email action (per-user, also reusable for resets) issues a
one-time token and POSTs it to an admin-configurable webhook URL —
intended for a Power Automate flow that delivers the actual email.
The old admin-sets-the-password flow is kept server-side
(LegacyUserCreateWithPassword) but no longer wired into the UI.

Every user-facing surface (top bar, sidebar, user management, audit
log, assignee pickers, dispute notifications) now shows full_name
instead of username, falling back to username when unset.

Also fixes long attack_procedure/expected_detection/suggested_text
text overflowing its container in the template review panels and
Red/Blue team fields (missing break-words on whitespace-pre-wrap
blocks).
2026-07-17 16:58:25 +02:00

338 lines
11 KiB
Python

"""Pydantic schemas for User management endpoints."""
# Import re
import re
# Import uuid
import uuid
# Import datetime from datetime
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ── Username policy ─────────────────────────────────────────────────
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,50}$")
# Assign _RESERVED_USERNAMES = frozenset({
_RESERVED_USERNAMES = frozenset({
# Literal argument value
"admin", "root", "system", "api", "null", "undefined",
# Literal argument value
"administrator", "superuser", "aegis",
})
# Define function _validate_username
def _validate_username(username: str) -> str:
"""Validate username format and reject reserved names.
Args:
username (str): The username string to validate.
Returns:
str: The validated username, unchanged.
"""
# Check: not _USERNAME_RE.match(username)
if not _USERNAME_RE.match(username):
# Raise ValueError
raise ValueError(
# Literal argument value
"Username must be 3-50 characters, containing only "
# Literal argument value
"letters, digits, underscores, and hyphens"
)
# Check: username.lower() in _RESERVED_USERNAMES
if username.lower() in _RESERVED_USERNAMES:
# Raise ValueError
raise ValueError(f"Username '{username}' is reserved")
# Return username
return username
# ── Password policy ─────────────────────────────────────────────────
_MIN_PASSWORD_LENGTH = 12
# Assign _PASSWORD_RULES = [
_PASSWORD_RULES: list[tuple[str, str]] = [
(r"[A-Z]", "at least one uppercase letter"),
(r"[a-z]", "at least one lowercase letter"),
(r"[0-9]", "at least one digit"),
(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?`~]", "at least one special character"),
]
# Define function _validate_password_strength
def _validate_password_strength(password: str) -> str:
"""Check that *password* satisfies the complexity policy.
Rules:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
Args:
password (str): The plaintext password to validate.
Returns:
str: The validated password, unchanged.
"""
# Assign errors = []
errors: list[str] = []
# Check: len(password) < _MIN_PASSWORD_LENGTH
if len(password) < _MIN_PASSWORD_LENGTH:
# Call errors.append()
errors.append(f"must be at least {_MIN_PASSWORD_LENGTH} characters long")
# Iterate over _PASSWORD_RULES
for pattern, description in _PASSWORD_RULES:
# Check: not re.search(pattern, password)
if not re.search(pattern, password):
# Call errors.append()
errors.append(description)
# Check: errors
if errors:
# Raise ValueError
raise ValueError(
# Literal argument value
"Password does not meet complexity requirements: " + "; ".join(errors)
)
# Return password
return password
# ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel):
"""Payload for creating a new user.
No password is set here — the admin creates the user with just a
name/email/role, then uses "Send Email" to let the user set their own
password via a one-time link. ``username`` is no longer admin-supplied;
the service layer derives it from ``email`` since it's still needed
internally as the login identifier, but it is never shown in the UI.
"""
full_name: str
email: str
# Assign role = "viewer"
role: str = "viewer"
@field_validator("full_name")
@classmethod
def full_name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Full name is required")
return v
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
v = v.strip()
if "@" not in v or v.startswith("@") or v.endswith("@"):
raise ValueError("A valid email address is required")
return v
# ── Legacy direct-password creation (hidden from the UI, kept for a
# possible future revert — see UserCreate above for the active path) ──
class LegacyUserCreateWithPassword(BaseModel):
"""The old admin-sets-the-password creation payload. Not wired to any
active endpoint; retained only so this path can be restored quickly."""
username: str
email: str | None = None
password: str
role: str = "viewer"
@field_validator("username")
@classmethod
def username_format(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
# ── Update ──────────────────────────────────────────────────────────
class UserUpdate(BaseModel):
"""Payload for partially updating an existing user.
Every field is optional so callers send only what changed. ``password``
is intentionally still supported server-side (see
LegacyUserCreateWithPassword) but the current UI never sends it —
password changes go through the email-a-set-password-link flow instead.
"""
# Assign email = None
email: str | None = None
full_name: str | None = None
# Assign role = None
role: str | None = None
# Assign is_active = None
is_active: bool | None = None
# Assign password = None
password: str | None = None
# Apply the @field_validator decorator
@field_validator("password")
# Apply the @classmethod decorator
@classmethod
# Define function password_strength
def password_strength(cls, v: str | None) -> str | None:
"""Validate the password field when provided.
Args:
v (str | None): Raw password value, or ``None`` when unchanged.
Returns:
str | None: The validated password, or ``None``.
"""
# Check: v is not None
if v is not None:
# Return _validate_password_strength(v)
return _validate_password_strength(v)
# Return v
return v
# ── Read (full) ─────────────────────────────────────────────────────
class PasswordChange(BaseModel):
"""Payload for changing the current user's password."""
# current_password: str
current_password: str
# new_password: str
new_password: str
# Apply the @field_validator decorator
@field_validator("new_password")
# Apply the @classmethod decorator
@classmethod
# Define function new_password_strength
def new_password_strength(cls, v: str) -> str:
"""Validate the new password against the complexity policy.
Args:
v (str): Raw new-password value from the request body.
Returns:
str: The validated new password.
"""
# Return _validate_password_strength(v)
return _validate_password_strength(v)
class UserPreferencesUpdate(BaseModel):
"""Payload for updating current user's notification preferences and Jira/Tempo settings."""
notification_preferences: dict | None = None
jira_account_id: str | None = None
# Personal Jira API token (Atlassian token) — write-only.
# Set to empty string "" to clear the token.
jira_api_token: str | None = None
# Atlassian email for Jira auth — overrides account email.
# Set to empty string "" to clear (falls back to account email).
jira_email: str | None = None
# Personal Tempo API token — write-only.
# Set to empty string "" to clear the token.
tempo_api_token: str | None = None
class UserOut(BaseModel):
"""Complete representation returned by the API."""
# id: uuid.UUID
id: uuid.UUID
# username: str
username: str
full_name: str | None = None
# Assign email = None
email: str | None = None
# role: str
role: str
# is_active: bool
is_active: bool
# Assign must_change_password = True
must_change_password: bool = True
# Assign created_at = None
created_at: datetime | None = None
# Assign last_login = None
last_login: datetime | None = None
notification_preferences: dict | None = None
jira_account_id: str | None = None
jira_email: str | None = None
# Read from ORM but NEVER exposed in responses — used only to derive *_token_set flags.
jira_api_token: str | None = Field(default=None, exclude=True)
tempo_api_token: str | None = Field(default=None, exclude=True)
# True when the user has the respective token stored.
jira_token_set: bool = False
tempo_token_set: bool = False
# Assign model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def _derive_token_set_flags(self) -> "UserOut":
"""Derive *_token_set booleans from the (excluded) raw token fields.
Uses @model_validator(mode='after') so Pydantic's Rust core calls it
during FastAPI response serialisation — model_validate() overrides are
bypassed by FastAPI's __pydantic_validator__.validate_python() path.
"""
self.jira_token_set = bool(self.jira_api_token)
self.tempo_token_set = bool(self.tempo_api_token)
return self
class UserOperatorOut(BaseModel):
"""Minimal user representation for lead/admin operator-picker dropdowns."""
id: uuid.UUID
username: str
full_name: str | None = None
role: str
model_config = ConfigDict(from_attributes=True)
# ── Password setup / reset (via emailed one-time token) ─────────────
class SendPasswordEmailOut(BaseModel):
"""Response after an admin triggers a set-password email for a user."""
detail: str
class SetPasswordTokenValidateOut(BaseModel):
"""Response for checking a set-password token before rendering the form."""
valid: bool
full_name: str | None = None
class SetPasswordRequest(BaseModel):
"""Payload for completing a password setup/reset via a one-time token."""
token: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)