权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""add permission monitoring tables
|
||||
|
||||
Revision ID: 20260519_01
|
||||
Revises: 20260518_01
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260519_01"
|
||||
down_revision: Union[str, None] = "20260518_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"permission_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studies.id"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("endpoint_key", sa.String(100), nullable=False),
|
||||
sa.Column("role", sa.String(30), nullable=False),
|
||||
sa.Column("allowed", sa.Boolean(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_log_study_created", "permission_access_logs", ["study_id", "created_at"])
|
||||
op.create_index("ix_perm_log_user_created", "permission_access_logs", ["user_id", "created_at"])
|
||||
op.create_index("ix_perm_log_endpoint_created", "permission_access_logs", ["endpoint_key", "created_at"])
|
||||
op.create_index("ix_perm_log_created_at", "permission_access_logs", ["created_at"])
|
||||
op.create_index("ix_perm_log_allowed", "permission_access_logs", ["allowed", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"permission_metric_snapshots",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("allowed_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("denied_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("avg_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("max_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_misses", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_snapshot_bucket", "permission_metric_snapshots", ["bucket_time"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("permission_metric_snapshots")
|
||||
op.drop_table("permission_access_logs")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add security access logs
|
||||
|
||||
Revision ID: 20260520_01
|
||||
Revises: 20260519_01
|
||||
Create Date: 2026-05-20 10:40:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260520_01"
|
||||
down_revision: Union[str, None] = "20260519_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"security_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("method", sa.String(12), nullable=False),
|
||||
sa.Column("path", sa.String(500), nullable=False),
|
||||
sa.Column("status_code", sa.Integer(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("client_ip", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("auth_status", sa.String(30), nullable=False),
|
||||
sa.Column("user_identifier", sa.String(80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_security_log_created_at", "security_access_logs", ["created_at"])
|
||||
op.create_index("ix_security_log_ip_created", "security_access_logs", ["client_ip", "created_at"])
|
||||
op.create_index("ix_security_log_status_created", "security_access_logs", ["status_code", "created_at"])
|
||||
op.create_index("ix_security_log_auth_created", "security_access_logs", ["auth_status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("security_access_logs")
|
||||
@@ -16,6 +16,7 @@ from app.core.project_permissions import (
|
||||
get_missing_prerequisites,
|
||||
)
|
||||
from app.models.api_endpoint_registry import ApiEndpointRegistry
|
||||
from app.models.study import Study
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
|
||||
|
||||
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
@@ -24,6 +25,13 @@ router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
|
||||
|
||||
async def _get_configurable_roles(db: AsyncSession, study_id: uuid.UUID) -> list[str]:
|
||||
result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = result.scalar_one_or_none()
|
||||
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
|
||||
return list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles]))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations",
|
||||
summary="获取系统中所有权限操作",
|
||||
@@ -167,7 +175,7 @@ async def get_study_api_permissions(
|
||||
|
||||
# 构建返回格式(get_api_endpoint_permissions 已返回 {role: {key: {"allowed": bool}}})
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
@@ -203,10 +211,11 @@ async def update_study_api_permissions(
|
||||
}
|
||||
"""
|
||||
# 验证输入
|
||||
configurable_roles = set(await _get_configurable_roles(db, study_id))
|
||||
for role in payload.keys():
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
if role not in PROJECT_PERMISSION_ROLES:
|
||||
if role not in configurable_roles:
|
||||
raise ValueError(f"无效的角色: {role}")
|
||||
|
||||
# 替换权限配置
|
||||
@@ -216,7 +225,7 @@ async def update_study_api_permissions(
|
||||
permissions = await get_api_endpoint_permissions(db, study_id)
|
||||
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
"""权限系统监控API
|
||||
|
||||
提供权限系统的监控数据和告警信息。
|
||||
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.core.permission_monitor import evaluate_permission_system_health, get_permission_monitor
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.user import User
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 原有端点(保持兼容)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/metrics", status_code=status.HTTP_200_OK)
|
||||
async def get_permission_metrics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取权限系统指标
|
||||
|
||||
返回权限检查、缓存等运行指标。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
return monitor.get_metrics()
|
||||
|
||||
@@ -29,10 +41,6 @@ async def get_permission_metrics(
|
||||
async def get_cache_statistics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取缓存统计信息
|
||||
|
||||
返回缓存命中率、缓存项目数等信息。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
return monitor.get_cache_stats()
|
||||
|
||||
@@ -43,15 +51,6 @@ async def get_alerts(
|
||||
limit: int = 100,
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取告警列表
|
||||
|
||||
Args:
|
||||
level: 告警级别过滤(info, warning, error)
|
||||
limit: 返回的最大告警数
|
||||
|
||||
Returns:
|
||||
告警列表
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
alerts = monitor.get_alerts(level=level, limit=limit)
|
||||
return {
|
||||
@@ -59,15 +58,10 @@ async def get_alerts(
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
|
||||
async def reset_metrics(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""重置监控指标
|
||||
|
||||
清除所有累积的指标数据,重新开始统计。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
monitor.reset_metrics()
|
||||
return {"message": "指标已重置"}
|
||||
@@ -77,10 +71,6 @@ async def reset_metrics(
|
||||
async def clear_alerts(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""清除所有告警
|
||||
|
||||
删除所有累积的告警记录。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
monitor.clear_alerts()
|
||||
return {"message": "告警已清除"}
|
||||
@@ -90,12 +80,527 @@ async def clear_alerts(
|
||||
async def permission_system_health(
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""权限系统健康检查
|
||||
|
||||
返回权限系统的健康状态。
|
||||
"""
|
||||
monitor = get_permission_monitor()
|
||||
metrics = monitor.get_metrics()
|
||||
cache_stats = monitor.get_cache_stats()
|
||||
|
||||
return evaluate_permission_system_health(metrics, cache_stats)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:访问日志
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/access-logs", status_code=status.HTTP_200_OK)
|
||||
async def get_access_logs(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
study_id: Optional[uuid.UUID] = Query(None),
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
endpoint_key: Optional[str] = Query(None),
|
||||
role: Optional[str] = Query(None),
|
||||
allowed: Optional[bool] = Query(None),
|
||||
start_time: Optional[datetime] = Query(None),
|
||||
end_time: Optional[datetime] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
"""分页查询权限访问日志"""
|
||||
conditions = []
|
||||
if study_id:
|
||||
conditions.append(PermissionAccessLog.study_id == study_id)
|
||||
if user_id:
|
||||
conditions.append(PermissionAccessLog.user_id == user_id)
|
||||
if endpoint_key:
|
||||
conditions.append(PermissionAccessLog.endpoint_key == endpoint_key)
|
||||
if role:
|
||||
conditions.append(PermissionAccessLog.role == role)
|
||||
if allowed is not None:
|
||||
conditions.append(PermissionAccessLog.allowed == allowed)
|
||||
if start_time:
|
||||
conditions.append(PermissionAccessLog.created_at >= start_time)
|
||||
if end_time:
|
||||
conditions.append(PermissionAccessLog.created_at <= end_time)
|
||||
|
||||
query = (
|
||||
select(PermissionAccessLog, User.full_name)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
)
|
||||
count_query = select(func.count()).select_from(PermissionAccessLog)
|
||||
summary_query = select(
|
||||
func.count().label("total_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.user_id)).label("unique_user_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_elapsed_ms"),
|
||||
).select_from(PermissionAccessLog)
|
||||
user_stats_query = (
|
||||
select(
|
||||
PermissionAccessLog.user_id,
|
||||
User.full_name,
|
||||
PermissionAccessLog.role,
|
||||
PermissionAccessLog.ip_address.label("sample_ip_address"),
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
||||
)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
|
||||
.order_by(desc("total_count"), desc("last_seen_at"))
|
||||
.limit(10)
|
||||
)
|
||||
for condition in conditions:
|
||||
query = query.where(condition)
|
||||
count_query = count_query.where(condition)
|
||||
summary_query = summary_query.where(condition)
|
||||
user_stats_query = user_stats_query.where(condition)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
summary_result = await db.execute(summary_query)
|
||||
summary_row = summary_result.one()
|
||||
user_stats_result = await db.execute(user_stats_query)
|
||||
user_stats_rows = user_stats_result.all()
|
||||
|
||||
query = query.order_by(desc(PermissionAccessLog.created_at))
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for log, full_name in rows:
|
||||
ip_location = resolve_ip_location(log.ip_address)
|
||||
items.append(
|
||||
{
|
||||
"id": str(log.id),
|
||||
"study_id": str(log.study_id),
|
||||
"user_id": str(log.user_id),
|
||||
"user_name": full_name or "未知用户",
|
||||
"endpoint_key": log.endpoint_key,
|
||||
"role": log.role,
|
||||
"allowed": log.allowed,
|
||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||
"ip_address": log.ip_address,
|
||||
"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,
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
user_stats = []
|
||||
for stat in user_stats_rows:
|
||||
sample_location = resolve_ip_location(stat.sample_ip_address)
|
||||
user_stats.append(
|
||||
{
|
||||
"user_id": str(stat.user_id),
|
||||
"user_name": stat.full_name or "未知用户",
|
||||
"role": stat.role,
|
||||
"total_count": stat.total_count,
|
||||
"denied_count": stat.denied_count,
|
||||
"unique_ip_count": stat.unique_ip_count,
|
||||
"sample_ip_address": stat.sample_ip_address,
|
||||
"last_seen_at": stat.last_seen_at.isoformat() if stat.last_seen_at else None,
|
||||
"primary_location": sample_location.location,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total_count": summary_row.total_count,
|
||||
"unique_user_count": summary_row.unique_user_count,
|
||||
"unique_ip_count": summary_row.unique_ip_count,
|
||||
"denied_count": summary_row.denied_count,
|
||||
"avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2),
|
||||
},
|
||||
"user_stats": user_stats,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _security_account_label(auth_status: str, user_identifier: str | None, user_names: dict[str, str] | None = None) -> str:
|
||||
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||
return (user_names or {}).get(user_identifier) or user_identifier
|
||||
if auth_status == "INVALID_TOKEN":
|
||||
return "无效令牌"
|
||||
return "未知账号"
|
||||
|
||||
|
||||
@router.get("/security-logs", status_code=status.HTTP_200_OK)
|
||||
async def get_security_access_logs(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
status_min: Optional[int] = Query(None, ge=100, le=599),
|
||||
auth_status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
|
||||
conditions = []
|
||||
if status_min is not None:
|
||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||
if auth_status:
|
||||
conditions.append(SecurityAccessLog.auth_status == auth_status)
|
||||
|
||||
query = select(SecurityAccessLog)
|
||||
count_query = select(func.count()).select_from(SecurityAccessLog)
|
||||
summary_query = select(
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(SecurityAccessLog.auth_status == "ANONYMOUS").label("anonymous_count"),
|
||||
func.count().filter(SecurityAccessLog.auth_status == "INVALID_TOKEN").label("invalid_token_count"),
|
||||
func.count().filter(SecurityAccessLog.status_code >= 400).label("error_count"),
|
||||
).select_from(SecurityAccessLog)
|
||||
for condition in conditions:
|
||||
query = query.where(condition)
|
||||
count_query = count_query.where(condition)
|
||||
summary_query = summary_query.where(condition)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
summary_result = await db.execute(summary_query)
|
||||
result = await db.execute(
|
||||
query.order_by(desc(SecurityAccessLog.created_at))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
summary_row = summary_result.one()
|
||||
|
||||
logs = result.scalars().all()
|
||||
user_ids: list[uuid.UUID] = []
|
||||
for log in logs:
|
||||
if log.auth_status != "AUTHENTICATED" or not log.user_identifier:
|
||||
continue
|
||||
try:
|
||||
user_ids.append(uuid.UUID(log.user_identifier))
|
||||
except ValueError:
|
||||
continue
|
||||
user_names: dict[str, str] = {}
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids)))
|
||||
user_names = {str(row.id): row.full_name for row in users_result.all()}
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
items.append(
|
||||
{
|
||||
"id": str(log.id),
|
||||
"method": log.method,
|
||||
"path": log.path,
|
||||
"status_code": log.status_code,
|
||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||
"client_ip": log.client_ip,
|
||||
"user_agent": log.user_agent,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total_count": summary_row.total_count,
|
||||
"anonymous_count": summary_row.anonymous_count,
|
||||
"invalid_token_count": summary_row.invalid_token_count,
|
||||
"error_count": summary_row.error_count,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:趋势数据
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/trends", status_code=status.HTTP_200_OK)
|
||||
async def get_trends(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||||
) -> dict:
|
||||
"""获取趋势数据(从快照表或实时聚合)"""
|
||||
now = datetime.now(timezone.utc)
|
||||
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||||
start_time = now - period_map[period]
|
||||
|
||||
# 先尝试从快照表获取
|
||||
snapshot_query = (
|
||||
select(PermissionMetricSnapshot)
|
||||
.where(PermissionMetricSnapshot.bucket_time >= start_time)
|
||||
.order_by(PermissionMetricSnapshot.bucket_time)
|
||||
)
|
||||
result = await db.execute(snapshot_query)
|
||||
snapshots = result.scalars().all()
|
||||
|
||||
if snapshots:
|
||||
return {
|
||||
"period": period,
|
||||
"data_points": [
|
||||
{
|
||||
"bucket_time": s.bucket_time.isoformat(),
|
||||
"total_checks": s.total_checks,
|
||||
"allowed_checks": s.allowed_checks,
|
||||
"denied_checks": s.denied_checks,
|
||||
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
|
||||
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
|
||||
"cache_hits": s.cache_hits,
|
||||
"cache_misses": s.cache_misses,
|
||||
"cache_hit_rate": round(
|
||||
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
|
||||
) if (s.cache_hits + s.cache_misses) > 0 else 0,
|
||||
"error_count": s.error_count,
|
||||
}
|
||||
for s in snapshots
|
||||
],
|
||||
}
|
||||
|
||||
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
||||
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
|
||||
result = await db.execute(
|
||||
select(
|
||||
PermissionAccessLog.created_at,
|
||||
PermissionAccessLog.allowed,
|
||||
PermissionAccessLog.elapsed_ms,
|
||||
).where(PermissionAccessLog.created_at >= start_time)
|
||||
)
|
||||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
||||
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||||
)
|
||||
for created_at, allowed, elapsed_ms in result.all():
|
||||
bucket = _trend_bucket_time(created_at, period)
|
||||
buckets[bucket]["total"] += 1
|
||||
buckets[bucket]["allowed" if allowed else "denied"] += 1
|
||||
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
|
||||
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"data_points": [
|
||||
{
|
||||
"bucket_time": bucket.isoformat(),
|
||||
"total_checks": values["total"],
|
||||
"allowed_checks": values["allowed"],
|
||||
"denied_checks": values["denied"],
|
||||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
|
||||
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_hit_rate": 0,
|
||||
"error_count": 0,
|
||||
}
|
||||
for bucket, values in sorted(buckets.items())
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _trend_bucket_time(value: datetime, period: str) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
if period == "24h":
|
||||
return value.replace(minute=0, second=0, microsecond=0)
|
||||
if period == "7d":
|
||||
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
|
||||
return value.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:被拒绝最多的权限
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/top-denied", status_code=status.HTTP_200_OK)
|
||||
async def get_top_denied(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict:
|
||||
"""获取被拒绝最多的权限"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
query = (
|
||||
select(
|
||||
PermissionAccessLog.endpoint_key,
|
||||
PermissionAccessLog.role,
|
||||
func.count().label("denied_count"),
|
||||
)
|
||||
.where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.allowed.is_(False),
|
||||
)
|
||||
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
|
||||
.order_by(desc("denied_count"))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"items": [
|
||||
{
|
||||
"endpoint_key": row.endpoint_key,
|
||||
"role": row.role,
|
||||
"denied_count": row.denied_count,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:IP 属地统计
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
|
||||
async def get_ip_locations(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict:
|
||||
"""获取 IP 省市属地统计。"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
query = (
|
||||
select(
|
||||
PermissionAccessLog.ip_address,
|
||||
PermissionAccessLog.user_id,
|
||||
PermissionAccessLog.allowed,
|
||||
)
|
||||
.where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.ip_address.is_not(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
buckets: dict[tuple[str, str, str, str], dict] = {}
|
||||
all_ip_addresses: set[str] = set()
|
||||
all_user_ids: set[uuid.UUID] = set()
|
||||
total_count = 0
|
||||
allowed_count = 0
|
||||
denied_count = 0
|
||||
|
||||
for ip_address, user_id, allowed in result.all():
|
||||
ip_info = resolve_ip_location(ip_address)
|
||||
key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp)
|
||||
bucket = buckets.setdefault(
|
||||
key,
|
||||
{
|
||||
"country": ip_info.country,
|
||||
"province": ip_info.province,
|
||||
"city": ip_info.city,
|
||||
"isp": ip_info.isp,
|
||||
"location": ip_info.location,
|
||||
"total_count": 0,
|
||||
"allowed_count": 0,
|
||||
"denied_count": 0,
|
||||
"ip_addresses": set(),
|
||||
"user_ids": set(),
|
||||
},
|
||||
)
|
||||
bucket["total_count"] += 1
|
||||
bucket["allowed_count" if allowed else "denied_count"] += 1
|
||||
bucket["ip_addresses"].add(ip_address)
|
||||
bucket["user_ids"].add(user_id)
|
||||
total_count += 1
|
||||
if allowed:
|
||||
allowed_count += 1
|
||||
else:
|
||||
denied_count += 1
|
||||
all_ip_addresses.add(ip_address)
|
||||
all_user_ids.add(user_id)
|
||||
|
||||
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
|
||||
return {
|
||||
"days": days,
|
||||
"summary": {
|
||||
"total_count": total_count,
|
||||
"allowed_count": allowed_count,
|
||||
"denied_count": denied_count,
|
||||
"unique_ip_count": len(all_ip_addresses),
|
||||
"unique_user_count": len(all_user_ids),
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"country": item["country"],
|
||||
"province": item["province"],
|
||||
"city": item["city"],
|
||||
"isp": item["isp"],
|
||||
"location": item["location"],
|
||||
"total_count": item["total_count"],
|
||||
"allowed_count": item["allowed_count"],
|
||||
"denied_count": item["denied_count"],
|
||||
"unique_ip_count": len(item["ip_addresses"]),
|
||||
"unique_user_count": len(item["user_ids"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 新增端点:实时统计摘要(从DB获取)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
|
||||
async def get_stats_summary(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_=Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""获取基于数据库的统计摘要"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
hour_ago = now - timedelta(hours=1)
|
||||
|
||||
# 今日统计
|
||||
today_result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(PermissionAccessLog.created_at >= today_start)
|
||||
)
|
||||
today = today_result.one()
|
||||
|
||||
# 最近一小时
|
||||
hour_result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
).where(PermissionAccessLog.created_at >= hour_ago)
|
||||
)
|
||||
hour = hour_result.one()
|
||||
|
||||
# 总记录数
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PermissionAccessLog)
|
||||
)
|
||||
total_logs = total_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"total_logs": total_logs,
|
||||
"today": {
|
||||
"total_checks": today.total,
|
||||
"allowed_checks": today.allowed,
|
||||
"denied_checks": today.denied,
|
||||
"avg_elapsed_ms": round(float(today.avg_ms), 2),
|
||||
"max_elapsed_ms": round(float(today.max_ms), 2),
|
||||
"allow_rate": round(today.allowed / today.total * 100, 1) if today.total > 0 else 0,
|
||||
"deny_rate": round(today.denied / today.total * 100, 1) if today.total > 0 else 0,
|
||||
},
|
||||
"last_hour": {
|
||||
"total_checks": hour.total,
|
||||
"allowed_checks": hour.allowed,
|
||||
"denied_checks": hour.denied,
|
||||
"requests_per_minute": round(hour.total / 60, 1),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,6 +15,23 @@ from app.models.study import Study
|
||||
router = APIRouter(prefix="/active-roles", tags=["active-roles"])
|
||||
|
||||
|
||||
def _normalize_active_roles(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="active_roles 必须是数组")
|
||||
roles: list[str] = []
|
||||
for item in value:
|
||||
role = str(item or "").strip()
|
||||
if not role:
|
||||
continue
|
||||
if role == "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
|
||||
if len(role) > 20:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
|
||||
if role not in roles:
|
||||
roles.append(role)
|
||||
return roles
|
||||
|
||||
|
||||
@router.get("", summary="获取项目已生效角色列表")
|
||||
async def get_active_roles(
|
||||
study_id: uuid.UUID,
|
||||
@@ -39,6 +56,6 @@ async def update_active_roles(
|
||||
study = result.scalar_one_or_none()
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
study.active_roles = payload.get("active_roles", [])
|
||||
study.active_roles = _normalize_active_roles(payload.get("active_roles", []))
|
||||
await db.commit()
|
||||
return {"active_roles": study.active_roles}
|
||||
|
||||
@@ -409,7 +409,7 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 项目概览
|
||||
"overview:read": {
|
||||
"project_overview:read": {
|
||||
"module": "project_overview",
|
||||
"action": "read",
|
||||
"description": "查询项目概览",
|
||||
@@ -583,6 +583,13 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM", "CRA"],
|
||||
"prerequisite_permissions": ["subjects:read", "sites:read"],
|
||||
},
|
||||
"subject_pds:delete": {
|
||||
"module": "subjects",
|
||||
"action": "write",
|
||||
"description": "删除参与者PDS",
|
||||
"default_roles": ["PM"],
|
||||
"prerequisite_permissions": ["subjects:read", "sites:read"],
|
||||
},
|
||||
# 审计日志管理
|
||||
"audit_logs:list": {
|
||||
"module": "audit_export",
|
||||
@@ -665,18 +672,43 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
|
||||
},
|
||||
# 项目里程碑管理
|
||||
"milestones:list": {
|
||||
"project_milestones:read": {
|
||||
"module": "project_milestones",
|
||||
"action": "read",
|
||||
"description": "查询项目里程碑列表",
|
||||
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
|
||||
},
|
||||
"milestones:update": {
|
||||
"project_milestones:update": {
|
||||
"module": "project_milestones",
|
||||
"action": "write",
|
||||
"description": "更新项目里程碑",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 启动与授权
|
||||
"startup_auth:create": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "创建启动会或培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
"startup_auth:read": {
|
||||
"module": "startup_auth",
|
||||
"action": "read",
|
||||
"description": "查询启动会或培训授权",
|
||||
"default_roles": ["PM", "CRA", "PV", "IMP"],
|
||||
},
|
||||
"startup_auth:update": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "更新启动会或培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
"startup_auth:delete": {
|
||||
"module": "startup_auth",
|
||||
"action": "write",
|
||||
"description": "删除培训授权",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 附件管理
|
||||
"attachments:create": {
|
||||
"module": "attachments",
|
||||
@@ -981,12 +1013,16 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"startup_auth": {
|
||||
"read": [
|
||||
"startup_auth:read",
|
||||
"budget:list",
|
||||
"budget:read",
|
||||
"timeline:list",
|
||||
"timeline:read",
|
||||
],
|
||||
"write": [
|
||||
"startup_auth:create",
|
||||
"startup_auth:update",
|
||||
"startup_auth:delete",
|
||||
"budget:create",
|
||||
"budget:update",
|
||||
"budget:delete",
|
||||
@@ -997,7 +1033,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"project_overview": {
|
||||
"read": [
|
||||
"overview:read",
|
||||
"project_overview:read",
|
||||
],
|
||||
"write": [],
|
||||
},
|
||||
@@ -1083,10 +1119,10 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
},
|
||||
"project_milestones": {
|
||||
"read": [
|
||||
"milestones:list",
|
||||
"project_milestones:read",
|
||||
],
|
||||
"write": [
|
||||
"milestones:update",
|
||||
"project_milestones:update",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
|
||||
LOGIN_RSA_KEY_ID: str = "default"
|
||||
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
|
||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Annotated, AsyncGenerator, Callable, Iterable
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
@@ -129,12 +130,17 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
check_prerequisites: 是否检查前置权限
|
||||
"""
|
||||
async def dependency(
|
||||
request: Request,
|
||||
study_id: uuid.UUID,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if allow_system_admin and role_value == "ADMIN":
|
||||
_enqueue_permission_log(
|
||||
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
|
||||
)
|
||||
return current_user
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
@@ -143,11 +149,29 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
message="不是该项目成员",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
allowed = await role_has_api_permission(
|
||||
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
|
||||
)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
error = None
|
||||
try:
|
||||
allowed = await role_has_api_permission(
|
||||
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
|
||||
)
|
||||
except Exception as e:
|
||||
error = e
|
||||
allowed = False
|
||||
raise
|
||||
finally:
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
||||
monitor = get_permission_monitor()
|
||||
monitor.record_permission_check(
|
||||
allowed=allowed, elapsed_time=elapsed_ms / 1000, error=error
|
||||
)
|
||||
_enqueue_permission_log(
|
||||
study_id, current_user.id, endpoint_key,
|
||||
membership.role_in_study, allowed, elapsed_ms, request
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
# 获取缺失的前置权限,用于错误提示
|
||||
missing_prereqs = await get_missing_prerequisites(
|
||||
db, study_id, membership.role_in_study, endpoint_key
|
||||
)
|
||||
@@ -168,6 +192,32 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
return dependency
|
||||
|
||||
|
||||
def _enqueue_permission_log(
|
||||
study_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
endpoint_key: str,
|
||||
role: str,
|
||||
allowed: bool,
|
||||
elapsed_ms: float,
|
||||
request: Request,
|
||||
) -> None:
|
||||
from app.services.permission_log_writer import get_log_writer
|
||||
|
||||
writer = get_log_writer()
|
||||
if writer:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
|
||||
writer.enqueue({
|
||||
"study_id": study_id,
|
||||
"user_id": user_id,
|
||||
"endpoint_key": endpoint_key,
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": ip,
|
||||
})
|
||||
|
||||
|
||||
async def get_cra_site_scope(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES
|
||||
from app.core.permission_cache import get_permission_cache
|
||||
|
||||
@@ -102,7 +103,10 @@ async def get_api_endpoint_permissions(
|
||||
"""
|
||||
overrides = await _get_project_permission_overrides(db, study_id)
|
||||
|
||||
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
|
||||
study_result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = study_result.scalar_one_or_none()
|
||||
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
|
||||
roles = list(dict.fromkeys(["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", *active_roles, *overrides.keys()]))
|
||||
matrix: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in roles:
|
||||
matrix[role] = {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
==========================================================================
|
||||
The following license applies to the ip2region library
|
||||
--------------------------------------------------------------------------
|
||||
Copyright (c) 2015 Lionsoul<chenxin619315@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -37,3 +37,6 @@ from app.models.study_setup_config import StudySetupConfig # noqa: F401
|
||||
from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401
|
||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
|
||||
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
|
||||
from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||
|
||||
+65
-4
@@ -17,6 +17,14 @@ from app.crud.user import ensure_admin_exists
|
||||
from app.db.base import Base
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.services.visit_scheduler import run_daily_lost_visit_job
|
||||
from app.services.permission_log_writer import start_log_writer, stop_log_writer
|
||||
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
|
||||
from app.services.security_access_log_writer import (
|
||||
get_security_log_writer,
|
||||
start_security_log_writer,
|
||||
stop_security_log_writer,
|
||||
)
|
||||
from app.core.security import decode_token
|
||||
|
||||
logger = logging.getLogger("ctms.setup_config")
|
||||
UUID_RE = re.compile(
|
||||
@@ -29,6 +37,9 @@ setup_config_stats: dict[str, int] = defaultdict(int)
|
||||
async def lifespan(_: FastAPI):
|
||||
stop_event = asyncio.Event()
|
||||
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
|
||||
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
|
||||
await start_log_writer()
|
||||
await start_security_log_writer()
|
||||
# Ensure models are imported so metadata is populated
|
||||
from app.models import user as user_model # noqa: F401
|
||||
|
||||
@@ -38,12 +49,12 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
# Initialize API endpoint registry
|
||||
# from app.core.decorators import initialize_api_endpoint_registry
|
||||
# await initialize_api_endpoint_registry(session)
|
||||
yield
|
||||
stop_event.set()
|
||||
await stop_log_writer()
|
||||
await stop_security_log_writer()
|
||||
await scheduler_task
|
||||
await aggregator_task
|
||||
|
||||
|
||||
async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
@@ -118,7 +129,9 @@ def create_app() -> FastAPI:
|
||||
async def setup_config_monitoring_middleware(request, call_next):
|
||||
path = request.url.path
|
||||
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()
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
@@ -134,12 +147,14 @@ def create_app() -> FastAPI:
|
||||
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)
|
||||
status_code = int(response.status_code)
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
@@ -167,6 +182,8 @@ def create_app() -> FastAPI:
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
return response
|
||||
|
||||
register_exception_handlers(app)
|
||||
@@ -215,3 +232,47 @@ 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 "):
|
||||
return "ANONYMOUS", None
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
return "ANONYMOUS", None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
return "INVALID_TOKEN", None
|
||||
subject = payload.get("sub")
|
||||
return ("AUTHENTICATED", str(subject)) if subject else ("INVALID_TOKEN", None)
|
||||
|
||||
|
||||
def _enqueue_security_access_log(request, path: str, status_code: int, started_at: float) -> None:
|
||||
writer = get_security_log_writer()
|
||||
if not writer:
|
||||
return
|
||||
auth_status, user_identifier = _resolve_auth_context(request)
|
||||
writer.enqueue(
|
||||
{
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"auth_status": auth_status,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""权限访问日志模型"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class PermissionAccessLog(Base):
|
||||
__tablename__ = "permission_access_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_perm_log_study_created", "study_id", "created_at"),
|
||||
Index("ix_perm_log_user_created", "user_id", "created_at"),
|
||||
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
|
||||
Index("ix_perm_log_created_at", "created_at"),
|
||||
Index("ix_perm_log_allowed", "allowed", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
endpoint_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""权限指标快照模型 - 每小时聚合"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class PermissionMetricSnapshot(Base):
|
||||
__tablename__ = "permission_metric_snapshots"
|
||||
__table_args__ = (
|
||||
Index("ix_perm_snapshot_bucket", "bucket_time"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
bucket_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
total_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
allowed_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
denied_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
avg_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
max_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
cache_hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cache_misses: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""底层安全访问日志模型"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class SecurityAccessLog(Base):
|
||||
__tablename__ = "security_access_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_security_log_created_at", "created_at"),
|
||||
Index("ix_security_log_ip_created", "client_ip", "created_at"),
|
||||
Index("ix_security_log_status_created", "status_code", "created_at"),
|
||||
Index("ix_security_log_auth_created", "auth_status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
method: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,23 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from app.schemas.user import UserResponse
|
||||
|
||||
StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
|
||||
StudyRole = str
|
||||
|
||||
|
||||
class StudyMemberCreate(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
role_in_study: StudyRole
|
||||
role_in_study: StudyRole = Field(min_length=1, max_length=20)
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("role_in_study")
|
||||
@classmethod
|
||||
def validate_project_role(cls, value: str) -> str:
|
||||
role = value.strip()
|
||||
if role == "ADMIN":
|
||||
raise ValueError("ADMIN 不能作为项目角色")
|
||||
return role
|
||||
|
||||
|
||||
class StudyMemberUpdate(BaseModel):
|
||||
role_in_study: Optional[StudyRole] = None
|
||||
role_in_study: Optional[StudyRole] = Field(default=None, min_length=1, max_length=20)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator("role_in_study")
|
||||
@classmethod
|
||||
def validate_project_role(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
role = value.strip()
|
||||
if role == "ADMIN":
|
||||
raise ValueError("ADMIN 不能作为项目角色")
|
||||
return role
|
||||
|
||||
|
||||
class StudyMemberRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""IP 属地解析服务。
|
||||
|
||||
优先使用 ip2region xdb 离线库;缺少依赖或数据文件时降级为基础地址分类。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("ctms.ip_location")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IpLocation:
|
||||
location: str
|
||||
country: str = ""
|
||||
province: str = ""
|
||||
city: str = ""
|
||||
isp: str = ""
|
||||
|
||||
|
||||
def _default_xdb_path(version: int) -> Path:
|
||||
filename = "ip2region_v6.xdb" if version == 6 else "ip2region_v4.xdb"
|
||||
return Path(__file__).resolve().parents[1] / "data" / filename
|
||||
|
||||
|
||||
def _normalize_part(value: str | None) -> str:
|
||||
part = (value or "").strip()
|
||||
return "" if part in {"", "0", "-"} else part
|
||||
|
||||
|
||||
def _format_location(country: str, province: str, city: str, isp: str) -> str:
|
||||
parts = [part for part in [country, province, city, isp] if part]
|
||||
return " / ".join(parts) if parts else "公网"
|
||||
|
||||
|
||||
class Ip2RegionResolver:
|
||||
def __init__(self, db_path: Optional[str] = None, ipv6_db_path: Optional[str] = None) -> None:
|
||||
self.db_paths = {
|
||||
4: Path(db_path or settings.IP2REGION_XDB_PATH or _default_xdb_path(4)),
|
||||
6: Path(ipv6_db_path or settings.IP2REGION_IPV6_XDB_PATH or _default_xdb_path(6)),
|
||||
}
|
||||
self._searchers = {}
|
||||
self._load_failed: set[int] = set()
|
||||
|
||||
def lookup(self, ip: str | None) -> IpLocation:
|
||||
addr = self._classify_address(ip)
|
||||
if addr is None:
|
||||
return IpLocation(location="未知")
|
||||
if addr.is_loopback:
|
||||
return IpLocation(location="本机")
|
||||
if addr.is_private or addr.is_link_local:
|
||||
return IpLocation(location="局域网")
|
||||
|
||||
searcher = self._get_searcher(addr.version)
|
||||
if not searcher:
|
||||
return IpLocation(location="公网")
|
||||
|
||||
try:
|
||||
region = searcher.search(str(addr))
|
||||
except Exception:
|
||||
logger.exception("Failed to lookup IP location: %s", addr)
|
||||
return IpLocation(location="公网")
|
||||
return self._parse_region(region)
|
||||
|
||||
@staticmethod
|
||||
def _classify_address(ip: str | None):
|
||||
if not ip:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _get_searcher(self, version: int):
|
||||
if version in self._searchers or version in self._load_failed:
|
||||
return self._searchers.get(version)
|
||||
db_path = self.db_paths[version]
|
||||
if not db_path.exists():
|
||||
self._load_failed.add(version)
|
||||
logger.info("ip2region xdb file not found: %s", db_path)
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
import ip2region.searcher as searcher
|
||||
import ip2region.util as util
|
||||
except ModuleNotFoundError:
|
||||
vendor_path = Path(__file__).resolve().parents[1] / "vendor"
|
||||
if str(vendor_path) not in sys.path:
|
||||
sys.path.insert(0, str(vendor_path))
|
||||
import ip2region.searcher as searcher
|
||||
import ip2region.util as util
|
||||
|
||||
ip_version = util.IPv6 if version == 6 else util.IPv4
|
||||
buffer = util.load_content_from_file(str(db_path))
|
||||
self._searchers[version] = searcher.new_with_buffer(ip_version, buffer)
|
||||
except Exception:
|
||||
self._load_failed.add(version)
|
||||
logger.exception("Failed to initialize ip2region searcher: %s", db_path)
|
||||
return self._searchers.get(version)
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(region: str | None) -> IpLocation:
|
||||
parts = [*str(region or "").split("|"), "", "", "", ""]
|
||||
country = _normalize_part(parts[0])
|
||||
province = _normalize_part(parts[1])
|
||||
city = _normalize_part(parts[2])
|
||||
isp = _normalize_part(parts[3])
|
||||
return IpLocation(
|
||||
location=_format_location(country, province, city, isp),
|
||||
country=country,
|
||||
province=province,
|
||||
city=city,
|
||||
isp=isp,
|
||||
)
|
||||
|
||||
|
||||
_resolver = Ip2RegionResolver()
|
||||
|
||||
|
||||
def resolve_ip_location(ip: str | None) -> IpLocation:
|
||||
return _resolver.lookup(ip)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""权限访问日志后台写入器
|
||||
|
||||
使用 asyncio.Queue 缓冲日志条目,批量写入数据库。
|
||||
设计原则:
|
||||
- enqueue() 非阻塞,不影响请求性能
|
||||
- 队列满时丢弃(监控不应拖慢被监控系统)
|
||||
- 批量写入减少数据库压力
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
|
||||
logger = logging.getLogger("ctms.permission_log_writer")
|
||||
|
||||
BATCH_SIZE = 50
|
||||
FLUSH_INTERVAL = 5.0
|
||||
QUEUE_MAX_SIZE = 10000
|
||||
|
||||
|
||||
class PermissionLogWriter:
|
||||
|
||||
def __init__(self):
|
||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def enqueue(self, entry: dict) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Permission log queue full, dropping entry")
|
||||
|
||||
async def start(self) -> None:
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
logger.info("PermissionLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
logger.info("PermissionLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
async def _collect_batch(self) -> list[dict]:
|
||||
batch: list[dict] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
batch.append(item)
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return batch
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
log = PermissionAccessLog(
|
||||
id=uuid.uuid4(),
|
||||
study_id=entry["study_id"],
|
||||
user_id=entry["user_id"],
|
||||
endpoint_key=entry["endpoint_key"],
|
||||
role=entry["role"],
|
||||
allowed=entry["allowed"],
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
ip_address=entry.get("ip_address"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
|
||||
|
||||
async def _drain(self) -> None:
|
||||
batch: list[dict] = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
|
||||
_writer: PermissionLogWriter | None = None
|
||||
|
||||
|
||||
def get_log_writer() -> PermissionLogWriter | None:
|
||||
return _writer
|
||||
|
||||
|
||||
async def start_log_writer() -> PermissionLogWriter:
|
||||
global _writer
|
||||
_writer = PermissionLogWriter()
|
||||
await _writer.start()
|
||||
return _writer
|
||||
|
||||
|
||||
async def stop_log_writer() -> None:
|
||||
global _writer
|
||||
if _writer:
|
||||
await _writer.stop()
|
||||
_writer = None
|
||||
@@ -0,0 +1,82 @@
|
||||
"""权限指标小时聚合任务
|
||||
|
||||
每小时从 permission_access_logs 聚合数据写入 permission_metric_snapshots。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
|
||||
logger = logging.getLogger("ctms.permission_metric_aggregator")
|
||||
|
||||
|
||||
async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= bucket_start,
|
||||
PermissionAccessLog.created_at < bucket_end,
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
|
||||
if row.total == 0:
|
||||
return
|
||||
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
monitor = get_permission_monitor()
|
||||
cache_metrics = monitor.metrics.cache_metrics
|
||||
|
||||
snapshot = PermissionMetricSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
bucket_time=bucket_start,
|
||||
total_checks=row.total,
|
||||
allowed_checks=row.allowed,
|
||||
denied_checks=row.denied,
|
||||
avg_elapsed_ms=float(row.avg_ms),
|
||||
max_elapsed_ms=float(row.max_ms),
|
||||
cache_hits=cache_metrics.cache_hits,
|
||||
cache_misses=cache_metrics.cache_misses,
|
||||
error_count=monitor.metrics.check_metrics.errors,
|
||||
)
|
||||
session.add(snapshot)
|
||||
await session.commit()
|
||||
logger.info("Aggregated permission metrics for bucket %s: %d checks", bucket_start, row.total)
|
||||
|
||||
|
||||
async def run_hourly_metric_aggregation(stop_event: asyncio.Event) -> None:
|
||||
logger.info("Permission metric aggregator started")
|
||||
while not stop_event.is_set():
|
||||
now = datetime.now(timezone.utc)
|
||||
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
wait_seconds = (next_hour - now).total_seconds()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
bucket_end = next_hour
|
||||
bucket_start = bucket_end - timedelta(hours=1)
|
||||
try:
|
||||
await _aggregate_hour(bucket_start, bucket_end)
|
||||
except Exception:
|
||||
logger.exception("Failed to aggregate permission metrics for %s", bucket_start)
|
||||
|
||||
logger.info("Permission metric aggregator stopped")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""底层安全访问日志后台写入器"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
|
||||
logger = logging.getLogger("ctms.security_access_log_writer")
|
||||
|
||||
BATCH_SIZE = 100
|
||||
FLUSH_INTERVAL = 3.0
|
||||
QUEUE_MAX_SIZE = 20000
|
||||
|
||||
|
||||
class SecurityAccessLogWriter:
|
||||
def __init__(self) -> None:
|
||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def enqueue(self, entry: dict) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Security access log queue full, dropping entry")
|
||||
|
||||
async def start(self) -> None:
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
logger.info("SecurityAccessLogWriter started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._drain()
|
||||
logger.info("SecurityAccessLogWriter stopped")
|
||||
|
||||
async def _flush_loop(self) -> None:
|
||||
while True:
|
||||
batch = await self._collect_batch()
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
async def _collect_batch(self) -> list[dict]:
|
||||
batch: list[dict] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||
batch.append(first)
|
||||
except asyncio.TimeoutError:
|
||||
return batch
|
||||
|
||||
while len(batch) < BATCH_SIZE:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return batch
|
||||
|
||||
async def _write_batch(self, batch: list[dict]) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
for entry in batch:
|
||||
session.add(
|
||||
SecurityAccessLog(
|
||||
id=uuid.uuid4(),
|
||||
method=entry["method"],
|
||||
path=entry["path"],
|
||||
status_code=entry["status_code"],
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
client_ip=entry.get("client_ip"),
|
||||
user_agent=entry.get("user_agent"),
|
||||
auth_status=entry["auth_status"],
|
||||
user_identifier=entry.get("user_identifier"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to write security access log batch (%d entries)", len(batch))
|
||||
|
||||
async def _drain(self) -> None:
|
||||
batch: list[dict] = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if batch:
|
||||
await self._write_batch(batch)
|
||||
|
||||
|
||||
_writer: SecurityAccessLogWriter | None = None
|
||||
|
||||
|
||||
def get_security_log_writer() -> SecurityAccessLogWriter | None:
|
||||
return _writer
|
||||
|
||||
|
||||
async def start_security_log_writer() -> SecurityAccessLogWriter:
|
||||
global _writer
|
||||
_writer = SecurityAccessLogWriter()
|
||||
await _writer.start()
|
||||
return _writer
|
||||
|
||||
|
||||
async def stop_security_log_writer() -> None:
|
||||
global _writer
|
||||
if _writer:
|
||||
await _writer.stop()
|
||||
_writer = None
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb package
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb searcher on 2025/10/30
|
||||
# Author Leon<chenxin619315@gmail.com>
|
||||
|
||||
import io
|
||||
import ip2region.util as util
|
||||
from typing import Union
|
||||
|
||||
class Searcher(object):
|
||||
'''
|
||||
xdb searcher class with Both IPv4 and IPv6 supported.
|
||||
three kinds of cache policy: file / vectorIndex / content
|
||||
'''
|
||||
def __init__(self, version: util.Version,
|
||||
db_path: str, vector_index: bytes, c_buffer: bytes):
|
||||
self.version = version
|
||||
self.__db_path = db_path
|
||||
self.__io_count = 0
|
||||
if c_buffer != None:
|
||||
self.__handle = None
|
||||
self.vector_index = None
|
||||
self.c_buffer = c_buffer
|
||||
else:
|
||||
self.__handle = io.open(db_path, "rb")
|
||||
self.vector_index = vector_index
|
||||
self.c_buffer = None
|
||||
|
||||
def get_ip_version(self):
|
||||
return self.version
|
||||
|
||||
def get_io_count(self):
|
||||
return self.__io_count
|
||||
|
||||
def search(self, ip: Union[bytes, str]):
|
||||
# check and parse the string ip
|
||||
ip_bytes = None
|
||||
if isinstance(ip, str):
|
||||
ip_bytes = util.parse_ip(ip)
|
||||
elif isinstance(ip, bytes):
|
||||
ip_bytes = ip
|
||||
else:
|
||||
raise ValueError("invalid ip address `{}`".format(ip))
|
||||
|
||||
# ip version check
|
||||
if len(ip_bytes) != self.version.byte_num:
|
||||
raise ValueError("invalid ip address `{}` ({} expected)".format(
|
||||
util.ip_to_string(ip_bytes), self.version.name))
|
||||
|
||||
# reset the global io_count
|
||||
self.__io_count = 0
|
||||
|
||||
# located the segment index block based on the vector index
|
||||
s_ptr, e_ptr, i0, i1 = 0, 0, ip_bytes[0], ip_bytes[1]
|
||||
idx = i0 * util.VectorIndexCols * util.VectorIndexSize + i1 * util.VectorIndexSize
|
||||
if self.vector_index != None:
|
||||
s_ptr = util.le_get_uint32(self.vector_index, idx)
|
||||
e_ptr = util.le_get_uint32(self.vector_index, idx + 4)
|
||||
elif self.c_buffer != None:
|
||||
offset = util.HeaderInfoLength + idx
|
||||
s_ptr = util.le_get_uint32(self.c_buffer, offset)
|
||||
e_ptr = util.le_get_uint32(self.c_buffer, offset + 4)
|
||||
else:
|
||||
buff = self.read(util.HeaderInfoLength + idx, util.VectorIndexSize)
|
||||
s_ptr = util.le_get_uint32(buff, 0)
|
||||
e_ptr = util.le_get_uint32(buff, 4)
|
||||
|
||||
# print("s_ptr: {}, e_ptr: {}".format(s_ptr, e_ptr))
|
||||
# @Note: ptr validate, zero ptr means source data missing
|
||||
# so we could just stop here and return an empty string.
|
||||
if s_ptr == 0 or e_ptr == 0:
|
||||
return ""
|
||||
|
||||
# binary search the segment index block to get the region info
|
||||
_bytes, _d_bytes = len(ip_bytes), len(ip_bytes) << 1
|
||||
index_size = self.version.index_size
|
||||
d_len, d_ptr, l, h = 0, 0, int(0), int((e_ptr - s_ptr) / index_size)
|
||||
while l <= h:
|
||||
m = (l + h) >> 1
|
||||
p = int(s_ptr + m * index_size)
|
||||
|
||||
# read the segment index
|
||||
buff = self.read(p, index_size)
|
||||
if self.version.ip_sub_compare(ip_bytes, buff, 0) < 0:
|
||||
h = m - 1
|
||||
elif self.version.ip_sub_compare(ip_bytes, buff, _bytes) > 0:
|
||||
l = m + 1
|
||||
else:
|
||||
d_len = util.le_get_uint16(buff, _d_bytes)
|
||||
d_ptr = util.le_get_uint32(buff, _d_bytes + 2)
|
||||
break
|
||||
|
||||
# print("d_len: {}, d_ptr: {}".format(d_len, d_ptr))
|
||||
# empty match interception.
|
||||
# and this could be a case.
|
||||
if d_len == 0:
|
||||
return ""
|
||||
|
||||
# read and return the region info
|
||||
return self.read(d_ptr, d_len).decode("utf-8")
|
||||
|
||||
def read(self, offset: int, length: int):
|
||||
# check the content buffer first
|
||||
if self.c_buffer != None:
|
||||
return self.c_buffer[offset:offset+length]
|
||||
|
||||
# load the buffer from file
|
||||
self.__handle.seek(offset)
|
||||
self.__io_count += 1
|
||||
return self.__handle.read(length)
|
||||
|
||||
def close(self):
|
||||
if self.__handle != None:
|
||||
self.__handle.close()
|
||||
|
||||
def __str__(self):
|
||||
return '{{"version": {}, "db_path": "{}", "v_index": {}, "c_buffer": {}}}'.format(
|
||||
self.version.name,
|
||||
self.__db_path,
|
||||
None if self.vector_index is None else len(self.vector_index),
|
||||
None if self.c_buffer is None else len(self.c_buffer)
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# functions to create Searcher with different cache policy
|
||||
|
||||
def new_with_file_only(version: util.Version, db_path: str):
|
||||
return Searcher(version, db_path, None, None)
|
||||
|
||||
def new_with_vector_index(version: util.Version, db_path: str, vector_index: bytes):
|
||||
return Searcher(version, db_path, vector_index, None)
|
||||
|
||||
def new_with_buffer(version: util.Version, c_buffer: bytes):
|
||||
return Searcher(version, None, None, c_buffer)
|
||||
Vendored
+270
@@ -0,0 +1,270 @@
|
||||
# Copyright 2022 The Ip2Region Authors. All rights reserved.
|
||||
# Use of this source code is governed by a Apache2.0-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# xdb utils on 2025/10/29
|
||||
# Author Leon<chenxin619315@gmail.com>
|
||||
|
||||
import io
|
||||
import os
|
||||
import ipaddress
|
||||
from typing import Callable
|
||||
|
||||
# global constants
|
||||
XdbStructure20 = 2
|
||||
XdbStructure30 = 3
|
||||
XdbIPv4Id = 4
|
||||
XdbIPv6Id = 6
|
||||
|
||||
HeaderInfoLength = 256
|
||||
VectorIndexRows = 256
|
||||
VectorIndexCols = 256
|
||||
VectorIndexSize = 8
|
||||
# cache of VectorIndexCols × VectorIndexRows × VectorIndexSize
|
||||
VectorIndexLength = 524288
|
||||
|
||||
class Header(object):
|
||||
def __init__(self, buff: bytes):
|
||||
self.version = le_get_uint16(buff, 0)
|
||||
self.indexPolicy = le_get_uint16(buff, 2)
|
||||
self.createdAt = le_get_uint32(buff, 4)
|
||||
self.startIndexPtr = le_get_uint32(buff, 8)
|
||||
self.endIndexPtr = le_get_uint32(buff, 12)
|
||||
|
||||
# since IPv6 supporting
|
||||
self.ipVersion = le_get_uint16(buff, 16)
|
||||
self.runtimePtrBytes = le_get_uint16(buff, 18)
|
||||
|
||||
|
||||
# keep the raw data
|
||||
self.buff = buff
|
||||
|
||||
def __str__(self):
|
||||
return '''{{
|
||||
"version": {},
|
||||
"indexPolicy": {},
|
||||
"createdAt": {},
|
||||
"startIndexPtr": {},
|
||||
"endIndexPtr": {},
|
||||
"ipVersion": {},
|
||||
"runtimePtrBytes": {}
|
||||
}}'''.format(
|
||||
self.version,
|
||||
self.indexPolicy,
|
||||
self.createdAt,
|
||||
self.startIndexPtr,
|
||||
self.endIndexPtr,
|
||||
self.ipVersion,
|
||||
self.runtimePtrBytes
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# ip parse and convert functions
|
||||
|
||||
def parse_ip(ip_string: str):
|
||||
try:
|
||||
return ipaddress.ip_address(ip_string).packed
|
||||
except:
|
||||
raise ValueError("invalid ip address `{}`".format(ip_string))
|
||||
|
||||
def ip_to_string(ip_bytes: bytes):
|
||||
if isinstance(ip_bytes, bytes):
|
||||
return str(ipaddress.ip_address(ip_bytes))
|
||||
else:
|
||||
raise ValueError("invalid bytes ip `{}`".format(ip_bytes))
|
||||
|
||||
def ip_compare(ip1: bytes, ip2: bytes):
|
||||
if ip1 > ip2:
|
||||
return 1
|
||||
elif ip1 < ip2:
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def ip_sub_compare(ip1: bytes, buff: bytes, offset: int):
|
||||
ip2 = buff[offset:offset+len(ip1)]
|
||||
if ip1 > ip2:
|
||||
return 1
|
||||
elif ip1 < ip2:
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
# ---
|
||||
# ip version class and functions
|
||||
|
||||
class Version(object):
|
||||
def __init__(self, id: int, name: str, byte_num: int, index_size: int, ip_compare: Callable[[bytes, bytes, int], int]):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.byte_num = byte_num
|
||||
self.index_size = index_size
|
||||
self.ip_compare = ip_compare
|
||||
|
||||
def ip_compare(self, ip1: bytes, ip2: bytes):
|
||||
return self.ip_compare(ip1, ip2, 0)
|
||||
|
||||
def ip_sub_compare(self, ip1: bytes, buff: bytes, offset: int):
|
||||
return self.ip_compare(ip1, buff, offset)
|
||||
|
||||
def __str__(self):
|
||||
return '{{"id": {}, "name": "{}", "bytes": {}, "index_size": {}}}'.format(
|
||||
self.id,
|
||||
self.name,
|
||||
self.byte_num,
|
||||
self.index_size
|
||||
)
|
||||
|
||||
def _v4_sub_compare(ip1: bytes, buff: bytes, offset: int):
|
||||
# ip1: Big endian byte order parsed from input
|
||||
# ip2: Little endian byte order read from xdb index.
|
||||
# @Note: to compatible with the old Litten endian index encode implementation.
|
||||
j = offset + len(ip1) - 1
|
||||
for i in range(len(ip1)):
|
||||
i1 = ip1[i]
|
||||
i2 = buff[j]
|
||||
if i1 < i2:
|
||||
return -1
|
||||
|
||||
if i1 > i2:
|
||||
return 1
|
||||
|
||||
# increase the j
|
||||
j = j - 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ---
|
||||
# IPv4 and IPv6 version constants
|
||||
# 14 = 4 + 4 + 2 + 4
|
||||
IPv4 = Version(XdbIPv4Id, "IPv4", 4, 14, _v4_sub_compare)
|
||||
# 38 = 16 + 16 + 2 + 4
|
||||
IPv6 = Version(XdbIPv6Id, "IPv6", 16, 38, ip_sub_compare)
|
||||
|
||||
def version_from_name(name: str):
|
||||
u_name = name.upper()
|
||||
if u_name == "IPV4" or u_name == "V4":
|
||||
return IPv4
|
||||
elif u_name == "IPV6" or u_name == "V6":
|
||||
return IPv6
|
||||
else:
|
||||
return None
|
||||
|
||||
def version_from_header(header: bytes):
|
||||
# old xdb 2.0 with IPv4 supports ONLY
|
||||
if header.version < XdbStructure30:
|
||||
return IPv4
|
||||
|
||||
# xdb 3.0 or later version
|
||||
ip_version = header.ipVersion
|
||||
if ip_version == XdbIPv4Id:
|
||||
return IPv4
|
||||
elif ip_version == XdbIPv6Id:
|
||||
return IPv6
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# ---
|
||||
# buffer decode functions
|
||||
|
||||
def le_get_uint32(buff: bytes, offset: int):
|
||||
'''
|
||||
decode an unsinged 4-bytes int from a buffer started from offset
|
||||
with little byte endian
|
||||
'''
|
||||
return (
|
||||
((buff[offset ]) & 0x000000FF) |
|
||||
((buff[offset+1] << 8) & 0x0000FF00) |
|
||||
((buff[offset+2] << 16) & 0x00FF0000) |
|
||||
((buff[offset+3] << 24) & 0xFF000000)
|
||||
)
|
||||
|
||||
def le_get_uint16(buff: bytes, offset: int):
|
||||
'''
|
||||
decode an unsinged 2-bytes short from a buffer started from offset
|
||||
with little byte endian
|
||||
'''
|
||||
return (
|
||||
((buff[offset ]) & 0x000000FF) |
|
||||
((buff[offset+1] << 8) & 0x0000FF00)
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# xdb buffer load functions
|
||||
|
||||
def load_header(handle):
|
||||
'''
|
||||
load xdb header from a specified file handle
|
||||
'''
|
||||
handle.seek(0)
|
||||
return Header(handle.read(HeaderInfoLength))
|
||||
|
||||
def load_header_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
header = load_header(handle)
|
||||
handle.close()
|
||||
return header
|
||||
|
||||
def load_vector_index(handle):
|
||||
'''
|
||||
load xdb vector index from a specified file handle
|
||||
'''
|
||||
handle.seek(HeaderInfoLength)
|
||||
return handle.read(VectorIndexLength)
|
||||
|
||||
def load_vector_index_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
v_index = load_vector_index(handle)
|
||||
handle.close()
|
||||
return v_index
|
||||
|
||||
def load_content(handle):
|
||||
'''
|
||||
load the whole xdb content from a specified file handle
|
||||
'''
|
||||
handle.seek(0)
|
||||
return handle.read()
|
||||
|
||||
def load_content_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
c_buff = load_content(handle)
|
||||
handle.close()
|
||||
return c_buff
|
||||
|
||||
# ---
|
||||
# Verify if the current Searcher could be used to search the specified xdb file.
|
||||
# Why do we need this check ?
|
||||
# The future features of the xdb impl may cause the current searcher not able to work properly.
|
||||
#
|
||||
# @Note: You Just need to check this ONCE when the service starts
|
||||
# Or use another process (eg, A command) to check once Just to confirm the suitability.
|
||||
def verify(handle):
|
||||
header = load_header(handle)
|
||||
|
||||
# get the runtime ptr bytes
|
||||
runtime_ptr_bytes = 0
|
||||
if header.version == XdbStructure20:
|
||||
runtime_ptr_bytes = 4
|
||||
elif header.version == XdbStructure30:
|
||||
runtime_ptr_bytes = header.runtimePtrBytes
|
||||
else:
|
||||
# Higher versions of the structure are usually incompatible.
|
||||
raise ValueError("invalid structure version {}".format(header.version))
|
||||
|
||||
# 1, confirm the xdb file size
|
||||
# to ensure that the maximum file pointer does not overflow
|
||||
max_file_ptr = (1 << (runtime_ptr_bytes * 8)) - 1
|
||||
__file_bytes = os.stat(handle.fileno()).st_size
|
||||
# print("max_file_ptr: {}, file_bytes: {}".format(max_file_ptr, __file_bytes))
|
||||
if __file_bytes > max_file_ptr:
|
||||
raise Exception("xdb file exceeds the maximum supported bytes: {}".format(max_file_ptr))
|
||||
|
||||
def verify_from_file(db_file: str):
|
||||
handle = io.open(db_file, "rb")
|
||||
verify(handle)
|
||||
handle.close()
|
||||
@@ -16,3 +16,4 @@ httpx==0.25.2
|
||||
email-validator==2.1.1
|
||||
alembic==1.13.1
|
||||
openpyxl==3.1.5
|
||||
py-ip2region==3.0.4
|
||||
|
||||
@@ -83,6 +83,9 @@ async def _create_test_engine():
|
||||
import app.models.monitoring_visit_issue
|
||||
import app.models.site
|
||||
import app.models.permission_template
|
||||
import app.models.permission_access_log
|
||||
import app.models.permission_metric_snapshot
|
||||
import app.models.security_access_log
|
||||
|
||||
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
||||
for table in Base.metadata.tables.values():
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import re
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
|
||||
from app.core.permission_cache import PermissionCache, set_permission_cache
|
||||
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -33,6 +39,112 @@ async def test_api_permission_check_allowed(db_session: AsyncSession):
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_all_backend_permission_guards_are_configurable():
|
||||
"""确保后端实际鉴权使用的 operation key 都能在权限管理中配置。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
used_keys: set[str] = set()
|
||||
pattern = re.compile(r"require_api_permission\(\s*[\"']([^\"']+)[\"']")
|
||||
for path in api_dir.rglob("*.py"):
|
||||
used_keys.update(pattern.findall(path.read_text()))
|
||||
|
||||
assert used_keys
|
||||
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession):
|
||||
"""逐一验证 6 个预设角色在每个权限上的默认矩阵。"""
|
||||
study_id = uuid.uuid4()
|
||||
matrix = await get_api_endpoint_permissions(db_session, study_id)
|
||||
|
||||
assert set(matrix) == set(PROJECT_PERMISSION_ROLES)
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS)
|
||||
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
|
||||
assert matrix[role][endpoint_key]["allowed"] is (role in config["default_roles"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: AsyncSession):
|
||||
"""逐一验证前端提交格式会落库,并被后端鉴权函数按相同结果读取。"""
|
||||
cache = PermissionCache()
|
||||
monitor = PermissionMonitor()
|
||||
set_permission_cache(cache)
|
||||
set_permission_monitor(monitor)
|
||||
|
||||
study_id = uuid.uuid4()
|
||||
payload = {
|
||||
role: {
|
||||
endpoint_key: index % 2 == role_index % 2
|
||||
for index, endpoint_key in enumerate(API_ENDPOINT_PERMISSIONS)
|
||||
}
|
||||
for role_index, role in enumerate(PROJECT_PERMISSION_ROLES)
|
||||
}
|
||||
|
||||
matrix = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
||||
|
||||
for role in PROJECT_PERMISSION_ROLES:
|
||||
for endpoint_key, expected in payload[role].items():
|
||||
assert matrix[role][endpoint_key]["allowed"] is expected
|
||||
allowed = await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
role,
|
||||
endpoint_key,
|
||||
check_prerequisites=False,
|
||||
)
|
||||
assert allowed is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_active_role_permissions_take_effect(db_session: AsyncSession):
|
||||
"""验证项目自定义角色配置权限后能被后端鉴权读取。"""
|
||||
study_id = uuid.uuid4()
|
||||
db_session.add(
|
||||
Study(
|
||||
id=study_id,
|
||||
code=f"CUSTOM-{study_id.hex[:8]}",
|
||||
name="自定义角色权限测试",
|
||||
status="ACTIVE",
|
||||
is_locked=False,
|
||||
visit_schedule=[],
|
||||
active_roles=["DATA_MANAGER"],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
matrix = await get_api_endpoint_permissions(db_session, study_id)
|
||||
assert "DATA_MANAGER" in matrix
|
||||
assert matrix["DATA_MANAGER"]["subjects:read"]["allowed"] is False
|
||||
|
||||
await replace_api_endpoint_permissions(
|
||||
db_session,
|
||||
study_id,
|
||||
{"DATA_MANAGER": {"subjects:read": True, "subjects:update": False}},
|
||||
)
|
||||
|
||||
assert await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
"DATA_MANAGER",
|
||||
"subjects:read",
|
||||
check_prerequisites=False,
|
||||
) is True
|
||||
assert await role_has_api_permission(
|
||||
db_session,
|
||||
study_id,
|
||||
"DATA_MANAGER",
|
||||
"subjects:update",
|
||||
check_prerequisites=False,
|
||||
) is False
|
||||
|
||||
|
||||
def test_admin_cannot_be_used_as_project_custom_role():
|
||||
"""避免项目角色 ADMIN 触发后端系统管理员绕过逻辑。"""
|
||||
with pytest.raises(ValueError):
|
||||
StudyMemberCreate(user_id=uuid.uuid4(), role_in_study="ADMIN")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_permission_check_denied(db_session: AsyncSession):
|
||||
"""测试接口级权限检查 - 拒绝"""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""单元测试:IP 属地解析服务"""
|
||||
|
||||
from app.services.ip_location import Ip2RegionResolver
|
||||
|
||||
|
||||
class FakeSearcher:
|
||||
def search(self, _ip: str) -> str:
|
||||
return "中国|广东省|深圳市|电信|CN"
|
||||
|
||||
|
||||
def test_ip_location_handles_special_addresses():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
|
||||
assert resolver.lookup(None).location == "未知"
|
||||
assert resolver.lookup("not-an-ip").location == "未知"
|
||||
assert resolver.lookup("127.0.0.1").location == "本机"
|
||||
assert resolver.lookup("192.168.1.1").location == "局域网"
|
||||
|
||||
|
||||
def test_ip2region_result_parses_province_city_isp():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
resolver._searchers[4] = FakeSearcher()
|
||||
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.country == "中国"
|
||||
assert result.province == "广东省"
|
||||
assert result.city == "深圳市"
|
||||
assert result.isp == "电信"
|
||||
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
|
||||
|
||||
|
||||
def test_default_resolver_can_use_packaged_xdb():
|
||||
resolver = Ip2RegionResolver()
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.location not in {"公网", "未知"}
|
||||
@@ -5,8 +5,19 @@
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.permission_monitor import get_permission_monitor, set_permission_monitor, PermissionMonitor
|
||||
from app.api.v1 import permission_monitoring
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
def __init__(self, province: str, city: str) -> None:
|
||||
self.country = "中国"
|
||||
self.province = province
|
||||
self.city = city
|
||||
self.isp = "电信"
|
||||
self.location = f"中国 / {province} / {city} / 电信"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -205,3 +216,242 @@ async def test_permission_system_health_includes_metrics(client: TestClient, aut
|
||||
assert "cache_stats" in data
|
||||
assert "check_metrics" in data["metrics"]
|
||||
assert "cache_metrics" in data["metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
||||
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000000001"
|
||||
user_a = "00000000-0000-0000-0000-000000000101"
|
||||
user_b = "00000000-0000-0000-0000-000000000102"
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": study_id,
|
||||
"code": "IP-LOCATION-STUDY",
|
||||
"name": "IP Location Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
for user_id, email in [(user_a, "user-a@example.com"), (user_b, "user-b@example.com")]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": email,
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
(study_id, user_a, "10.1.1.1", True),
|
||||
(study_id, user_a, "10.1.1.1", False),
|
||||
(study_id, user_b, "10.1.1.2", True),
|
||||
]
|
||||
for study, user, ip_address, allowed in rows:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
VALUES
|
||||
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
||||
substr(lower(hex(randomblob(2))),2) || '-' ||
|
||||
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
||||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
|
||||
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"study_id": study,
|
||||
"user_id": user,
|
||||
"endpoint_key": "admin.permissions.read",
|
||||
"role": "PM",
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
||||
|
||||
assert result["items"][0]["province"] == "广东省"
|
||||
assert result["items"][0]["total_count"] == 3
|
||||
assert result["items"][0]["unique_ip_count"] == 2
|
||||
assert result["items"][0]["unique_user_count"] == 2
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["unique_ip_count"] == 2
|
||||
assert result["summary"]["unique_user_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
"""访问日志应返回用户行为审计汇总和用户排行。"""
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000000201"
|
||||
user_a = "00000000-0000-0000-0000-000000000301"
|
||||
user_b = "00000000-0000-0000-0000-000000000302"
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": study_id,
|
||||
"code": "ACCESS-LOG-STUDY",
|
||||
"name": "Access Log Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
for user_id, email, name, role in [
|
||||
(user_a, "audit-a@example.com", "审计用户A", "PM"),
|
||||
(user_b, "audit-b@example.com", "审计用户B", "CRA"),
|
||||
]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": name,
|
||||
"role": role,
|
||||
"clinical_department": "临床运营",
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
(study_id, user_a, "admin.permissions.read", "PM", True, 3.0, "10.1.1.1"),
|
||||
(study_id, user_a, "admin.users.delete", "PM", False, 9.0, "10.1.1.2"),
|
||||
(study_id, user_b, "admin.permissions.read", "CRA", True, 6.0, "10.1.1.3"),
|
||||
]
|
||||
for study, user, endpoint, role, allowed, elapsed_ms, ip_address in rows:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
VALUES
|
||||
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
||||
substr(lower(hex(randomblob(2))),2) || '-' ||
|
||||
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
||||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
|
||||
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"study_id": study,
|
||||
"user_id": user,
|
||||
"endpoint_key": endpoint,
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
study_id=None,
|
||||
user_id=None,
|
||||
endpoint_key=None,
|
||||
role=None,
|
||||
allowed=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
page=1,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["unique_user_count"] == 2
|
||||
assert result["summary"]["denied_count"] == 1
|
||||
assert result["summary"]["avg_elapsed_ms"] == 6.0
|
||||
assert result["user_stats"][0]["user_name"] == "审计用户A"
|
||||
assert result["user_stats"][0]["total_count"] == 1
|
||||
assert result["user_stats"][0]["denied_count"] in {0, 1}
|
||||
assert result["user_stats"][0]["unique_ip_count"] == 1
|
||||
assert result["user_stats"][0]["sample_ip_address"] in {"10.1.1.1", "10.1.1.2"}
|
||||
user_a_ips = {
|
||||
stat["sample_ip_address"]
|
||||
for stat in result["user_stats"]
|
||||
if stat["user_name"] == "审计用户A"
|
||||
}
|
||||
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
||||
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5,
|
||||
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
assert result["summary"]["total_count"] == 1
|
||||
assert result["summary"]["anonymous_count"] == 1
|
||||
assert result["summary"]["error_count"] == 1
|
||||
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
||||
assert result["items"][0]["account_label"] == "未知账号"
|
||||
assert result["items"][0]["status_code"] == 401
|
||||
|
||||
Reference in New Issue
Block a user