"""Pydantic schemas for User management endpoints.""" import re import uuid from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, field_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 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 # Never return the raw token — just indicate whether it is configured. jira_token_set: bool = False model_config = ConfigDict(from_attributes=True) @classmethod def model_validate(cls, obj, *args, **kwargs): # type: ignore[override] instance = super().model_validate(obj, *args, **kwargs) # Derive jira_token_set from the ORM object without exposing the value if hasattr(obj, "jira_api_token"): instance.jira_token_set = bool(obj.jira_api_token) return instance