"""Admin-only audit log query API (GET /api/v1/admin/audit).""" from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone from typing import Annotated, Any, Optional from fastapi import APIRouter, Header, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import asc, desc, func, select from src.auth_jwt import decode_access_token_user_id, decode_bearer_token from src.initiative_db.engine import get_session, init_engine, is_postgres_enabled from src.initiative_db.models import AuditEvent router = APIRouter(prefix="/admin", tags=["admin-audit"]) def _jwt_role_strings(authorization: str | None) -> list[str]: p = decode_bearer_token(authorization) if not p: return [] r = p.get("roles") if isinstance(r, list): return [str(x) for x in r] return [] def require_admin_uid(authorization: str | None) -> uuid.UUID: uid = decode_access_token_user_id(authorization) if uid is None: raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") if "admin" not in _jwt_role_strings(authorization): raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") return uid class AuditEventListItem(BaseModel): model_config = {"from_attributes": True} id: int occurred_at: datetime actor_user_id: Optional[uuid.UUID] = None actor_email: str actor_role: str action: str entity_type: str entity_id: Optional[str] = None metadata: dict[str, Any] = Field(default_factory=dict) request_id: Optional[uuid.UUID] = None has_before: bool = False has_after: bool = False class AuditListResponse(BaseModel): items: list[AuditEventListItem] total: int page: int page_size: int class AuditEventDetail(BaseModel): id: int occurred_at: datetime actor_user_id: Optional[uuid.UUID] = None actor_email: str actor_role: str action: str entity_type: str entity_id: Optional[str] = None before: Optional[dict[str, Any]] = None after: Optional[dict[str, Any]] = None metadata: dict[str, Any] = Field(default_factory=dict) request_id: Optional[uuid.UUID] = None _AUDIT_ACTIONS = frozenset( {"create", "read", "update", "delete", "login", "logout", "login_failed"} ) def _parse_sort(sort: str) -> bool: """True when sorting occurred_at ascending.""" s = (sort or "occurred_at:desc").strip().lower() if ":" in s: col_name, direction = s.split(":", 1) else: col_name, direction = s, "desc" if col_name != "occurred_at": raise HTTPException(status_code=400, detail='sort chỉ hỗ trợ occurred_at (+ asc|desc)') return direction in ("asc", "ascending", "old", "older") def _where_audit( *, from_ts: datetime, to_ts: datetime, actor_user_id: Optional[uuid.UUID], actor_email: Optional[str], entity_type: Optional[str], entity_id: Optional[str], actions: Optional[list[str]], request_id: Optional[uuid.UUID], ): parts = [ AuditEvent.occurred_at >= from_ts, AuditEvent.occurred_at <= to_ts, ] if actor_user_id is not None: parts.append(AuditEvent.actor_user_id == actor_user_id) if actor_email: parts.append(AuditEvent.actor_email == actor_email.strip().lower()) if entity_type: parts.append(AuditEvent.entity_type == entity_type.strip()) if entity_id is not None and entity_id.strip() != "": parts.append(AuditEvent.entity_id == entity_id.strip()) if actions: parts.append(AuditEvent.action.in_(actions)) if request_id is not None: parts.append(AuditEvent.request_id == request_id) return parts @router.get("/audit", response_model=AuditListResponse) async def list_audit_events( authorization: Annotated[str | None, Header()] = None, from_: Annotated[ Optional[datetime], Query(alias="from", description="Inclusive lower bound (UTC). Default: now−7d"), ] = None, to: Annotated[ Optional[datetime], Query(description="Inclusive upper bound (UTC). Default: now"), ] = None, actor_user_id: Optional[uuid.UUID] = None, actor_email: Optional[str] = None, entity_type: Optional[str] = None, entity_id: Optional[str] = None, action: Optional[str] = Query( None, description="Comma-separated audit_action values" ), request_id: Optional[uuid.UUID] = None, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), sort: str = Query("occurred_at:desc", description='e.g. "occurred_at:desc"'), ): require_admin_uid(authorization) if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.") await init_engine() now = datetime.now(timezone.utc) end = to or now start = from_ or (end - timedelta(days=7)) if end < start: raise HTTPException(status_code=400, detail="Tham số to phải >= from") actions_list: Optional[list[str]] = None if action: raw = [a.strip().lower() for a in action.split(",") if a.strip()] bad = [a for a in raw if a not in _AUDIT_ACTIONS] if bad: raise HTTPException(status_code=400, detail=f"action không hợp lệ: {bad}") actions_list = raw asc_order = _parse_sort(sort) offset = (page - 1) * page_size wh = _where_audit( from_ts=start, to_ts=end, actor_user_id=actor_user_id, actor_email=actor_email, entity_type=entity_type, entity_id=entity_id, actions=actions_list, request_id=request_id, ) async with get_session() as session: cnt_stmt = select(func.count()).select_from(AuditEvent).where(*wh) total = int((await session.execute(cnt_stmt)).scalar_one()) order_clause = asc(AuditEvent.occurred_at) if asc_order else desc(AuditEvent.occurred_at) stmt = select(AuditEvent).where(*wh).order_by(order_clause).limit(page_size).offset(offset) rows = (await session.execute(stmt)).scalars().all() items = [ AuditEventListItem( id=r.id, occurred_at=r.occurred_at, actor_user_id=r.actor_user_id, actor_email=r.actor_email, actor_role=r.actor_role, action=str(r.action), entity_type=r.entity_type, entity_id=r.entity_id, metadata=dict(r.metadata_) if isinstance(r.metadata_, dict) else {}, request_id=r.request_id, has_before=r.before is not None, has_after=r.after is not None, ) for r in rows ] return AuditListResponse(items=items, total=total, page=page, page_size=page_size) @router.get("/audit/{event_id:int}", response_model=AuditEventDetail) async def get_audit_event_detail( event_id: int, authorization: Annotated[str | None, Header()] = None, ): require_admin_uid(authorization) if not is_postgres_enabled(): raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.") await init_engine() async with get_session() as session: row = await session.get(AuditEvent, event_id) if row is None: raise HTTPException(status_code=404, detail="Không có sự kiện audit.") return AuditEventDetail( id=row.id, occurred_at=row.occurred_at, actor_user_id=row.actor_user_id, actor_email=row.actor_email, actor_role=row.actor_role, action=str(row.action), entity_type=row.entity_type, entity_id=row.entity_id, before=dict(row.before) if isinstance(row.before, dict) else row.before, after=dict(row.after) if isinstance(row.after, dict) else row.after, metadata=dict(row.metadata_) if isinstance(row.metadata_, dict) else {}, request_id=row.request_id, )