428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""SCIM 2.0 路由 (U4 最小子集) — handler 内联,不拆 handlers.py。
|
||
|
||
端点:
|
||
- POST /scim/v2/Users — 创建用户(同步写 user_idp_links 供 SSO 联动)
|
||
- PATCH /scim/v2/Users/{id} — 禁用 (active=false) / 更新
|
||
- GET /scim/v2/Users — 列表 / 过滤
|
||
|
||
Bearer token 认证(独立于 JWT — ``auth.scim_token`` 配置项)。
|
||
SCIM push 失败触发实时告警(日志 error + OTel span event)。
|
||
RFC 7643/7644 合规:错误响应用 SCIMErrorResponse 格式 + Location 头 + totalResults 全量总数。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hmac
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
import aiosqlite
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from ...auth.audit import SOURCE_SCIM, get_audit_service
|
||
from ...auth.models import (
|
||
init_auth_db,
|
||
resolve_auth_db_path as _resolve_default_db_path,
|
||
user_row_to_dict,
|
||
_now_iso,
|
||
)
|
||
from ...auth.password import hash_password
|
||
from .models import SCIMListResponse, SCIMPatchRequest, SCIMUser
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# STAND-3: prefix 由挂载点控制(app.py / test fixture 通过 include_router(prefix="/scim/v2") 注入)。
|
||
# router 内部不设 prefix,避免双重前缀 /scim/v2/scim/v2/Users。
|
||
router = APIRouter(tags=["scim"])
|
||
|
||
_SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||
_SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
||
|
||
|
||
def _resolve_db_path(request: Request) -> Path:
|
||
path = getattr(request.app.state, "auth_db_path", None)
|
||
# API-3: 用 resolve_auth_db_path() 在调用时读取 env var,
|
||
# 让测试 monkeypatch.setenv("AGENTKIT_AUTH_DB", tmp) 可见。
|
||
return Path(path) if path else _resolve_default_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()
|
||
with tracer.start_span("scim.push.failure") as span:
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RFC 7644 3.12 — SCIM 错误响应格式
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _scim_error_response(status_code: int, detail: str) -> JSONResponse:
|
||
"""API-2: 构造 RFC 7644 3.12 规定的 SCIM 错误响应格式。"""
|
||
return JSONResponse(
|
||
status_code=status_code,
|
||
content={
|
||
"schemas": [_SCIM_ERROR_SCHEMA],
|
||
"status": str(status_code),
|
||
"detail": detail,
|
||
},
|
||
)
|
||
|
||
|
||
# API-2: APIRouter 在旧版 FastAPI 无 exception_handler 方法,改为导出注册函数
|
||
# 由 app.py 在挂载 SCIM router 后调用,scope 到 /scim/v2 路径。
|
||
|
||
|
||
def register_scim_exception_handlers(app) -> None:
|
||
"""注册 SCIM 异常处理器 — 仅对 /scim/v2 路径生效,不影响其他路由。
|
||
|
||
API-2: APIRouter 在旧版 FastAPI 无 exception_handler 方法,改为在 app 上注册
|
||
并通过 request.url.path scope 到 SCIM 路径。非 SCIM 路由走 FastAPI 默认格式。
|
||
"""
|
||
|
||
@app.exception_handler(HTTPException)
|
||
async def _scim_http_handler(request: Request, exc: HTTPException):
|
||
# STAND-3: SCIM 挂载在 /scim/v2,非 SCIM 路由走 FastAPI 默认格式
|
||
if not request.url.path.startswith("/scim/v2"):
|
||
headers = getattr(exc, "headers", None)
|
||
return JSONResponse(
|
||
status_code=exc.status_code,
|
||
content={"detail": exc.detail},
|
||
headers=headers,
|
||
)
|
||
return _scim_error_response(exc.status_code, str(exc.detail))
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def _scim_validation_handler(request: Request, exc: RequestValidationError):
|
||
if not request.url.path.startswith("/scim/v2"):
|
||
from fastapi.encoders import jsonable_encoder
|
||
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content={"detail": jsonable_encoder(exc.errors())},
|
||
)
|
||
return _scim_error_response(400, f"请求体校验失败: {exc}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SCIM User 资源转换 — STAND-1: 用 dict[str, object] 替代 Any
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _user_to_scim(row: dict[str, object]) -> dict[str, object]:
|
||
"""本地 user dict → SCIM 2.0 User JSON。
|
||
|
||
API-11: displayName 改为 None(让客户端用 userName fallback),
|
||
因为 users 表无 name/full_name 列,username 不是 displayName 的合理来源。
|
||
"""
|
||
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": None,
|
||
"emails": [{"value": email, "type": "work", "primary": True}] if email else [],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SCIM 端点
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/Users", status_code=status.HTTP_201_CREATED)
|
||
async def create_user(
|
||
payload: SCIMUser,
|
||
request: Request,
|
||
_token: str = Depends(_verify_scim_token),
|
||
) -> JSONResponse:
|
||
"""SCIM POST /Users — 创建本地用户(随机密码,不可本地登录)。
|
||
|
||
API-5: 同时写 user_idp_links 行,让 SCIM 预配的用户能通过 SSO 登录,
|
||
避免 OIDC JIT 创建重复账户。externalId 作为 idp_subject,
|
||
缺省时用 userName。
|
||
"""
|
||
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"
|
||
# externalId 是 RFC 7643 标准 attribute,作为 IDP subject 关联本地用户
|
||
external_id = getattr(payload, "externalId", None) or payload.userName
|
||
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,
|
||
),
|
||
)
|
||
# API-5: 写 user_idp_links 行,idp_name='scim',idp_subject=externalId
|
||
# 表无 id 列(PK = idp_name + idp_subject),省去 uuid 生成。
|
||
await db.execute(
|
||
"INSERT INTO user_idp_links "
|
||
"(idp_name, idp_subject, user_id, idp_type, created_at) "
|
||
"VALUES (?, ?, ?, ?, ?)",
|
||
("scim", external_id, user_id, "scim", 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 externalId=%s", payload.userName, user_id, external_id
|
||
)
|
||
# API-6: RFC 7644 3.14 强制要求 Location 头
|
||
body = _user_to_scim(user_row_to_dict(row))
|
||
response = JSONResponse(status_code=status.HTTP_201_CREATED, content=body)
|
||
response.headers["Location"] = f"/scim/v2/Users/{user_id}"
|
||
return response
|
||
except aiosqlite.IntegrityError as exc:
|
||
_alert_scim_failure("create", f"用户名或邮箱冲突: {exc}", user_id)
|
||
return _scim_error_response(409, "userName 或 email 已存在")
|
||
except Exception as exc: # noqa: BLE001
|
||
_alert_scim_failure("create", str(exc), user_id)
|
||
return _scim_error_response(500, "SCIM 创建失败")
|
||
|
||
|
||
@router.patch("/Users/{user_id}")
|
||
async def patch_user(
|
||
user_id: str,
|
||
payload: SCIMPatchRequest,
|
||
request: Request,
|
||
_token: str = Depends(_verify_scim_token),
|
||
) -> dict[str, object]:
|
||
"""SCIM PATCH /Users/{id} — 禁用 (active=false) 或更新字段。
|
||
|
||
API-7: userName UNIQUE 冲突时返回 409(而非 500)。
|
||
SEC-3: active true→false 触发审计记录(与 admin deprovision 对齐)。
|
||
"""
|
||
db_path = await _ensure_db(request)
|
||
now = _now_iso()
|
||
# SEC-3: 收集 deprovision 审计信息,在事务提交后调用(避免 SQLite 写锁竞争)
|
||
_pending_audit: dict[str, object] | None = None
|
||
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:
|
||
return _scim_error_response(404, "用户不存在")
|
||
prev_active = bool(row["is_active"])
|
||
# API-12: 收集未处理的 op/path,循环结束后返回 400 noTarget
|
||
_skipped_ops: list[str] = []
|
||
for op in payload.Operations:
|
||
op_lower = op.op.lower() if op.op else ""
|
||
if op_lower not in ("replace", "add", "remove"):
|
||
# API-12: 仅 replace/add/remove 受支持(当前仅 replace 有副作用)
|
||
_skipped_ops.append(f"op={op.op}")
|
||
continue
|
||
if op_lower != "replace":
|
||
# add/remove 暂未实现 — 收集而非静默忽略
|
||
_skipped_ops.append(f"op={op.op}(未实现)")
|
||
continue
|
||
|
||
# API-14: path==None 时遍历 value 的所有 keys(原来仅更新 active 丢数据)
|
||
if op.path is None and isinstance(op.value, dict):
|
||
for field_name, field_value in op.value.items():
|
||
if field_name == "active":
|
||
active = bool(field_value)
|
||
await db.execute(
|
||
"UPDATE users SET is_active = ?, updated_at = ? WHERE id = ?",
|
||
(1 if active else 0, now, user_id),
|
||
)
|
||
if prev_active and not active:
|
||
_pending_audit = {
|
||
"target_user_id": user_id,
|
||
"target_username": str(row["username"]),
|
||
}
|
||
elif field_name == "userName":
|
||
try:
|
||
await db.execute(
|
||
"UPDATE users SET username = ?, updated_at = ? WHERE id = ?",
|
||
(str(field_value)[:64], now, user_id),
|
||
)
|
||
except aiosqlite.IntegrityError:
|
||
return _scim_error_response(409, "userName 已存在")
|
||
else:
|
||
# API-12: 不支持的字段收集
|
||
_skipped_ops.append(f"path={field_name}")
|
||
continue
|
||
|
||
# path 明确指定的情况
|
||
if op.path in ("active", None):
|
||
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),
|
||
)
|
||
# SEC-3: active true→false 写审计(延后到事务提交后)
|
||
if prev_active and not active:
|
||
_pending_audit = {
|
||
"target_user_id": user_id,
|
||
"target_username": str(row["username"]),
|
||
}
|
||
elif op.path == "userName":
|
||
try:
|
||
await db.execute(
|
||
"UPDATE users SET username = ?, updated_at = ? WHERE id = ?",
|
||
(str(op.value)[:64], now, user_id),
|
||
)
|
||
except aiosqlite.IntegrityError:
|
||
# API-7: UNIQUE 冲突返回 409
|
||
return _scim_error_response(409, "userName 已存在")
|
||
else:
|
||
# API-12: 不支持的 path 收集
|
||
_skipped_ops.append(f"path={op.path}")
|
||
|
||
# API-12: 有未处理的 op/path → 400 noTarget
|
||
if _skipped_ops:
|
||
return _scim_error_response(
|
||
400,
|
||
f"SCIM PATCH 包含不支持的操作(scimType=noTarget): {', '.join(_skipped_ops)}",
|
||
)
|
||
await db.commit()
|
||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||
row = await cursor.fetchone()
|
||
assert row is not None
|
||
# SEC-3: 事务提交后写审计(避免 SQLite "database is locked")
|
||
if _pending_audit is not None:
|
||
await get_audit_service().record_deprovision(
|
||
actor_user_id=None,
|
||
actor_username="scim",
|
||
target_user_id=str(_pending_audit["target_user_id"]),
|
||
target_username=str(_pending_audit["target_username"]),
|
||
reason="scim_patch_active_false",
|
||
source=SOURCE_SCIM,
|
||
)
|
||
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)
|
||
return _scim_error_response(500, "SCIM 更新失败")
|
||
|
||
|
||
@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, object]:
|
||
"""SCIM GET /Users — 列表 / 过滤。
|
||
|
||
API-1: totalResults 返回全量总数(COUNT 查询),而非当前页条数。
|
||
API-9: 不支持的 filter 语法返回 400 + invalidFilter,不静默全量返回。
|
||
API-13: filter 解析大小写不敏感(RFC 7644 3.4.2.2)。
|
||
"""
|
||
db_path = await _ensure_db(request)
|
||
where = ""
|
||
params: list[object] = []
|
||
if filter:
|
||
filter_lower = filter.lower()
|
||
# API-13: 大小写不敏感匹配
|
||
if "username eq" in filter_lower:
|
||
# 从原 filter 中提取引号内的值(保留原大小写)
|
||
val = filter.split(" eq", 1)[1].strip().strip('"').strip("'")
|
||
where = "WHERE username = ?"
|
||
params.append(val)
|
||
elif "active eq" in filter_lower:
|
||
val = filter.split(" eq", 1)[1].strip().strip('"').strip("'").lower()
|
||
where = "WHERE is_active = ?"
|
||
params.append(1 if val in ("true", "1") else 0)
|
||
else:
|
||
# API-9: 不支持的 filter 返回 400
|
||
return _scim_error_response(
|
||
400, f"Unsupported filter syntax (scimType=invalidFilter): {filter}"
|
||
)
|
||
# API-1: 先 COUNT 全量总数
|
||
count_sql = f"SELECT COUNT(*) FROM users {where}"
|
||
list_sql = f"SELECT * FROM users {where} ORDER BY created_at ASC LIMIT ? OFFSET ?"
|
||
list_params = [*params, count, startIndex - 1]
|
||
async with aiosqlite.connect(str(db_path)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
# totalResults
|
||
cursor = await db.execute(count_sql, tuple(params))
|
||
total_results = (await cursor.fetchone())[0]
|
||
# 当前页
|
||
cursor = await db.execute(list_sql, tuple(list_params))
|
||
rows = await cursor.fetchall()
|
||
resources = [_user_to_scim(user_row_to_dict(r)) for r in rows]
|
||
return SCIMListResponse(
|
||
totalResults=total_results,
|
||
startIndex=startIndex,
|
||
itemsPerPage=len(resources),
|
||
Resources=resources,
|
||
).model_dump()
|