sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user