d307039a41
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).
168 lines
5.7 KiB
Python
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
|