权限监控与管理界面全面美化

重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
+12 -3
View File
@@ -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] = {}
+540 -35
View File
@@ -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),
},
}
+18 -1
View File
@@ -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}
+42 -6
View File
@@ -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",
],
},
}
+2
View File
@@ -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
+54 -4
View File
@@ -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,
+5 -1
View File
@@ -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] = {}
+1
View File
@@ -0,0 +1 @@
+225
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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()
)
+37
View File
@@ -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()
)
+25 -5
View File
@@ -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
+129
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+270
View File
@@ -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()