"""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