修改用户设置的新增用户的权限点击操作

This commit is contained in:
wangjiming
2026-08-17 16:04:04 +08:00
parent 6c1bf61ff7
commit 4e27b98a84
12 changed files with 1065 additions and 192 deletions

View File

@@ -16,6 +16,7 @@ import httpx
from app.core.auth import filter_accessible_resource_ids, filter_accessible_resource_ids_batch, get_current_user, has_resource_access, is_admin
from app.core.config import get_settings
from app.core.audit import audit_log, AuditActions
from app.db.platform_store import get_platform_store
from app.modules.compute_gateway.client import ComputeNodeClient
from app.modules.compute_gateway.sync import fetch_eval_result_content, poll_compute_jobs_once
@@ -884,6 +885,11 @@ async def test_online_model(payload: dict[str, Any] = Body(...)) -> dict[str, An
@router.post("/model-manage")
@audit_log(
action=AuditActions.CREATE_MODEL,
target_type="model",
detail_template="创建模型: {name}",
)
async def create_model(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
payload.setdefault("created_by", current_user.get("id"))
try:
@@ -908,6 +914,11 @@ async def model_detail(model_id: str, current_user: dict = Depends(get_current_u
@router.put("/model-manage/{model_id}")
@audit_log(
action=AuditActions.UPDATE_MODEL,
target_type="model",
detail_template="更新模型: {model_id}",
)
async def update_model(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return ok(get_platform_store().update_model(model_id, payload))
@@ -1266,6 +1277,11 @@ async def dataset_list(current_user: dict = Depends(get_current_user)) -> dict[s
@router.post("/dataset-manage")
@audit_log(
action=AuditActions.CREATE_DATASET,
target_type="dataset",
detail_template="创建数据集: {name}",
)
async def create_dataset(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
payload.setdefault("created_by", current_user.get("id"))
dataset = get_platform_store().create_dataset(payload)
@@ -1284,6 +1300,11 @@ async def dataset_detail(dataset_id: str, current_user: dict = Depends(get_curre
@router.put("/dataset-manage/{dataset_id}")
@audit_log(
action=AuditActions.UPDATE_DATASET,
target_type="dataset",
detail_template="更新数据集: {dataset_id}",
)
async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return ok(get_platform_store().update_dataset(dataset_id, payload))
@@ -1292,6 +1313,11 @@ async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -
@router.delete("/dataset-manage/{dataset_id}")
@audit_log(
action=AuditActions.DELETE_DATASET,
target_type="dataset",
detail_template="删除数据集: {dataset_id}",
)
async def delete_dataset(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
if not has_resource_access("dataset", dataset_id, current_user, "delete"):
raise fail(403, "no permission to delete this dataset")
@@ -1331,6 +1357,11 @@ async def fine_tune_list(current_user: dict = Depends(get_current_user)) -> dict
@router.post("/fine-tune")
@audit_log(
action=AuditActions.CREATE_FINE_TUNE,
target_type="fine_tune",
detail_template="创建微调任务: {name}",
)
async def create_fine_tune(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
payload.setdefault("created_by", current_user.get("id"))
if not is_admin(current_user):

199
backend/app/core/audit.py Normal file
View File

@@ -0,0 +1,199 @@
"""
审计日志装饰器模块
提供 @audit_log 装饰器,用于自动记录关键业务操作的审计日志。
使用示例:
from app.core.audit import audit_log
@audit_log(action="create_dataset", target_type="dataset")
async def create_dataset(request: Request, ...):
...
"""
from __future__ import annotations
import asyncio
import functools
import time
from datetime import datetime, timezone
from typing import Any, Callable, Optional, TypeVar
from fastapi import Request
from app.core.logging import get_logger, request_id_var
logger = get_logger("app.audit")
F = TypeVar("F", bound=Callable[..., Any])
def audit_log(
action: str,
target_type: str = "",
*,
detail_template: str = "",
extract_target_id: Optional[Callable[[Any], str]] = None,
) -> Callable[[F], F]:
"""
审计日志装饰器
Args:
action: 操作类型,如 create_dataset、update_model 等
target_type: 目标资源类型,如 dataset、model 等
detail_template: 日志详情模板(支持 format 参数)
extract_target_id: 从返回值中提取目标 ID 的函数
Returns:
装饰后的函数
"""
def decorator(func: F) -> F:
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
started_at = time.perf_counter()
trace_id = request_id_var.get("-")
try:
result = await func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - started_at) * 1000
target_id = _extract_target_id(result, kwargs, extract_target_id)
detail = _build_detail(detail_template, kwargs)
_record_audit(
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
return result
except Exception:
logger.error(
"审计日志记录失败 action=%s", action, exc_info=True
)
raise
return async_wrapper # type: ignore
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
started_at = time.perf_counter()
trace_id = request_id_var.get("-")
try:
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - started_at) * 1000
target_id = _extract_target_id(result, kwargs, extract_target_id)
detail = _build_detail(detail_template, kwargs)
_record_audit(
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
return result
except Exception:
logger.error(
"审计日志记录失败 action=%s", action, exc_info=True
)
raise
return sync_wrapper # type: ignore
return decorator
def _extract_target_id(
result: Any, kwargs: dict, extractor: Optional[Callable[[Any], str]]
) -> Optional[str]:
"""从返回值或 kwargs 中提取目标 ID"""
if extractor:
try:
return extractor(result)
except Exception:
pass
if isinstance(result, dict):
return result.get("id")
# 尝试从路径参数中提取
for key in ("dataset_id", "model_id", "task_id", "resource_id"):
val = kwargs.get(key)
if val:
return str(val)
return None
def _build_detail(template: str, kwargs: dict) -> str:
"""构建审计详情"""
if not template:
return ""
try:
return template.format(**kwargs)
except (KeyError, IndexError):
return template
def _record_audit(
action: str,
target_type: str,
target_id: Optional[str],
detail: str,
trace_id: str,
duration_ms: float,
) -> None:
"""通过已有的 record_audit 方法写入审计日志"""
try:
from app.db.platform_store import get_platform_store
store = get_platform_store()
store.record_audit(
action=action,
target_type=target_type or None,
target_id=target_id,
detail=f"{detail} trace_id={trace_id} duration_ms={duration_ms:.1f}" if detail else f"trace_id={trace_id} duration_ms={duration_ms:.1f}",
)
except Exception:
logger.error("写入审计日志失败 action=%s", action, exc_info=True)
# ==================== 预定义的审计操作常量 ====================
class AuditActions:
"""预定义的审计操作类型"""
# 数据集操作
CREATE_DATASET = "create_dataset"
UPDATE_DATASET = "update_dataset"
DELETE_DATASET = "delete_dataset"
# 模型操作
CREATE_MODEL = "create_model"
UPDATE_MODEL = "update_model"
DELETE_MODEL = "delete_model"
# 微调任务
CREATE_FINE_TUNE = "create_fine_tune"
UPDATE_FINE_TUNE = "update_fine_tune"
DELETE_FINE_TUNE = "delete_fine_tune"
# 推理任务
CREATE_INFERENCE = "create_inference"
UPDATE_INFERENCE = "update_inference"
DELETE_INFERENCE = "delete_inference"
# 用户管理
CREATE_USER = "create_user"
UPDATE_USER = "update_user"
DELETE_USER = "delete_user"
# 租户管理
CREATE_TENANT = "create_tenant"
UPDATE_TENANT = "update_tenant"
DELETE_TENANT = "delete_tenant"
# 权限授权
GRANT_ACL = "grant_acl"
REVOKE_ACL = "revoke_acl"
# 系统配置
UPDATE_CONFIG = "update_config"

View File

@@ -8,7 +8,7 @@ from logging import Handler, LogRecord
from pathlib import Path
import re
import time
from typing import Any
from typing import Any, Callable, Optional
from uuid import uuid4
from fastapi import FastAPI, Request
@@ -17,6 +17,74 @@ from app.core.config import Settings, get_settings
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
# ==================== 敏感数据脱敏规则 ====================
SENSITIVE_PATTERNS: dict[str, Callable | str] = {
"token": "***",
"password": "***",
"access_token": "***",
"refresh_token": "***",
"secret_key": "***",
"authorization": "***",
"bearer": "***",
"api_key": "***",
"private_key": "***",
}
def mask_value(key: str, value: Any) -> str:
"""对单个值进行脱敏处理"""
if value is None:
return ""
str_val = str(value)
handler = SENSITIVE_PATTERNS.get(key)
if callable(handler):
return handler(str_val)
elif isinstance(handler, str):
# 支持正则替换模式,如 r"1\d{3}\d{4}"
try:
return re.sub(handler, "***", str_val)
except re.error:
return "***"
return handler
def mask_sensitive_dict(data: dict) -> dict:
"""递归脱敏字典中的敏感字段"""
if not data or not isinstance(data, dict):
return data
result = {}
for key, value in data.items():
result[key] = mask_value(key, value)
return result
def mask_sensitive_string(text: str) -> str:
"""从文本中脱敏常见敏感信息"""
if not text:
return text
patterns = [
(r'Bearer\s+[A-Za-z0-9\-._]+', '***'),
(r'token\s*[:=]\s*', '***'),
(r'password\s*[:=]\s*', '***'),
(r'secret[_-]?key\s*[:=]', '***'),
(r'api[-_]?key\s*[:=]', '***'),
(r'private[_-]?key\s*[:=]', '***'),
(r'\d{11}', r'\d{3}\*\d{4}'), # 手机号/身份证
(r'1[3-9]\d{9}', r'1\*{3}\*{4}'), # 手机号
]
for pattern, replacement in patterns:
try:
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
except re.error:
pass
return text
# ==================== RequestId Filter ====================
class RequestIdFilter(logging.Filter):
def filter(self, record: LogRecord) -> bool:
@@ -24,8 +92,30 @@ class RequestIdFilter(logging.Filter):
return True
# ==================== Enhanced JSON Formatter ====================
class JsonLogFormatter(logging.Formatter):
"""Format one JSON object per line for ELK/Filebeat collection."""
"""
增强的 JSON 日志格式化器,支持结构化字段输出。
输出示例:
{
"@timestamp": "2026-08-17T18:30:00.123Z",
"level": "INFO",
"logger": "dataset.router",
"message": "数据集创建成功",
"module": "dataset.router",
"function": "create_dataset",
"file": "dataset/router.py",
"line": 45,
"process": 12345,
"thread": "MainThread",
"request_id": "req-abc123",
"user_id": "u_admin",
"client_ip": "192.168.1.100",
"extra": {...}
}
"""
def format(self, record: LogRecord) -> str:
payload: dict[str, Any] = {
@@ -44,13 +134,26 @@ class JsonLogFormatter(logging.Formatter):
"thread_name": record.threadName,
"request_id": getattr(record, "request_id", "-"),
}
# 从 record 中提取额外字段(通过 extra 参数传入)
for attr in ("user_id", "client_ip", "target_type", "target_id",
"duration_ms", "status_code", "error"):
val = getattr(record, attr, None)
if val is not None:
payload[attr] = val
# 处理异常信息
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
if record.stack_info:
payload["stack"] = self.formatStack(record.stack_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
# ==================== DateSizeRotatingFileHandler ====================
# (保持不变,已有实现)
class DateSizeRotatingFileHandler(Handler):
"""Rotate log files by date and size while keeping date in every file name."""
@@ -160,6 +263,114 @@ class DateSizeRotatingFileHandler(Handler):
path.unlink(missing_ok=True)
# ==================== Structured Logger 封装 ====================
class StructuredLogger:
"""
结构化日志记录器,提供统一的日志接口。
使用方式:
logger = get_structured_logger('dataset.router')
logger.info('创建数据集', dataset_id='ds_123')
"""
def __init__(self, name: str, module: str = ""):
self.logger = logging.getLogger(name)
self.name = name
self.module = module
@property
def trace_id(self) -> str:
return request_id_var.get("-")
def info(self, message: str, **extra: Any) -> None:
self._log("INFO", message, **extra)
def warning(self, message: str, **extra: Any) -> None:
self._log("WARNING", message, **extra)
def error(self, message: str, **extra: Any) -> None:
self._log("ERROR", message, **extra)
def debug(self, message: str, **extra: Any) -> None:
self._log("DEBUG", message, **extra)
def _log(self, level: str, message: str, **extra: Any) -> None:
"""统一日志记录方法"""
log_entry: dict[str, Any] = {
"timestamp": datetime.utcnow().isoformat(),
"level": level,
"logger": self.name,
"module": self.module,
"message": message,
"trace_id": self.trace_id,
"extra": extra,
}
self.logger.log(getattr(logging, level, logging.INFO), json.dumps(log_entry, ensure_ascii=False, default=str))
def get_structured_logger(name: str, module: str = "") -> StructuredLogger:
"""获取结构化日志记录器"""
return StructuredLogger(name, module)
# ==================== 快捷函数 ====================
def get_logger(name: str) -> logging.Logger:
"""获取标准 Python logger"""
return logging.getLogger(name)
def set_request_id(request_id: str) -> None:
"""设置当前请求的追踪 ID"""
request_id_var.set(request_id)
def setup_request_logging(app: FastAPI) -> None:
"""配置 FastAPI 请求日志中间件"""
logger = get_logger("app.access")
@app.middleware("http")
async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
request_id = request.headers.get("X-Request-ID") or str(uuid4())
token = request_id_var.set(request_id)
started_at = time.perf_counter()
try:
response = await call_next(request)
elapsed_ms = (time.perf_counter() - started_at) * 1000
noisy_paths = ("/health", "/system-info", "/compute/jobs/", "/model-eval/", "/model-compare/")
log_method = logger.debug if request.method == "GET" and response.status_code < 400 else logger.info
if any(request.url.path.endswith(path) or path in request.url.path for path in noisy_paths) and response.status_code < 400:
log_method = logger.debug
if response.status_code >= 400:
log_method = logger.warning
log_method(
"request completed method=%s path=%s status_code=%s duration_ms=%.2f client=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
request.client.host if request.client else "-",
)
response.headers["X-Request-ID"] = request_id
return response
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
logger.exception(
"request failed method=%s path=%s duration_ms=%.2f client=%s",
request.method,
request.url.path,
elapsed_ms,
request.client.host if request.client else "-",
)
raise
finally:
request_id_var.reset(token)
# ==================== 配置函数 ====================
def configure_logging(settings: Settings | None = None) -> None:
settings = settings or get_settings()
@@ -209,57 +420,5 @@ def configure_logging(settings: Settings | None = None) -> None:
logger.handlers.clear()
logger.propagate = True
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("psycopg.pool").setLevel(logging.ERROR)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
def set_request_id(request_id: str) -> None:
request_id_var.set(request_id)
def setup_request_logging(app: FastAPI) -> None:
logger = get_logger("app.access")
@app.middleware("http")
async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
request_id = request.headers.get("X-Request-ID") or str(uuid4())
token = request_id_var.set(request_id)
started_at = time.perf_counter()
try:
response = await call_next(request)
elapsed_ms = (time.perf_counter() - started_at) * 1000
# Docker/frontend probes and polling endpoints are intentionally
# quiet at INFO; failures remain visible at WARNING/ERROR.
noisy_paths = ("/health", "/system-info", "/compute/jobs/", "/model-eval/", "/model-compare/")
log_method = logger.debug if request.method == "GET" and response.status_code < 400 else logger.info
if any(request.url.path.endswith(path) or path in request.url.path for path in noisy_paths) and response.status_code < 400:
log_method = logger.debug
if response.status_code >= 400:
log_method = logger.warning
log_method(
"request completed method=%s path=%s status_code=%s duration_ms=%.2f client=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
request.client.host if request.client else "-",
)
response.headers["X-Request-ID"] = request_id
return response
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
logger.exception(
"request failed method=%s path=%s duration_ms=%.2f client=%s",
request.method,
request.url.path,
elapsed_ms,
request.client.host if request.client else "-",
)
raise
finally:
request_id_var.reset(token)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("psycopg.pool").setLevel(logging.ERROR)

View File

@@ -555,6 +555,8 @@ class PlatformStore:
extra_path = schema_dir / extra
if extra_path.exists():
conn.executescript(extra_path.read_text(encoding="utf-8"))
# data_convert_tasks 表补充 created_by 字段(用于数据隔离)
self._ensure_columns(conn, "data_convert_tasks", {"created_by": "TEXT"})
def _column_names(self, conn: PgConnection, table_name: str) -> set[str]:
columns = conn.execute(
@@ -1285,7 +1287,13 @@ class PlatformStore:
def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
user_id = new_id("u")
permissions = payload.get("permissions") or (ALL_PERMISSIONS if payload.get("role") == "admin" else ["dashboard"])
role = payload.get("role", "user")
if role == "admin":
permissions = ALL_PERMISSIONS
else:
# 普通用户:默认拥有所有业务权限,仅排除 user-settings 和 compute
role = "user"
permissions = [p for p in ALL_PERMISSIONS if p not in ("user-settings", "compute")]
with self.connect() as conn:
conn.execute(
"""
@@ -1298,7 +1306,7 @@ class PlatformStore:
payload["username"],
hash_password(payload.get("password", "platform123")),
payload.get("display_name") or payload["username"],
payload.get("role", "viewer"),
role,
payload.get("status", "active"),
json_dumps(permissions),
utcnow(),
@@ -1318,8 +1326,8 @@ class PlatformStore:
# 管理员权限不可更改,必须是全部
perms = ALL_PERMISSIONS
else:
# 非 admin 用户不能拥有 user-settings 权限
perms = [p for p in (perms or []) if p != "user-settings"]
# 非 admin 用户不能拥有 user-settings 和 compute 权限
perms = [p for p in (perms or []) if p not in ("user-settings", "compute")]
payload = {**payload, "permissions": perms}
values = {
"role": payload.get("role", row["role"]),

View File

@@ -51,10 +51,18 @@ CREATE TABLE IF NOT EXISTS audit_logs (
time TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_project ON audit_logs(project_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_logs(time);
-- 幂等升级 audit_logs 表:新增字段(已存在则跳过)
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS trace_id TEXT;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_method TEXT;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_path TEXT;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS status_code INTEGER;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS duration_ms REAL;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS extra JSONB;
-- 索引(已存在则跳过)
CREATE INDEX IF NOT EXISTS idx_audit_trace ON audit_logs(trace_id);
CREATE INDEX IF NOT EXISTS idx_audit_actor_time ON audit_logs(actor_id, time);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_logs(target_type, target_id);
CREATE TABLE IF NOT EXISTS retention_policies (
id TEXT PRIMARY KEY,

View File

@@ -9,7 +9,7 @@ from fastapi import APIRouter, Body, Depends, File, UploadFile
from fastapi.responses import FileResponse
from app.api.v1.endpoints.platform import ok, fail
from app.core.auth import get_current_user
from app.core.auth import get_current_user, is_admin
from app.db.platform_store import get_platform_store, new_id
@@ -63,14 +63,28 @@ def list_tasks(
) -> dict[str, Any]:
store = get_platform_store()
with store.connect() as conn:
rows = conn.execute(
"SELECT * FROM data_convert_tasks WHERE deleted_at IS NULL "
"ORDER BY create_time DESC LIMIT %s OFFSET %s",
(page_size, (page - 1) * page_size),
).fetchall()
total = conn.execute(
"SELECT COUNT(*) FROM data_convert_tasks WHERE deleted_at IS NULL"
).fetchone()[0]
if is_admin(current_user):
# 管理员可见全部
rows = conn.execute(
"SELECT * FROM data_convert_tasks WHERE deleted_at IS NULL "
"ORDER BY create_time DESC LIMIT %s OFFSET %s",
(page_size, (page - 1) * page_size),
).fetchall()
total = conn.execute(
"SELECT COUNT(*) FROM data_convert_tasks WHERE deleted_at IS NULL"
).fetchone()[0]
else:
# 普通用户只能看到自己创建的
user_id = current_user.get("id")
rows = conn.execute(
"SELECT * FROM data_convert_tasks WHERE deleted_at IS NULL AND created_by=%s "
"ORDER BY create_time DESC LIMIT %s OFFSET %s",
(user_id, page_size, (page - 1) * page_size),
).fetchall()
total = conn.execute(
"SELECT COUNT(*) FROM data_convert_tasks WHERE deleted_at IS NULL AND created_by=%s",
(user_id,)
).fetchone()[0]
return ok({"items": [dict(r) for r in rows], "total": total})
@@ -85,12 +99,13 @@ def create_task(
task_id = new_id("dct")
output_filename = _safe_output_filename(payload.get("output_filename"))
description = str(payload.get("description") or "").strip()
user_id = current_user.get("id")
store = get_platform_store()
with store.connect() as conn:
conn.execute(
"INSERT INTO data_convert_tasks (id, name, description, output_filename) "
"VALUES (%s, %s, %s, %s)",
(task_id, name, description, output_filename),
"INSERT INTO data_convert_tasks (id, name, description, output_filename, created_by) "
"VALUES (%s, %s, %s, %s, %s)",
(task_id, name, description, output_filename, user_id),
)
# 创建目录
_input_dir(task_id).mkdir(parents=True, exist_ok=True)

View File

@@ -6,6 +6,7 @@ from typing import Any
from app.api.v1.endpoints.platform import ok, fail
from app.db.platform_store import get_platform_store
from app.core.auth import get_current_user, has_resource_access, is_admin
from app.core.audit import audit_log, AuditActions
router = APIRouter(prefix="/resources", tags=["resource"])
@@ -25,6 +26,11 @@ def get_acl(resource_type: str, resource_id: str, current_user: dict = Depends(g
@router.put("/{resource_type}/{resource_id}/acl")
@audit_log(
action=AuditActions.GRANT_ACL,
target_type="",
detail_template="设置资源授权: {resource_type}/{resource_id}",
)
def set_acl(
resource_type: str,
resource_id: str,