feat: 权限与日志治理完善,MinIO 独立部署与 tiktoken 离线打包适配
- 后端:强化平台/审批/资源/系统接口权限校验与操作日志,更新权限设计文档与测试用例 - 存储:新增 MinIO 独立部署适配(端口 19000/19001),外部端点与 host-gateway 互通 - 离线:打包 tiktoken cl100k_base 词表进镜像,避免无网环境联网下载 - 其他:算力节点接口微调,前端微调创建页小修,忽略 MinIO 运行时数据 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,13 @@ from app.db.platform_store import get_platform_store
|
||||
|
||||
# 无需鉴权的路径前缀(健康检查、登录等)
|
||||
PUBLIC_PATHS = ("/health", "/login", "/system-info")
|
||||
OWNER_TABLES = {
|
||||
"dataset": ("datasets", "created_by"), "model": ("models", "created_by"),
|
||||
"trained_model": ("trained_models", "created_by"), "eval": ("eval_tasks", "created_by"),
|
||||
"fine-tune": ("fine_tune_tasks", "payload"), "fine_tune_task": ("fine_tune_tasks", "payload"),
|
||||
"compare": ("compare_tasks", "payload"), "inference": ("compare_tasks", "payload"),
|
||||
"project": ("projects", "created_by"), "data_process": ("data_process_tasks", "created_by"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_token(request: Request) -> str | None:
|
||||
@@ -19,6 +26,9 @@ def _extract_token(request: Request) -> str | None:
|
||||
return token[len("platform-token-"):]
|
||||
return None
|
||||
|
||||
def _session_token(user_id: str, session_id: str) -> str:
|
||||
return f"platform-token-{user_id}.{session_id}"
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -33,14 +43,30 @@ def get_current_user(request: Request) -> dict[str, Any]:
|
||||
if path.endswith(prefix):
|
||||
return {"id": None, "username": "anonymous", "role": "viewer", "permissions": [], "protected": False}
|
||||
|
||||
user_id = _extract_token(request)
|
||||
if not user_id:
|
||||
token_value = _extract_token(request)
|
||||
if not token_value:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing or invalid token")
|
||||
|
||||
store = get_platform_store()
|
||||
for u in store.users():
|
||||
if u.get("id") == user_id:
|
||||
return u
|
||||
user_id, _, session_id = token_value.partition(".")
|
||||
if session_id:
|
||||
with store.connect() as conn:
|
||||
session = conn.execute(
|
||||
"SELECT user_id, logout_at, expires_at FROM sessions WHERE id=?", (session_id,)
|
||||
).fetchone()
|
||||
if not session or session["user_id"] != user_id or session["logout_at"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="session expired")
|
||||
if session["expires_at"]:
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
if datetime.fromisoformat(str(session["expires_at"]).replace("Z", "+00:00")) <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="session expired")
|
||||
except ValueError:
|
||||
pass
|
||||
with store.connect() as conn:
|
||||
user_row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if user_row:
|
||||
return store._user(user_row)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
|
||||
|
||||
@@ -75,6 +101,23 @@ def has_resource_access(
|
||||
user_id = user.get("id")
|
||||
user_role = user.get("role")
|
||||
|
||||
owner_tables = OWNER_TABLES
|
||||
table_info = owner_tables.get(resource_type)
|
||||
if table_info and user_id:
|
||||
table, column = table_info
|
||||
with store.connect() as conn:
|
||||
row = conn.execute(f"SELECT {column} FROM {table} WHERE id=?", (resource_id,)).fetchone()
|
||||
if row:
|
||||
owner = row[column]
|
||||
if column == "payload":
|
||||
try:
|
||||
import json
|
||||
owner = json.loads(owner or "{}").get("created_by")
|
||||
except (TypeError, ValueError):
|
||||
owner = None
|
||||
if owner == user_id:
|
||||
return True
|
||||
|
||||
for entry in acls:
|
||||
# 按 user 授权
|
||||
if entry.get("principal_type") == "user" and entry.get("principal_id") == user_id:
|
||||
@@ -135,4 +178,40 @@ def filter_accessible_resource_ids(
|
||||
).fetchall()
|
||||
|
||||
accessible = {r["resource_id"] for r in rows}
|
||||
if resource_type in owner_tables:
|
||||
table, column = owner_tables[resource_type]
|
||||
with store.connect() as conn:
|
||||
owned = conn.execute(f"SELECT id FROM {table} WHERE {column}=?", (user_id,)).fetchall()
|
||||
accessible.update(row["id"] for row in owned)
|
||||
return [rid for rid in all_ids if rid in accessible]
|
||||
|
||||
|
||||
def filter_accessible_resource_ids_batch(
|
||||
resource_type: str,
|
||||
resource_ids: list[str],
|
||||
user: dict[str, Any],
|
||||
) -> set[str]:
|
||||
"""Filter a list endpoint with one ACL query instead of one query per row."""
|
||||
if is_admin(user):
|
||||
return set(resource_ids)
|
||||
if not resource_ids:
|
||||
return set()
|
||||
store = get_platform_store()
|
||||
placeholders = ",".join("?" for _ in resource_ids)
|
||||
with store.connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT resource_id FROM acls WHERE resource_type=? AND resource_id IN ({placeholders}) "
|
||||
"AND ((principal_type='user' AND principal_id=?) OR (principal_type='role' AND principal_id=?))",
|
||||
(resource_type, *resource_ids, user.get("id"), user.get("role")),
|
||||
).fetchall()
|
||||
accessible = {row["resource_id"] for row in rows}
|
||||
table_info = OWNER_TABLES.get(resource_type)
|
||||
if table_info and user.get("id"):
|
||||
table, column = table_info
|
||||
with store.connect() as conn:
|
||||
owned = conn.execute(
|
||||
f"SELECT id FROM {table} WHERE id IN ({placeholders}) AND {column}=?",
|
||||
(*resource_ids, user["id"]),
|
||||
).fetchall()
|
||||
accessible.update(row["id"] for row in owned)
|
||||
return accessible
|
||||
|
||||
@@ -61,7 +61,9 @@ class Settings:
|
||||
compute_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3)
|
||||
compute_request_timeout_seconds: int = _int_env("COMPUTE_REQUEST_TIMEOUT_SECONDS", 5)
|
||||
minio_enabled: bool = _bool_env("MINIO_ENABLED", False)
|
||||
minio_endpoint: str = os.getenv("MINIO_ENDPOINT", "http://minio:9000")
|
||||
# MinIO is an independent service and may run on another host. The
|
||||
# endpoint must therefore be reachable from the Backend container.
|
||||
minio_endpoint: str = os.getenv("MINIO_ENDPOINT", "http://host.docker.internal:19000")
|
||||
minio_access_key: str = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||
minio_secret_key: str = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||
minio_bucket: str = os.getenv("MINIO_BUCKET", "yg-ft-resources")
|
||||
|
||||
@@ -210,6 +210,10 @@ def configure_logging(settings: Settings | None = None) -> None:
|
||||
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)
|
||||
|
||||
@@ -229,7 +233,15 @@ def setup_request_logging(app: FastAPI) -> None:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
logger.info(
|
||||
# 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,
|
||||
|
||||
Reference in New Issue
Block a user