253 lines
9.5 KiB
Python
253 lines
9.5 KiB
Python
"""SCIM 2.0 路由 (U4 最小子集) — handler 内联,不拆 handlers.py。
|
||
|
||
端点:
|
||
- POST /scim/v2/Users — 创建用户
|
||
- PATCH /scim/v2/Users/{id} — 禁用 (active=false) / 更新
|
||
- GET /scim/v2/Users — 列表 / 过滤
|
||
|
||
Bearer token 认证(独立于 JWT — ``auth.scim_token`` 配置项)。
|
||
SCIM push 失败触发实时告警(日志 error + OTel span event)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hmac
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import aiosqlite
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||
from pydantic import BaseModel, ConfigDict
|
||
|
||
from ...auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db, user_row_to_dict
|
||
from ...auth.password import hash_password
|
||
from .models import SCIMListResponse, SCIMPatchRequest, SCIMUser
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/scim/v2", tags=["scim"])
|
||
|
||
_SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _resolve_db_path(request: Request) -> Path:
|
||
path = getattr(request.app.state, "auth_db_path", None)
|
||
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
||
|
||
|
||
def _resolve_scim_token(request: Request) -> str | None:
|
||
"""从 app.state 或环境变量解析 SCIM bearer token。"""
|
||
token = getattr(request.app.state, "scim_token", None)
|
||
if token:
|
||
return token
|
||
return os.environ.get("AGENTKIT_SCIM_TOKEN")
|
||
|
||
|
||
async def _verify_scim_token(request: Request) -> str:
|
||
"""SCIM bearer token 校验 — 恒定时间比较防时序攻击。"""
|
||
expected = _resolve_scim_token(request)
|
||
if not expected:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="SCIM 未配置 bearer token(auth.scim_token)",
|
||
)
|
||
auth_header = request.headers.get("Authorization", "")
|
||
if not auth_header.startswith("Bearer "):
|
||
raise HTTPException(status_code=401, detail="缺少 SCIM Bearer token")
|
||
provided = auth_header[7:]
|
||
if not hmac.compare_digest(provided, expected):
|
||
raise HTTPException(status_code=401, detail="SCIM token 无效")
|
||
return provided
|
||
|
||
|
||
def _alert_scim_failure(operation: str, detail: str, user_id: str | None = None) -> None:
|
||
"""SCIM 推送失败实时告警 — 日志 error + OTel span event。
|
||
|
||
ponytail: 仅日志 + OTel event。生产可扩展为 webhook / 告警通道。
|
||
上限:告警去重 / 限流需在告警通道侧实现。
|
||
"""
|
||
logger.error("SCIM 推送失败 [op=%s user=%s]: %s", operation, user_id or "?", detail)
|
||
try:
|
||
from agentkit.telemetry.tracer import get_tracer
|
||
|
||
tracer = get_tracer()
|
||
span = tracer.start_span("scim.push.failure")
|
||
span.set_attribute("scim.operation", operation)
|
||
span.set_attribute("scim.error", detail)
|
||
if user_id:
|
||
span.set_attribute("scim.user_id", user_id)
|
||
span.add_event("scim.alert", {"operation": operation, "detail": detail})
|
||
except Exception: # noqa: BLE001 — OTel 不可用不阻断主流程
|
||
pass
|
||
|
||
|
||
async def _ensure_db(request: Request) -> Path:
|
||
db_path = _resolve_db_path(request)
|
||
await init_auth_db(db_path)
|
||
return db_path
|
||
|
||
|
||
def _user_to_scim(row: dict[str, object]) -> dict[str, Any]:
|
||
"""本地 user dict → SCIM 2.0 User JSON。"""
|
||
email = str(row.get("email", "") or "")
|
||
return {
|
||
"schemas": [_SCIM_USER_SCHEMA],
|
||
"id": str(row["id"]),
|
||
"userName": str(row["username"]),
|
||
"active": bool(row.get("is_active", True)),
|
||
"displayName": str(row.get("username", "")),
|
||
"emails": [{"value": email, "type": "work", "primary": True}] if email else [],
|
||
}
|
||
|
||
|
||
@router.post("/Users", status_code=status.HTTP_201_CREATED)
|
||
async def create_user(
|
||
payload: SCIMUser,
|
||
request: Request,
|
||
_token: str = Depends(_verify_scim_token),
|
||
) -> dict[str, Any]:
|
||
"""SCIM POST /Users — 创建本地用户(随机密码,不可本地登录)。"""
|
||
db_path = await _ensure_db(request)
|
||
user_id = str(uuid.uuid4())
|
||
now = _now_iso()
|
||
email = payload.emails[0].value if payload.emails else f"{user_id}@scim.local"
|
||
try:
|
||
async with aiosqlite.connect(str(db_path)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
await db.execute(
|
||
"INSERT INTO users "
|
||
"(id, username, email, password_hash, role, is_active, "
|
||
" is_terminal_authorized, is_server_terminal_authorized, "
|
||
" created_at, updated_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(
|
||
user_id,
|
||
payload.userName,
|
||
str(email),
|
||
hash_password(secrets.token_urlsafe(32)),
|
||
"member",
|
||
1 if payload.active else 0,
|
||
0,
|
||
0,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
await db.commit()
|
||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||
row = await cursor.fetchone()
|
||
assert row is not None
|
||
logger.info("SCIM 创建用户 userName=%s id=%s", payload.userName, user_id)
|
||
return _user_to_scim(user_row_to_dict(row))
|
||
except aiosqlite.IntegrityError as exc:
|
||
_alert_scim_failure("create", f"用户名或邮箱冲突: {exc}", user_id)
|
||
raise HTTPException(status_code=409, detail="userName 或 email 已存在") from exc
|
||
except Exception as exc: # noqa: BLE001
|
||
_alert_scim_failure("create", str(exc), user_id)
|
||
raise HTTPException(status_code=500, detail="SCIM 创建失败") from exc
|
||
|
||
|
||
@router.patch("/Users/{user_id}")
|
||
async def patch_user(
|
||
user_id: str,
|
||
payload: SCIMPatchRequest,
|
||
request: Request,
|
||
_token: str = Depends(_verify_scim_token),
|
||
) -> dict[str, Any]:
|
||
"""SCIM PATCH /Users/{id} — 禁用 (active=false) 或更新字段。"""
|
||
db_path = await _ensure_db(request)
|
||
now = _now_iso()
|
||
try:
|
||
async with aiosqlite.connect(str(db_path)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||
row = await cursor.fetchone()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
for op in payload.Operations:
|
||
if op.op.lower() == "replace":
|
||
if op.path in (None, "active"):
|
||
active = (
|
||
bool(op.value)
|
||
if not isinstance(op.value, dict)
|
||
else bool(op.value.get("active", True))
|
||
)
|
||
await db.execute(
|
||
"UPDATE users SET is_active = ?, updated_at = ? WHERE id = ?",
|
||
(1 if active else 0, now, user_id),
|
||
)
|
||
elif op.path == "userName":
|
||
await db.execute(
|
||
"UPDATE users SET username = ?, updated_at = ? WHERE id = ?",
|
||
(str(op.value)[:64], now, user_id),
|
||
)
|
||
await db.commit()
|
||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||
row = await cursor.fetchone()
|
||
assert row is not None
|
||
return _user_to_scim(user_row_to_dict(row))
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
_alert_scim_failure("patch", str(exc), user_id)
|
||
raise HTTPException(status_code=500, detail="SCIM 更新失败") from exc
|
||
|
||
|
||
@router.get("/Users")
|
||
async def list_users(
|
||
request: Request,
|
||
filter: str | None = Query(default=None, alias="filter"),
|
||
startIndex: int = Query(default=1, ge=1),
|
||
count: int = Query(default=100, ge=1, le=200),
|
||
_token: str = Depends(_verify_scim_token),
|
||
) -> dict[str, Any]:
|
||
"""SCIM GET /Users — 列表 / 过滤。
|
||
|
||
ponytail: filter 仅支持最简形式 ``userName eq "xxx"`` / ``active eq true``。
|
||
上限:复杂 SCIM filter 表达式需扩展为完整 AST 解析器。
|
||
"""
|
||
db_path = await _ensure_db(request)
|
||
where = ""
|
||
params: list[Any] = []
|
||
if filter:
|
||
# 最简 filter 解析:userName eq "x" / active eq true
|
||
if "userName eq" in filter:
|
||
val = filter.split("userName eq", 1)[1].strip().strip('"').strip("'")
|
||
where = "WHERE username = ?"
|
||
params.append(val)
|
||
elif "active eq" in filter:
|
||
val = filter.split("active eq", 1)[1].strip().strip('"').strip("'").lower()
|
||
where = "WHERE is_active = ?"
|
||
params.append(1 if val in ("true", "1") else 0)
|
||
sql = f"SELECT * FROM users {where} ORDER BY created_at ASC LIMIT ? OFFSET ?"
|
||
params.extend([count, startIndex - 1])
|
||
async with aiosqlite.connect(str(db_path)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(sql, tuple(params))
|
||
rows = await cursor.fetchall()
|
||
resources = [_user_to_scim(user_row_to_dict(r)) for r in rows]
|
||
return SCIMListResponse(
|
||
totalResults=len(resources),
|
||
startIndex=startIndex,
|
||
itemsPerPage=len(resources),
|
||
Resources=resources,
|
||
).model_dump()
|
||
|
||
|
||
class SCIMErrorResponse(BaseModel):
|
||
"""SCIM 错误响应包装。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
status: str
|
||
detail: str | None = None
|