修改用户设置的新增用户的权限点击操作
This commit is contained in:
@@ -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
199
backend/app/core/audit.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user