Files
sciagent/be0/src/admin_audit_routes.py
T
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

236 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: now7d"),
] = 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,
)