feat(admin): 完善审计访问上下文与后台布局
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""add access context to audit logs
|
||||
|
||||
Revision ID: 20260709_01
|
||||
Revises: 20260630_02
|
||||
Create Date: 2026-07-09 10:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260709_01"
|
||||
down_revision: Union[str, None] = "20260630_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
|
||||
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
|
||||
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
|
||||
for column in (
|
||||
"build_commit",
|
||||
"build_channel",
|
||||
"client_platform",
|
||||
"client_version",
|
||||
"client_type",
|
||||
"user_agent",
|
||||
"client_ip",
|
||||
):
|
||||
op.drop_column("audit_logs", column)
|
||||
@@ -1,13 +1,18 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,6 +45,10 @@ async def list_audit_logs(
|
||||
entity_id: uuid.UUID | None = None,
|
||||
action: str | None = None,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
client_ip: str | None = None,
|
||||
client_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -51,10 +60,51 @@ async def list_audit_logs(
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
operator_id=operator_id,
|
||||
client_ip=client_ip,
|
||||
client_type=client_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return list(logs)
|
||||
operator_ids = {log.operator_id for log in logs}
|
||||
operator_map: dict[uuid.UUID, User] = {}
|
||||
if operator_ids:
|
||||
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
|
||||
operator_map = {user.id: user for user in result.scalars().all()}
|
||||
|
||||
items: list[AuditLogRead] = []
|
||||
for log in logs:
|
||||
ip_location = resolve_ip_location(log.client_ip)
|
||||
operator = operator_map.get(log.operator_id)
|
||||
items.append(
|
||||
AuditLogRead(
|
||||
id=log.id,
|
||||
study_id=log.study_id,
|
||||
entity_type=log.entity_type,
|
||||
entity_id=log.entity_id,
|
||||
action=log.action,
|
||||
detail=log.detail,
|
||||
operator_id=log.operator_id,
|
||||
operator_name=operator.full_name if operator else None,
|
||||
operator_email=operator.email if operator else None,
|
||||
operator_role=log.operator_role,
|
||||
client_ip=log.client_ip,
|
||||
ip_location=ip_location.location,
|
||||
ip_country=ip_location.country,
|
||||
ip_province=ip_location.province,
|
||||
ip_city=ip_location.city,
|
||||
ip_isp=ip_location.isp,
|
||||
user_agent=log.user_agent,
|
||||
client_type=log.client_type,
|
||||
client_version=log.client_version,
|
||||
client_platform=log.client_platform,
|
||||
build_channel=log.build_channel,
|
||||
build_commit=log.build_commit,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Request-scoped metadata used by server-side audit writers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuditContext:
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
client_type: str | None = None
|
||||
client_version: str | None = None
|
||||
client_platform: str | None = None
|
||||
build_channel: str | None = None
|
||||
build_commit: str | None = None
|
||||
|
||||
|
||||
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
"request_audit_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
_SENSITIVE_TEXT_PATTERN = re.compile(
|
||||
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
|
||||
)
|
||||
|
||||
|
||||
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned[:max_length]
|
||||
|
||||
|
||||
def resolve_client_ip(request: Any) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return _clean_header_value(real_ip.strip(), 45)
|
||||
return _clean_header_value(request.client.host if request.client else None, 45)
|
||||
|
||||
|
||||
def build_request_audit_context(request: Any) -> RequestAuditContext:
|
||||
headers = request.headers
|
||||
return RequestAuditContext(
|
||||
client_ip=resolve_client_ip(request),
|
||||
user_agent=_clean_header_value(headers.get("user-agent"), 500),
|
||||
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16),
|
||||
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
|
||||
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
|
||||
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
|
||||
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
|
||||
)
|
||||
|
||||
|
||||
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
|
||||
return _request_audit_context.set(context)
|
||||
|
||||
|
||||
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
|
||||
_request_audit_context.reset(token)
|
||||
|
||||
|
||||
def get_request_audit_context() -> RequestAuditContext | None:
|
||||
return _request_audit_context.get()
|
||||
@@ -4,8 +4,10 @@ import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.request_context import get_request_audit_context
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
@@ -19,8 +21,16 @@ async def log_action(
|
||||
detail: str | None,
|
||||
operator_id: uuid.UUID,
|
||||
operator_role: str,
|
||||
client_ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
client_type: str | None = None,
|
||||
client_version: str | None = None,
|
||||
client_platform: str | None = None,
|
||||
build_channel: str | None = None,
|
||||
build_commit: str | None = None,
|
||||
auto_commit: bool = True,
|
||||
) -> AuditLog:
|
||||
request_context = get_request_audit_context()
|
||||
log = AuditLog(
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
@@ -29,6 +39,13 @@ async def log_action(
|
||||
detail=detail,
|
||||
operator_id=operator_id,
|
||||
operator_role=operator_role,
|
||||
client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None),
|
||||
user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None),
|
||||
client_type=client_type if client_type is not None else (request_context.client_type if request_context else None),
|
||||
client_version=client_version if client_version is not None else (request_context.client_version if request_context else None),
|
||||
client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None),
|
||||
build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None),
|
||||
build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None),
|
||||
)
|
||||
db.add(log)
|
||||
if auto_commit:
|
||||
@@ -47,6 +64,10 @@ async def list_logs(
|
||||
entity_id: uuid.UUID | None = None,
|
||||
action: str | None = None,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
client_ip: str | None = None,
|
||||
client_type: str | None = None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[AuditLog]:
|
||||
@@ -59,6 +80,24 @@ async def list_logs(
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
if operator_id:
|
||||
stmt = stmt.where(AuditLog.operator_id == operator_id)
|
||||
if client_ip:
|
||||
stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%"))
|
||||
if client_type:
|
||||
normalized_client_type = client_type.strip().lower()
|
||||
if normalized_client_type == "unknown":
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
AuditLog.client_type.is_(None),
|
||||
AuditLog.client_type == "",
|
||||
~AuditLog.client_type.in_(("web", "desktop")),
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(AuditLog.client_type == normalized_client_type)
|
||||
if start_time:
|
||||
stmt = stmt.where(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
stmt = stmt.where(AuditLog.created_at <= end_time)
|
||||
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
+58
-57
@@ -25,6 +25,12 @@ from app.services.security_access_log_writer import (
|
||||
stop_security_log_writer,
|
||||
)
|
||||
from app.core.security import decode_token
|
||||
from app.core.request_context import (
|
||||
build_request_audit_context,
|
||||
reset_request_audit_context,
|
||||
resolve_client_ip,
|
||||
set_request_audit_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ctms.setup_config")
|
||||
UUID_RE = re.compile(
|
||||
@@ -137,60 +143,65 @@ def create_app() -> FastAPI:
|
||||
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
|
||||
should_security_log = path.startswith("/api/")
|
||||
started_at = time.perf_counter()
|
||||
audit_context = build_request_audit_context(request)
|
||||
audit_context_token = set_request_audit_context(audit_context)
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
setup_config_stats[key] += 1
|
||||
logger.exception(
|
||||
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
logger.exception(
|
||||
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
return response
|
||||
return response
|
||||
finally:
|
||||
reset_request_audit_context(audit_context_token)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -240,16 +251,6 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _resolve_client_ip(request) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
||||
authorization = request.headers.get("authorization") or ""
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
@@ -276,7 +277,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
"path": path,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"client_ip": resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"client_type": request.headers.get("x-ctms-client-type"),
|
||||
"client_version": request.headers.get("x-ctms-client-version"),
|
||||
|
||||
@@ -13,7 +13,12 @@ from app.db.base_class import Base
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),)
|
||||
__table_args__ = (
|
||||
Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),
|
||||
Index("ix_audit_logs_operator_created", "operator_id", "created_at"),
|
||||
Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"),
|
||||
Index("ix_audit_logs_client_source_created", "client_type", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
||||
@@ -23,4 +28,11 @@ class AuditLog(Base):
|
||||
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: Optional[uuid.UUID] = None
|
||||
entity_type: str
|
||||
entity_id: Optional[uuid.UUID] = None
|
||||
action: str
|
||||
detail: Optional[str]
|
||||
operator_id: uuid.UUID
|
||||
operator_name: Optional[str] = None
|
||||
operator_email: Optional[str] = None
|
||||
operator_role: str
|
||||
client_ip: Optional[str] = None
|
||||
ip_location: str = ""
|
||||
ip_country: str = ""
|
||||
ip_province: str = ""
|
||||
ip_city: str = ""
|
||||
ip_isp: str = ""
|
||||
user_agent: Optional[str] = None
|
||||
client_type: Optional[str] = None
|
||||
client_version: Optional[str] = None
|
||||
client_platform: Optional[str] = None
|
||||
build_channel: Optional[str] = None
|
||||
build_commit: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.request_context import (
|
||||
RequestAuditContext,
|
||||
reset_request_audit_context,
|
||||
set_request_audit_context,
|
||||
)
|
||||
from app.crud.audit import log_action
|
||||
|
||||
|
||||
class FakeAsyncSession:
|
||||
def __init__(self) -> None:
|
||||
self.added = None
|
||||
self.flushed = False
|
||||
|
||||
def add(self, value) -> None:
|
||||
self.added = value
|
||||
|
||||
async def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_action_captures_request_access_context():
|
||||
context_token = set_request_audit_context(
|
||||
RequestAuditContext(
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="curl/8.1.2",
|
||||
client_type="desktop",
|
||||
client_version="1.2.3",
|
||||
client_platform="macos",
|
||||
build_channel="release",
|
||||
build_commit="abcdef1",
|
||||
)
|
||||
)
|
||||
db = FakeAsyncSession()
|
||||
try:
|
||||
log = await log_action(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
entity_type="subject",
|
||||
entity_id=uuid.uuid4(),
|
||||
action="UPDATE_SUBJECT",
|
||||
detail=None,
|
||||
operator_id=uuid.uuid4(),
|
||||
operator_role="PM",
|
||||
auto_commit=False,
|
||||
)
|
||||
finally:
|
||||
reset_request_audit_context(context_token)
|
||||
|
||||
assert db.added is log
|
||||
assert db.flushed is True
|
||||
assert log.client_ip == "203.0.113.10"
|
||||
assert log.user_agent == "curl/8.1.2"
|
||||
assert log.client_type == "desktop"
|
||||
assert log.client_version == "1.2.3"
|
||||
assert log.client_platform == "macos"
|
||||
assert log.build_channel == "release"
|
||||
assert log.build_commit == "abcdef1"
|
||||
Reference in New Issue
Block a user