Kyle Pope d8bdae8ec3 Implement multi-user RBAC: database, auth, routing, admin API (Phases 1-6)
Phase 1: Add role, mfa_enforce_pending, must_change_password to users table.
Create system_config (singleton) and audit_log tables. Migration 026.

Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects,
calendars, people, locations, event_templates, ntfy_sent) with 4-step
nullable→backfill→FK→NOT NULL pattern. Migrations 027-034.

Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add
MFA enforcement token serializer with distinct salt, rewrite auth router
with require_role() factory and registration endpoint.

Phase 4: Scope all 12 routers by user_id, fix dependency type bugs,
bound weather cache (SEC-15), multi-user ntfy dispatch.

Phase 5: Create admin router (14 endpoints), admin schemas, audit
service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With.

Phase 6: Update frontend types, useAuth hook (role/isAdmin/register),
App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header).

Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05,
SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:06:25 +08:00

134 lines
3.5 KiB
Python

"""
Admin API schemas — Pydantic v2.
All admin-facing request/response shapes live here to keep the admin router
clean and testable in isolation.
"""
import re
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength
# ---------------------------------------------------------------------------
# User list / detail
# ---------------------------------------------------------------------------
class UserListItem(BaseModel):
id: int
username: str
role: str
is_active: bool
last_login_at: Optional[datetime] = None
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserListResponse(BaseModel):
users: list[UserListItem]
total: int
class UserDetailResponse(UserListItem):
active_sessions: int
# ---------------------------------------------------------------------------
# Mutating user requests
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
"""Admin-created user — allows role selection (unlike public RegisterRequest)."""
model_config = ConfigDict(extra="forbid")
username: str
password: str
role: Literal["admin", "standard", "public_event_manager"] = "standard"
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
class UpdateUserRoleRequest(BaseModel):
role: Literal["admin", "standard", "public_event_manager"]
class ToggleActiveRequest(BaseModel):
is_active: bool
class ToggleMfaEnforceRequest(BaseModel):
enforce: bool
# ---------------------------------------------------------------------------
# System config
# ---------------------------------------------------------------------------
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
model_config = ConfigDict(from_attributes=True)
class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------
class AdminDashboardResponse(BaseModel):
total_users: int
active_users: int
admin_count: int
active_sessions: int
mfa_adoption_rate: float
recent_logins: list[dict]
recent_audit_entries: list[dict]
# ---------------------------------------------------------------------------
# Password reset
# ---------------------------------------------------------------------------
class ResetPasswordResponse(BaseModel):
message: str
temporary_password: str
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
class AuditLogEntry(BaseModel):
id: int
actor_username: Optional[str] = None
target_username: Optional[str] = None
action: str
detail: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class AuditLogResponse(BaseModel):
entries: list[AuditLogEntry]
total: int