修复(权限管理):统一 PM 系统导航与权限校验
This commit is contained in:
@@ -602,6 +602,7 @@ async def get_access_logs(
|
||||
end_time: Optional[datetime] = Query(None),
|
||||
client_ip: Optional[str] = Query(None),
|
||||
client_type: Optional[str] = Query(None),
|
||||
event_type: str = Query("all", pattern="^(all|permission)$"),
|
||||
keyword: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
@@ -660,7 +661,14 @@ async def get_access_logs(
|
||||
keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips))
|
||||
conditions.append(or_(*keyword_conditions))
|
||||
|
||||
include_security_logs = scope.is_admin and study_id is None and role is None and endpoint_key is None and allowed is not True
|
||||
include_security_logs = (
|
||||
event_type != "permission"
|
||||
and scope.is_admin
|
||||
and study_id is None
|
||||
and role is None
|
||||
and endpoint_key is None
|
||||
and allowed is not True
|
||||
)
|
||||
event_conditions = list(conditions)
|
||||
if include_security_logs:
|
||||
matching_security_event = select(SecurityAccessLog.id).where(
|
||||
@@ -1118,81 +1126,352 @@ async def get_trends(
|
||||
_=Depends(get_current_user),
|
||||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||||
) -> dict:
|
||||
"""获取趋势数据(从快照表或实时聚合)"""
|
||||
"""获取权限检查趋势、周期摘要和归因数据。"""
|
||||
scope = await resolve_monitoring_scope(db, _)
|
||||
if not scope.is_admin and not scope.study_ids:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
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]
|
||||
bucket_delta, bucket_count = _trend_bucket_config(period)
|
||||
current_bucket = _trend_bucket_time(now, period)
|
||||
start_time = current_bucket - bucket_delta * (bucket_count - 1)
|
||||
current_duration = now - start_time
|
||||
previous_end = start_time
|
||||
previous_start = previous_end - current_duration
|
||||
|
||||
# 先尝试从快照表获取
|
||||
snapshot_query = (
|
||||
select(PermissionMetricSnapshot)
|
||||
.where(PermissionMetricSnapshot.bucket_time >= start_time)
|
||||
.order_by(PermissionMetricSnapshot.bucket_time)
|
||||
log_buckets = await _load_trend_log_buckets(db, scope, start_time, now, period)
|
||||
cache_buckets, current_cache, previous_cache = await _load_trend_cache_buckets(
|
||||
db,
|
||||
scope,
|
||||
previous_start,
|
||||
previous_end,
|
||||
start_time,
|
||||
now,
|
||||
period,
|
||||
)
|
||||
result = await db.execute(snapshot_query)
|
||||
snapshots = result.scalars().all()
|
||||
summary = await _load_trend_summary(db, scope, start_time, now)
|
||||
previous_summary = await _load_trend_summary(db, scope, previous_start, previous_end)
|
||||
summary.update(current_cache)
|
||||
previous_summary.update(previous_cache)
|
||||
previous_summary.pop("observed_end_at", None)
|
||||
|
||||
if snapshots and scope.is_admin:
|
||||
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。
|
||||
trend_query = select(
|
||||
PermissionAccessLog.created_at,
|
||||
PermissionAccessLog.allowed,
|
||||
PermissionAccessLog.elapsed_ms,
|
||||
).where(PermissionAccessLog.created_at >= start_time)
|
||||
result = await db.execute(
|
||||
_apply_monitoring_scope_to_log_query(trend_query, scope)
|
||||
)
|
||||
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))
|
||||
data_points = []
|
||||
for index in range(bucket_count):
|
||||
bucket_time = start_time + bucket_delta * index
|
||||
values = log_buckets.get(bucket_time, _empty_trend_bucket())
|
||||
cache_values = cache_buckets.get(bucket_time, {"cache_hits": 0, "cache_misses": 0})
|
||||
cache_total = int(cache_values["cache_hits"]) + int(cache_values["cache_misses"])
|
||||
total = int(values["total"])
|
||||
data_points.append(
|
||||
{
|
||||
"bucket_time": bucket_time.isoformat(),
|
||||
"sample_state": "partial" if bucket_time == current_bucket else "complete",
|
||||
"total_checks": total,
|
||||
"allowed_checks": int(values["allowed"]),
|
||||
"denied_checks": int(values["denied"]),
|
||||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / total, 2) if total else 0,
|
||||
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||||
"cache_hits": int(cache_values["cache_hits"]),
|
||||
"cache_misses": int(cache_values["cache_misses"]),
|
||||
"cache_hit_rate": round(int(cache_values["cache_hits"]) / cache_total * 100, 1) if cache_total else 0,
|
||||
"cache_sample_available": cache_total > 0,
|
||||
"error_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"data_points": [
|
||||
"generated_at": now.isoformat(),
|
||||
"range": {
|
||||
"start_at": start_time.isoformat(),
|
||||
"end_at": now.isoformat(),
|
||||
"previous_start_at": previous_start.isoformat(),
|
||||
"previous_end_at": previous_end.isoformat(),
|
||||
"bucket_seconds": int(bucket_delta.total_seconds()),
|
||||
"bucket_count": bucket_count,
|
||||
"timezone": "UTC",
|
||||
"observed_end_at": summary.pop("observed_end_at"),
|
||||
},
|
||||
"summary": summary,
|
||||
"previous_summary": previous_summary,
|
||||
"attribution": await _load_trend_attribution(db, scope, start_time, now),
|
||||
"data_points": data_points,
|
||||
}
|
||||
|
||||
|
||||
def _trend_bucket_config(period: str) -> tuple[timedelta, int]:
|
||||
return {
|
||||
"24h": (timedelta(hours=1), 24),
|
||||
"7d": (timedelta(hours=6), 28),
|
||||
"30d": (timedelta(days=1), 30),
|
||||
}[period]
|
||||
|
||||
|
||||
def _empty_trend_bucket() -> dict[str, float | int]:
|
||||
return {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||||
|
||||
|
||||
def _merge_trend_bucket(target: dict[str, float | int], *, total: int, allowed: int, denied: int, elapsed_sum: float, max_ms: float) -> None:
|
||||
target["total"] = int(target["total"]) + total
|
||||
target["allowed"] = int(target["allowed"]) + allowed
|
||||
target["denied"] = int(target["denied"]) + denied
|
||||
target["elapsed_sum"] = float(target["elapsed_sum"]) + elapsed_sum
|
||||
target["max_ms"] = max(float(target["max_ms"]), max_ms)
|
||||
|
||||
|
||||
async def _load_trend_log_buckets(
|
||||
db: AsyncSession,
|
||||
scope: MonitoringScope,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
period: str,
|
||||
) -> dict[datetime, dict[str, float | int]]:
|
||||
"""PostgreSQL 先按小时聚合,SQLite 测试库使用 Python 分桶。"""
|
||||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(_empty_trend_bucket)
|
||||
dialect_name = db.get_bind().dialect.name
|
||||
if dialect_name == "postgresql":
|
||||
hour_bucket = func.date_trunc("hour", PermissionAccessLog.created_at).label("hour_bucket")
|
||||
query = select(
|
||||
hour_bucket,
|
||||
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.sum(PermissionAccessLog.elapsed_ms), 0).label("elapsed_sum"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
).group_by(hour_bucket)
|
||||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||
for row in result.all():
|
||||
bucket_time = _trend_bucket_time(row.hour_bucket, period)
|
||||
_merge_trend_bucket(
|
||||
buckets[bucket_time],
|
||||
total=int(row.total or 0),
|
||||
allowed=int(row.allowed or 0),
|
||||
denied=int(row.denied or 0),
|
||||
elapsed_sum=float(row.elapsed_sum or 0),
|
||||
max_ms=float(row.max_ms or 0),
|
||||
)
|
||||
return buckets
|
||||
|
||||
query = select(
|
||||
PermissionAccessLog.created_at,
|
||||
PermissionAccessLog.allowed,
|
||||
PermissionAccessLog.elapsed_ms,
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
)
|
||||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||
for created_at, allowed, elapsed_ms in result.all():
|
||||
bucket_time = _trend_bucket_time(created_at, period)
|
||||
_merge_trend_bucket(
|
||||
buckets[bucket_time],
|
||||
total=1,
|
||||
allowed=1 if allowed else 0,
|
||||
denied=0 if allowed else 1,
|
||||
elapsed_sum=float(elapsed_ms or 0),
|
||||
max_ms=float(elapsed_ms or 0),
|
||||
)
|
||||
return buckets
|
||||
|
||||
|
||||
def _percentile(values: list[float], percentile: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
position = (len(ordered) - 1) * percentile
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
fraction = position - lower
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
||||
|
||||
|
||||
async def _load_trend_summary(
|
||||
db: AsyncSession,
|
||||
scope: MonitoringScope,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> dict:
|
||||
query = select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow"),
|
||||
func.count(func.distinct(PermissionAccessLog.study_id)).label("studies"),
|
||||
func.count(func.distinct(PermissionAccessLog.user_id)).label("users"),
|
||||
func.count(func.distinct(PermissionAccessLog.endpoint_key)).label("endpoints"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
func.max(PermissionAccessLog.created_at).label("observed_end_at"),
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
)
|
||||
row = (await db.execute(_apply_monitoring_scope_to_log_query(query, scope))).one()
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
percentile_query = select(
|
||||
func.percentile_cont(0.95).within_group(PermissionAccessLog.elapsed_ms)
|
||||
).where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
)
|
||||
percentile_result = await db.execute(
|
||||
_apply_monitoring_scope_to_log_query(percentile_query, scope)
|
||||
)
|
||||
p95_ms = float(percentile_result.scalar() or 0)
|
||||
else:
|
||||
elapsed_query = select(PermissionAccessLog.elapsed_ms).where(
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
)
|
||||
elapsed_result = await db.execute(
|
||||
_apply_monitoring_scope_to_log_query(elapsed_query, scope)
|
||||
)
|
||||
p95_ms = _percentile(
|
||||
[float(value) for value in elapsed_result.scalars().all()],
|
||||
0.95,
|
||||
)
|
||||
total = int(row.total or 0)
|
||||
denied = int(row.denied or 0)
|
||||
return {
|
||||
"total_checks": total,
|
||||
"allowed_checks": int(row.allowed or 0),
|
||||
"denied_checks": denied,
|
||||
"deny_rate": round(denied / total * 100, 2) if total else 0,
|
||||
"avg_elapsed_ms": round(float(row.avg_ms or 0), 2),
|
||||
"p95_elapsed_ms": round(p95_ms, 2),
|
||||
"max_elapsed_ms": round(float(row.max_ms or 0), 2),
|
||||
"slow_check_count": int(row.slow or 0),
|
||||
"active_study_count": int(row.studies or 0),
|
||||
"active_user_count": int(row.users or 0),
|
||||
"active_endpoint_count": int(row.endpoints or 0),
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_hit_rate": 0,
|
||||
"cache_sample_available": False,
|
||||
"observed_end_at": row.observed_end_at.isoformat() if row.observed_end_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _load_trend_cache_buckets(
|
||||
db: AsyncSession,
|
||||
scope: MonitoringScope,
|
||||
previous_start: datetime,
|
||||
previous_end: datetime,
|
||||
current_start: datetime,
|
||||
current_end: datetime,
|
||||
period: str,
|
||||
) -> tuple[dict[datetime, dict[str, int]], dict, dict]:
|
||||
empty_summary = {"cache_hits": 0, "cache_misses": 0, "cache_hit_rate": 0, "cache_sample_available": False}
|
||||
if not scope.is_admin:
|
||||
return {}, dict(empty_summary), dict(empty_summary)
|
||||
result = await db.execute(
|
||||
select(PermissionMetricSnapshot).where(
|
||||
PermissionMetricSnapshot.bucket_time >= previous_start,
|
||||
PermissionMetricSnapshot.bucket_time <= current_end,
|
||||
)
|
||||
)
|
||||
buckets: dict[datetime, dict[str, int]] = defaultdict(lambda: {"cache_hits": 0, "cache_misses": 0})
|
||||
current_counts = {"cache_hits": 0, "cache_misses": 0}
|
||||
previous_counts = {"cache_hits": 0, "cache_misses": 0}
|
||||
for snapshot in result.scalars().all():
|
||||
snapshot_time = snapshot.bucket_time
|
||||
if snapshot_time.tzinfo is None:
|
||||
snapshot_time = snapshot_time.replace(tzinfo=timezone.utc)
|
||||
if snapshot_time >= current_start:
|
||||
current_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||
current_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||
bucket = buckets[_trend_bucket_time(snapshot_time, period)]
|
||||
bucket["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||
bucket["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||
elif snapshot_time < previous_end:
|
||||
previous_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||
previous_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||
|
||||
def serialize(counts: dict[str, int]) -> dict:
|
||||
total = counts["cache_hits"] + counts["cache_misses"]
|
||||
return {
|
||||
**counts,
|
||||
"cache_hit_rate": round(counts["cache_hits"] / total * 100, 2) if total else 0,
|
||||
"cache_sample_available": total > 0,
|
||||
}
|
||||
|
||||
return dict(buckets), serialize(current_counts), serialize(previous_counts)
|
||||
|
||||
|
||||
async def _load_trend_attribution(
|
||||
db: AsyncSession,
|
||||
scope: MonitoringScope,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> dict:
|
||||
base_conditions = (
|
||||
PermissionAccessLog.created_at >= start_time,
|
||||
PermissionAccessLog.created_at <= end_time,
|
||||
)
|
||||
denied_query = select(
|
||||
PermissionAccessLog.endpoint_key,
|
||||
PermissionAccessLog.role,
|
||||
func.count().label("denied_count"),
|
||||
).where(*base_conditions, PermissionAccessLog.allowed.is_(False)).group_by(
|
||||
PermissionAccessLog.endpoint_key,
|
||||
PermissionAccessLog.role,
|
||||
).order_by(desc("denied_count")).limit(5)
|
||||
denied_rows = (await db.execute(_apply_monitoring_scope_to_log_query(denied_query, scope))).all()
|
||||
|
||||
slow_query = select(
|
||||
PermissionAccessLog.endpoint_key,
|
||||
func.count().label("sample_count"),
|
||||
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow_count"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||
).where(*base_conditions).group_by(PermissionAccessLog.endpoint_key).order_by(
|
||||
desc("slow_count"), desc("avg_ms"), desc("sample_count")
|
||||
).limit(5)
|
||||
slow_rows = (await db.execute(_apply_monitoring_scope_to_log_query(slow_query, scope))).all()
|
||||
|
||||
client_type = func.coalesce(PermissionAccessLog.client_type, "unknown")
|
||||
client_version = func.coalesce(PermissionAccessLog.client_version, "-")
|
||||
client_platform = func.coalesce(PermissionAccessLog.client_platform, "-")
|
||||
client_query = select(
|
||||
client_type.label("client_type"),
|
||||
client_version.label("client_version"),
|
||||
client_platform.label("client_platform"),
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||
).where(*base_conditions).group_by(client_type, client_version, client_platform).order_by(
|
||||
desc("total_count")
|
||||
).limit(5)
|
||||
client_rows = (await db.execute(_apply_monitoring_scope_to_log_query(client_query, scope))).all()
|
||||
|
||||
return {
|
||||
"top_denied": [
|
||||
{"endpoint_key": row.endpoint_key, "role": row.role, "denied_count": int(row.denied_count)}
|
||||
for row in denied_rows
|
||||
],
|
||||
"top_slow": [
|
||||
{
|
||||
"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,
|
||||
"endpoint_key": row.endpoint_key,
|
||||
"sample_count": int(row.sample_count),
|
||||
"slow_count": int(row.slow_count),
|
||||
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||||
"max_elapsed_ms": round(float(row.max_ms), 2),
|
||||
}
|
||||
for bucket, values in sorted(buckets.items())
|
||||
for row in slow_rows
|
||||
],
|
||||
"client_breakdown": [
|
||||
{
|
||||
"client_type": row.client_type,
|
||||
"client_version": row.client_version,
|
||||
"client_platform": row.client_platform,
|
||||
"total_count": int(row.total_count),
|
||||
"denied_count": int(row.denied_count),
|
||||
"deny_rate": round(int(row.denied_count) / int(row.total_count) * 100, 2) if row.total_count else 0,
|
||||
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||||
}
|
||||
for row in client_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.schemas.common import PaginatedResponse
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities
|
||||
|
||||
router = APIRouter()
|
||||
@@ -21,11 +21,16 @@ async def list_users(
|
||||
limit: int = 100,
|
||||
keyword: str | None = Query(default=None),
|
||||
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||
login_status: LoginStatus | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
||||
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
||||
users = await user_crud.list_users(
|
||||
db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status
|
||||
)
|
||||
total_users = await user_crud.count_users(
|
||||
db, keyword=keyword, status=user_status, login_status=login_status
|
||||
)
|
||||
summaries = await get_login_summaries(db, [user.id for user in users])
|
||||
items = []
|
||||
for user in users:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal, Sequence
|
||||
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,12 +12,14 @@ from app.core.config import (
|
||||
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
||||
PROTECTED_ADMIN_EMAIL,
|
||||
PROTECTED_ADMIN_FULL_NAME,
|
||||
settings,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserStatus
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
||||
|
||||
|
||||
@@ -81,7 +84,13 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
||||
return user
|
||||
|
||||
|
||||
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None):
|
||||
def _apply_user_filters(
|
||||
query,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||
):
|
||||
if keyword:
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
query = query.where(
|
||||
@@ -93,6 +102,18 @@ def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus
|
||||
)
|
||||
if status is not None:
|
||||
query = query.where(User.status == status)
|
||||
if login_status is not None:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||
has_online_session = (
|
||||
select(UserLoginSession.id)
|
||||
.where(
|
||||
UserLoginSession.user_id == User.id,
|
||||
UserLoginSession.ended_at.is_(None),
|
||||
UserLoginSession.last_seen_at >= cutoff,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
query = query.where(has_online_session if login_status == "ONLINE" else ~has_online_session)
|
||||
return query
|
||||
|
||||
|
||||
@@ -103,8 +124,9 @@ async def list_users(
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||
) -> Sequence[User]:
|
||||
query = _apply_user_filters(select(User), keyword=keyword, status=status)
|
||||
query = _apply_user_filters(select(User), keyword=keyword, status=status, login_status=login_status)
|
||||
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -114,8 +136,14 @@ async def count_users(
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||
) -> int:
|
||||
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status)
|
||||
query = _apply_user_filters(
|
||||
select(func.count()).select_from(User),
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
login_status=login_status,
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
@@ -565,6 +565,16 @@ def test_project_permission_config_is_system_level_permission():
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||
|
||||
|
||||
def test_pm_system_management_navigation_matches_backend_permission_contract():
|
||||
"""PM 系统管理导航中的审计日志和权限管理应与后端角色权限保持一致。"""
|
||||
assert "PM" in API_ENDPOINT_PERMISSIONS["audit_logs:read"]["default_roles"]
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:read"]["roles"]
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||
|
||||
audit_route = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||
assert 'require_api_permission("audit_logs:read")' in audit_route.read_text()
|
||||
|
||||
|
||||
def test_project_api_permission_routes_use_system_project_config_permission():
|
||||
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
|
||||
|
||||
@@ -69,7 +69,19 @@ async def test_system_permission_monitoring_definitions_are_admin_only():
|
||||
assert all("PM" not in item["roles"] for item in monitoring_items)
|
||||
|
||||
|
||||
async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None:
|
||||
async def _seed_permission_log(
|
||||
db_session,
|
||||
study_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
allowed: bool,
|
||||
elapsed_ms: float,
|
||||
endpoint_key: str = "admin.permissions.read",
|
||||
role: str = "PM",
|
||||
client_type: str | None = None,
|
||||
client_version: str | None = None,
|
||||
client_platform: str | None = None,
|
||||
) -> None:
|
||||
study_exists = (
|
||||
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
|
||||
).scalar_one_or_none()
|
||||
@@ -119,21 +131,24 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
|
||||
request_snapshot, created_at)
|
||||
client_type, client_version, client_platform, request_snapshot, created_at)
|
||||
VALUES
|
||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
||||
:request_snapshot, CURRENT_TIMESTAMP)
|
||||
:client_type, :client_version, :client_platform, :request_snapshot, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"study_id": str(study_id),
|
||||
"user_id": str(user_id),
|
||||
"endpoint_key": "admin.permissions.read",
|
||||
"role": "PM",
|
||||
"endpoint_key": endpoint_key,
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": "127.0.0.1",
|
||||
"client_type": client_type,
|
||||
"client_version": client_version,
|
||||
"client_platform": client_platform,
|
||||
"request_snapshot": None,
|
||||
},
|
||||
)
|
||||
@@ -159,6 +174,53 @@ async def test_get_permission_metrics(db_session):
|
||||
assert data["check_metrics"]["denied_checks"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trends_returns_dense_summary_comparison_and_attribution(db_session):
|
||||
baseline = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||||
study_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
await _seed_permission_log(
|
||||
db_session,
|
||||
study_id,
|
||||
user_id,
|
||||
allowed=True,
|
||||
elapsed_ms=5,
|
||||
endpoint_key="subjects.read",
|
||||
client_type="web",
|
||||
client_version="0.1.0",
|
||||
client_platform="macos",
|
||||
)
|
||||
await _seed_permission_log(
|
||||
db_session,
|
||||
study_id,
|
||||
user_id,
|
||||
allowed=False,
|
||||
elapsed_ms=80,
|
||||
endpoint_key="subjects.export",
|
||||
client_type="web",
|
||||
client_version="0.1.0",
|
||||
client_platform="macos",
|
||||
)
|
||||
|
||||
data = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||||
|
||||
assert data["period"] == "24h"
|
||||
assert data["range"]["bucket_seconds"] == 3600
|
||||
assert len(data["data_points"]) == 24
|
||||
assert data["data_points"][-1]["sample_state"] == "partial"
|
||||
assert data["summary"]["total_checks"] == baseline["summary"]["total_checks"] + 2
|
||||
assert data["summary"]["denied_checks"] == baseline["summary"]["denied_checks"] + 1
|
||||
assert data["summary"]["slow_check_count"] == baseline["summary"]["slow_check_count"] + 1
|
||||
assert data["summary"]["active_study_count"] == baseline["summary"]["active_study_count"] + 1
|
||||
assert data["summary"]["active_user_count"] == baseline["summary"]["active_user_count"] + 1
|
||||
assert data["summary"]["active_endpoint_count"] >= 2
|
||||
assert data["summary"]["max_elapsed_ms"] >= data["summary"]["p95_elapsed_ms"]
|
||||
assert data["previous_summary"]["total_checks"] == 0
|
||||
assert any(item["endpoint_key"] == "subjects.export" for item in data["attribution"]["top_denied"])
|
||||
assert data["attribution"]["top_slow"][0]["endpoint_key"] == "subjects.export"
|
||||
assert any(item["client_type"] == "web" for item in data["attribution"]["client_breakdown"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cache_statistics(db_session):
|
||||
"""测试获取缓存统计"""
|
||||
@@ -1206,6 +1268,26 @@ async def test_access_logs_include_security_events_for_admin(db_session, monkeyp
|
||||
assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview"
|
||||
assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
|
||||
|
||||
permission_only = await permission_monitoring.get_access_logs(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
study_id=None,
|
||||
user_id=None,
|
||||
endpoint_key=None,
|
||||
role=None,
|
||||
allowed=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
client_ip=None,
|
||||
client_type=None,
|
||||
event_type="permission",
|
||||
keyword=None,
|
||||
page=1,
|
||||
page_size=50,
|
||||
)
|
||||
assert permission_only["total"] == 1
|
||||
assert permission_only["items"][0]["event_type"] == "permission"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserStatus
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
||||
@@ -59,3 +60,44 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo
|
||||
assert summaries[user.id].status == "ONLINE"
|
||||
assert summaries[user.id].active_session_count == 1
|
||||
assert summaries[user.id].client_type == "desktop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
online_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="online-filter@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Online Filter",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
offline_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="offline-filter@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Offline Filter",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add_all([online_user, offline_user])
|
||||
db_session.add(
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=online_user.id,
|
||||
client_type="web",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=10),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
online = await user_crud.list_users(db_session, login_status="ONLINE")
|
||||
offline = await user_crud.list_users(db_session, login_status="OFFLINE")
|
||||
|
||||
assert online_user.id in {user.id for user in online}
|
||||
assert offline_user.id not in {user.id for user in online}
|
||||
assert offline_user.id in {user.id for user in offline}
|
||||
assert await user_crud.count_users(db_session, login_status="ONLINE") == len(online)
|
||||
|
||||
Reference in New Issue
Block a user