Files
Aegis/backend/app/schemas/user.py
Kitos a4a2adccee
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
feat(phase-39): role-based access control overhaul + forced password change
- Add must_change_password field to User model with migration b023

- Add POST /auth/change-password endpoint with password policy validation

- Add require_password_changed dependency to block requests until password is changed

- Add ChangePasswordModal with live password policy checklist (forced on first login)

- Show password policy in CreateUserModal and EditUserModal

- Fix backend permissions: tests, campaigns, templates, reports, evidence, worklogs

- red_tech/blue_tech: execute only, cannot create tests/campaigns/templates

- red_lead/blue_lead: create/edit tests/campaigns/templates, generate reports, no system access

- viewer: read-only everywhere, can generate reports

- Fix frontend role checks across TestDetailPage, TestDetailHeader, TeamTabs, TestsPage, CampaignsPage, CampaignDetailPage, Sidebar
2026-02-18 10:37:02 +01:00

111 lines
3.3 KiB
Python

"""Pydantic schemas for User management endpoints."""
import re
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
# ── 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("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 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
model_config = ConfigDict(from_attributes=True)