236 lines
7.9 KiB
Python
236 lines
7.9 KiB
Python
"""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,
|
||
)
|