218 lines
8.3 KiB
Python
218 lines
8.3 KiB
Python
"""鉴权依赖:从 Authorization header 解析当前用户,提供权限校验。"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import Depends, HTTPException, Query, Request, status
|
||
|
||
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:
|
||
"""从 Authorization header 提取 token(格式: Bearer platform-token-{user_id})。"""
|
||
auth = request.headers.get("Authorization", "")
|
||
token = auth.replace("Bearer ", "").strip()
|
||
if token.startswith("platform-token-"):
|
||
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]:
|
||
"""
|
||
FastAPI 依赖:解析当前登录用户。
|
||
- 公开路径(/health, /login 等)直接放行,返回匿名用户。
|
||
- 无 token 或 token 无效时抛 401。
|
||
- admin 用户标记为超级管理员,拥有全部权限。
|
||
"""
|
||
path = request.url.path
|
||
# 去掉路由前缀后判断
|
||
for prefix in PUBLIC_PATHS:
|
||
if path.endswith(prefix):
|
||
return {"id": None, "username": "anonymous", "role": "viewer", "permissions": [], "protected": False}
|
||
|
||
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()
|
||
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")
|
||
|
||
|
||
def require_admin(current_user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]:
|
||
"""FastAPI 依赖:要求当前用户是管理员(role=admin 或 protected)。"""
|
||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||
return current_user
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin permission required")
|
||
|
||
|
||
def is_admin(user: dict[str, Any]) -> bool:
|
||
"""判断用户是否为管理员(admin 角色或 protected 标记)。"""
|
||
return user.get("role") == "admin" or user.get("protected", False)
|
||
|
||
|
||
def has_resource_access(
|
||
resource_type: str,
|
||
resource_id: str,
|
||
user: dict[str, Any],
|
||
permission: str = "read",
|
||
) -> bool:
|
||
"""
|
||
检查用户对某资源是否有指定权限。
|
||
- admin/protected 用户直接放行(旁路)。
|
||
- 其他用户检查 acls 表中是否有对应授权。
|
||
"""
|
||
if user.get("role") == "admin" or user.get("protected"):
|
||
return True
|
||
|
||
store = get_platform_store()
|
||
acls = store.get_acl(resource_type, resource_id)
|
||
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:
|
||
if _permission_covers(entry.get("permission"), permission):
|
||
return True
|
||
# 按 role 授权
|
||
if entry.get("principal_type") == "role" and entry.get("principal_id") == user_role:
|
||
if _permission_covers(entry.get("permission"), permission):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _permission_covers(granted: str | None, required: str) -> bool:
|
||
"""权限覆盖判断:write/execute 覆盖 read;admin 覆盖一切。"""
|
||
if not granted:
|
||
return False
|
||
if granted == "admin":
|
||
return True
|
||
if granted == required:
|
||
return True
|
||
# write 覆盖 read
|
||
if required == "read" and granted in ("write", "execute"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def filter_accessible_resource_ids(
|
||
resource_type: str,
|
||
all_ids: list[str],
|
||
user: dict[str, Any],
|
||
) -> list[str]:
|
||
"""
|
||
从全部资源 ID 中过滤出当前用户可访问的 ID 列表。
|
||
- admin 直接返回全部。
|
||
- 普通用户查 acls 表取交集。
|
||
"""
|
||
if user.get("role") == "admin" or user.get("protected"):
|
||
return all_ids
|
||
|
||
if not all_ids:
|
||
return []
|
||
|
||
store = get_platform_store()
|
||
user_id = user.get("id")
|
||
user_role = user.get("role")
|
||
|
||
# 查询该用户在该资源类型下有 read 权限的所有 resource_id
|
||
with store.connect() as conn:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT DISTINCT resource_id FROM acls
|
||
WHERE resource_type=? AND (
|
||
(principal_type='user' AND principal_id=?)
|
||
OR (principal_type='role' AND principal_id=?)
|
||
)
|
||
""",
|
||
(resource_type, user_id, user_role),
|
||
).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
|