完善权限监控与安全中心

This commit is contained in:
Cheng Zhou
2026-06-17 17:21:48 +08:00
parent 1886765db8
commit 061792c73f
15 changed files with 2837 additions and 160 deletions
+100 -13
View File
@@ -20,7 +20,7 @@ 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
from app.services.ip_location import IpLocation, resolve_ip_location
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
@@ -266,17 +266,13 @@ async def get_access_logs(
).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)
.group_by(PermissionAccessLog.ip_address)
.order_by(desc("total_count"), desc("last_seen_at"))
.limit(10)
)
@@ -292,6 +288,26 @@ async def get_access_logs(
summary_row = summary_result.one()
user_stats_result = await db.execute(user_stats_query)
user_stats_rows = user_stats_result.all()
latest_user_by_ip = {}
if user_stats_rows:
ranked_ips = [stat.sample_ip_address for stat in user_stats_rows if stat.sample_ip_address]
latest_user_query = (
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
User.full_name,
PermissionAccessLog.role,
PermissionAccessLog.created_at,
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.where(PermissionAccessLog.ip_address.in_(ranked_ips))
.order_by(PermissionAccessLog.ip_address, desc(PermissionAccessLog.created_at))
)
for condition in conditions:
latest_user_query = latest_user_query.where(condition)
latest_user_result = await db.execute(latest_user_query)
for row in latest_user_result.all():
latest_user_by_ip.setdefault(row.ip_address, row)
query = query.order_by(desc(PermissionAccessLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
@@ -323,11 +339,12 @@ async def get_access_logs(
user_stats = []
for stat in user_stats_rows:
sample_location = resolve_ip_location(stat.sample_ip_address)
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
user_stats.append(
{
"user_id": str(stat.user_id),
"user_name": stat.full_name or "未知用户",
"role": stat.role,
"user_id": str(latest_user.user_id) if latest_user else "",
"user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
"role": latest_user.role if latest_user else "",
"total_count": stat.total_count,
"denied_count": stat.denied_count,
"unique_ip_count": stat.unique_ip_count,
@@ -361,6 +378,43 @@ def _security_account_label(auth_status: str, user_identifier: str | None, user_
return "未知账号"
SENSITIVE_PROBE_MARKERS = (
"/.env",
".env",
"/.git",
".git/config",
"backup",
"config.php",
"wp-config",
"database.yml",
)
CHINA_IP_COUNTRY_LABELS = {"中国", "China", "Mainland China", "中国香港", "中国澳门", "中国台湾"}
def _is_non_china_ip(ip_location: IpLocation) -> bool:
country = (ip_location.country or "").strip()
return bool(country) and country not in CHINA_IP_COUNTRY_LABELS and not country.startswith("中国")
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
if ip_location and _is_non_china_ip(ip_location):
return {"category": "ABNORMAL_IP", "severity": "HIGH"}
path = (log.path or "").lower()
if any(marker in path for marker in SENSITIVE_PROBE_MARKERS):
return {"category": "PROBE", "severity": "CRITICAL"}
if log.status_code >= 500:
return {"category": "SERVER_ERROR", "severity": "HIGH"}
if log.auth_status == "INVALID_TOKEN":
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
if log.auth_status == "ANONYMOUS" and log.status_code in {401, 403}:
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
if log.status_code == 404:
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
return {"category": "OTHER", "severity": "LOW"}
@router.get("/security-logs", status_code=status.HTTP_200_OK)
async def get_security_access_logs(
db: AsyncSession = Depends(get_db_session),
@@ -418,6 +472,7 @@ async def get_security_access_logs(
items = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
items.append(
{
"id": str(log.id),
@@ -426,10 +481,16 @@ async def get_security_access_logs(
"status_code": log.status_code,
"elapsed_ms": round(log.elapsed_ms, 2),
"client_ip": log.client_ip,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"user_agent": log.user_agent,
"auth_status": log.auth_status,
"user_identifier": log.user_identifier,
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
**_classify_security_access_log(log, ip_location),
"created_at": log.created_at.isoformat(),
}
)
@@ -623,14 +684,26 @@ async def get_ip_locations(
)
)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
security_result = await db.execute(
select(
SecurityAccessLog.client_ip,
SecurityAccessLog.user_identifier,
SecurityAccessLog.auth_status,
SecurityAccessLog.status_code,
).where(
SecurityAccessLog.created_at >= start_time,
SecurityAccessLog.client_ip.is_not(None),
)
)
buckets: dict[tuple[str, str, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[uuid.UUID] = set()
all_user_ids: set[str] = set()
total_count = 0
allowed_count = 0
denied_count = 0
for ip_address, user_id, allowed in result.all():
def add_ip_location_row(ip_address: str, user_id: str | uuid.UUID | None, allowed: bool) -> None:
nonlocal total_count, allowed_count, denied_count
ip_info = resolve_ip_location(ip_address)
key = (ip_info.country, ip_info.province, ip_info.city)
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
@@ -652,14 +725,28 @@ async def get_ip_locations(
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)
if user_id:
bucket["user_ids"].add(str(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)
if user_id:
all_user_ids.add(str(user_id))
for ip_address, user_id, allowed in result.all():
add_ip_location_row(ip_address, user_id, allowed)
for client_ip, user_identifier, auth_status, status_code in security_result.all():
user_id = None
if auth_status == "AUTHENTICATED" and user_identifier:
try:
user_id = uuid.UUID(user_identifier)
except ValueError:
user_id = user_identifier
add_ip_location_row(client_ip, user_id, status_code < 400)
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
return {