Files
Aegis/backend/app/schemas/user.py
kitos 48a936d426
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
fix(jira): use model_validator(after) for jira_token_set + timeout on test
FastAPI uses __pydantic_validator__.validate_python() which bypasses
model_validate() overrides. Switch to @model_validator(mode='after')
which the Pydantic Rust core always calls, so jira_token_set is now
correctly derived from the excluded jira_api_token field.

Also add a 10s timeout to the jira-test endpoint and better error
messages (the Atlassian library's "Expecting value" JSON error was
ambiguous).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:36:35 +02:00

168 lines
5.7 KiB
Python

"""Pydantic schemas for User management endpoints."""
import re
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
# ── Username policy ─────────────────────────────────────────────────
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,50}$")
_RESERVED_USERNAMES = frozenset({
"admin", "root", "system", "api", "null", "undefined",
"administrator", "superuser", "aegis",
})
def _validate_username(username: str) -> str:
"""Validate username format and reject reserved names."""
if not _USERNAME_RE.match(username):
raise ValueError(
"Username must be 3-50 characters, containing only "
"letters, digits, underscores, and hyphens"
)
if username.lower() in _RESERVED_USERNAMES:
raise ValueError(f"Username '{username}' is reserved")
return username
# ── Password policy ─────────────────────────────────────────────────
_MIN_PASSWORD_LENGTH = 12
_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"),
]
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
"""
errors: list[str] = []
if len(password) < _MIN_PASSWORD_LENGTH:
errors.append(f"must be at least {_MIN_PASSWORD_LENGTH} characters long")
for pattern, description in _PASSWORD_RULES:
if not re.search(pattern, password):
errors.append(description)
if errors:
raise ValueError(
"Password does not meet complexity requirements: " + "; ".join(errors)
)
return password
# ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel):
"""Payload for creating a new user."""
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."""
email: str | None = None
role: str | None = None
is_active: bool | None = None
password: str | None = None
@field_validator("password")
@classmethod
def password_strength(cls, v: str | None) -> str | None:
if v is not None:
return _validate_password_strength(v)
return v
# ── Read (full) ─────────────────────────────────────────────────────
class PasswordChange(BaseModel):
"""Payload for changing the current user's password."""
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
class UserPreferencesUpdate(BaseModel):
"""Payload for updating current user's notification preferences and Jira settings."""
notification_preferences: dict | None = None
jira_account_id: str | None = None
# Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest.
# 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
class UserOut(BaseModel):
"""Complete representation returned by the API."""
id: uuid.UUID
username: str
email: str | None = None
role: str
is_active: bool
must_change_password: bool = True
created_at: datetime | None = 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 jira_token_set.
jira_api_token: str | None = Field(default=None, exclude=True)
# True when the user has a personal Atlassian token stored.
jira_token_set: bool = False
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def _derive_jira_token_set(self) -> "UserOut":
"""Set jira_token_set from the (excluded) jira_api_token field.
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)
return self