fischer-agentkit/src/agentkit/server/auth/scim/router.py

428 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 tokenauth.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()