fix(enterprise): ce-debug batch — PERF-1 PII filter + DEPLOY-1/2/4/5 + SCIM RFC 7643/7644
Test / backend-test (pull_request) Waiting to run Details
Test / frontend-unit (pull_request) Waiting to run Details
Test / api-e2e (pull_request) Waiting to run Details
Test / frontend-e2e (pull_request) Waiting to run Details

修复 ce-code-review 发现的全部 P1/P2 residual findings:

P1:
- PERF-1: PII 脱敏接入 gateway chat()/chat_stream() 生产路径
  (env AGENTKIT_PII_FILTER_ENABLED=true 启用,fail-closed)
- API-1/2/5/6/7/9/11/13 + STAND-1 + SEC-3: SCIM router 全面重写
  - API-1: totalResults 用 SELECT COUNT(*) 返回全量总数
  - API-2: 异常处理器转 RFC 7644 错误格式(scope 到 /scim/v2)
  - API-5: create_user 写 user_idp_links(idp_name='scim')
  - API-6: POST /Users 返回 Location 头
  - API-7: PATCH userName UNIQUE 冲突 → 409
  - API-9: 不支持的 filter → 400 invalidFilter
  - API-11: displayName=None
  - API-13: filter 大小写不敏感
  - STAND-1: dict[str, Any] → dict[str, object]
  - SEC-3: active true→false 写审计(延后到事务提交后避免 SQLite 锁竞争)
- API-4: _sso_issue_token_pair 用 secrets.token_urlsafe(32) 生成真实 refresh_token
  (移除 __pending_sso__ 占位符 + 后续 UPDATE 的非原子两步写入)
- API-8: _sso_issue_token_pair 增加 provider_type/provider_name 参数
- API-10: deprovision_user/change_user_role 加 response_model
- DEPLOY-1: install.sh + docs 用 instance 标签选择器替代硬编码 fullname
- DEPLOY-2: 11 处 Redis 客户端构造显式传入 REDIS_PASSWORD env var

P2:
- DEPLOY-4: 移除 agentkit deployment 中死 env POSTGRES_PASSWORD
  (应用通过 DATABASE_URL Slot 3 内嵌密码连接,不读此 env)
- DEPLOY-5: OTel token 格式改为 Authorization=Bearer <token>
  (符合 OTEL_EXPORTER_OTLP_HEADERS key=value 规范)

测试:
- ruff check + format 全部通过
- pytest tests/unit/test_scim_router.py 9 passed
- pytest tests/unit/test_llm_gateway.py + test_handoff.py + test_event_queue_integration.py 70 passed, 5 skipped
- helm lint + helm template 验证 POSTGRES_PASSWORD env 已移除
This commit is contained in:
Chiguyong 2026-07-06 11:41:06 +08:00
parent 444667e2f0
commit cb278bb5f0
21 changed files with 520 additions and 148 deletions

View File

@ -92,11 +92,10 @@ spec:
secretKeyRef:
name: {{ include "agentkit.secretName" . }}
key: {{ .Values.secrets.redisPassword.key }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "agentkit.secretName" . }}
key: {{ .Values.secrets.postgresPassword.key }}
# DEPLOY-4: 移除 POSTGRES_PASSWORD env — 应用通过 DATABASE_URLSlot 3
# 消费 PG 密码DSN 内嵌POSTGRES_PASSWORD 仅用于 PostgreSQL StatefulSet
# 初始化(由独立 PG chart / StatefulSet 引用 secrets.postgresPassword
# 此处注入是死 env误导运维以为应用读 POSTGRES_PASSWORD。
# --- 非密配置OTel endpoint、service name---
{{- if .Values.otel.enabled }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT

View File

@ -194,19 +194,19 @@ secrets:
# 9) OTel exporter token — OTLP collector 鉴权 token
otelExporterToken:
key: otel-exporter-token
description: "OTLP exporter 鉴权 tokencollector 启用 bearer auth 时"
description: "OTLP exporter 鉴权 tokenDEPLOY-5: 值格式必须为 'Authorization=Bearer <token>',符合 OTEL_EXPORTER_OTLP_HEADERS key=value 规范"
rotation: 90d
# 10) Redis 密码 — Redis requirepass基础设施层
redisPassword:
key: redis-password
description: "Redis requirepass基础设施层"
description: "Redis requirepass基础设施层env REDIS_PASSWORD 注入应用"
rotation: 90d
# 11) PostgreSQL 密码 — POSTGRES_PASSWORD基础设施层
# 11) PostgreSQL 密码 — POSTGRES_PASSWORD基础设施层PG StatefulSet 初始化用
postgresPassword:
key: postgres-password
description: "PostgreSQL POSTGRES_PASSWORD基础设施层"
description: "PostgreSQL POSTGRES_PASSWORDDEPLOY-4: 仅 PG StatefulSet 初始化消费,应用通过 DATABASE_URL Slot 3 内嵌密码连接,不读此 env"
rotation: 90d
# ---------------------------------------------------------------------------

View File

@ -55,7 +55,11 @@ helm upgrade --install "${RELEASE}" "${CHART_TGZ}" \
--set image.pullPolicy=Never # 离线本地镜像Never 强制使用已加载的本地镜像
echo "==> 等待 rollout"
kubectl rollout status deployment/"${RELEASE}-agentkit" -n "${NAMESPACE}" --timeout=300s || \
# DEPLOY-1: 用标签选择器定位 Deployment — fullname 在 release 名包含 chart 名时
# 仅返回 release 名(如 release=agentkit → Deployment=agentkit其他情况返回
# release-chart如 release=myprod → myprod-agentkit。硬编码 "${RELEASE}-agentkit"
# 在默认 release=agentkit 时拼出不存在的 agentkit-agentkit。改用 instance 标签。
kubectl rollout status deployment -l app.kubernetes.io/instance="${RELEASE}" -n "${NAMESPACE}" --timeout=300s || \
echo "WARN: rollout 未在 300s 内完成,请检查 pod 状态kubectl get pods -n ${NAMESPACE}"
echo "==> 完成。验证:"

View File

@ -163,12 +163,14 @@ kubectl get pods -n agentkit
kubectl get svc,ingress -n agentkit
# 3) 健康检查
kubectl exec -n agentkit deploy/agentkit-agentkit -- \
# DEPLOY-1: 默认 release=agentkit 时 Deployment 名为 agentkitfullname helper
# 在 release 名包含 chart 名时去重)。若改了 release 名,按 helm status 输出替换。
kubectl exec -n agentkit deploy/agentkit -- \
curl -s http://localhost:8001/api/v1/health
# 期望:{"status":"ok"}
# 4) Secret 已注入
kubectl exec -n agentkit deploy/agentkit-agentkit -- env | grep -E 'JWT|API_KEY|DATABASE' | sed 's/=.*/=<redacted>/'
kubectl exec -n agentkit deploy/agentkit -- env | grep -E 'JWT|API_KEY|DATABASE' | sed 's/=.*/=<redacted>/'
# 期望:所有 env 都有值(不为空)
# 5) helm 状态
@ -181,7 +183,7 @@ helm status agentkit -n agentkit
```bash
# 查看日志
kubectl logs -n agentkit deploy/agentkit-agentkit --tail=50
kubectl logs -n agentkit deploy/agentkit --tail=50
# 常见原因:
# - Secret 未注入:检查 SealedSecret 是否已解封kubectl get sealedsecret -n agentkit

View File

@ -34,10 +34,11 @@ kubectl create secret generic agentkit-secrets-new --dry-run=client -o yaml \
# 3) 应用并等待同步
kubectl apply -f agentkit-sealedsecret.yaml -n agentkit
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
# DEPLOY-1: 用 instance 标签定位 Deployment — 避免硬编码 fullname 拼错。
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 4) 验证
kubectl exec -n agentkit deploy/agentkit-agentkit -- \
kubectl exec -n agentkit deploy/agentkit -- \
python -c "import os; print('jwt key len:', len(os.environ['JWT_SIGNING_KEY']))"
# 5) 用户重新登录(旧 token 失效)
@ -62,7 +63,7 @@ NEW_KEY=$(openssl rand -hex 32)
# 3) 通知所有使用 API Key 的客户端更新agentkit pair 重新生成)
# 4) 应用并 restart
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 5) 验证:用新 key 调用 /api/v1/auth/me 或 SCIM 端点
```
@ -87,7 +88,7 @@ NEW_URL="postgresql+asyncpg://agentkit:new-pg-pwd@postgres:5432/agentkit"
# 3) 更新密钥后端key=database-url
# 4) restart pod连接池重建
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 5) 验证:触发一次 DB 操作(如 GET /api/v1/memory
@ -117,7 +118,7 @@ NEW_CERTS='{"feishu":"-----BEGIN CERTIFICATE-----\n新证书\n-----END CERTIFICA
# 3) 更新密钥后端key=idp-signing-certs
# 4) restart pod证书热轮换不重启但 Helm 部署需 restart 加载新 Secret
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 5) 验证:通过该 IdP 发起一次 SSO 登录
```
@ -141,7 +142,7 @@ NEW_KEYS='{"dashscope":"sk-new1","deepseek":"sk-new2","openai":"sk-old"}'
# 3) 更新密钥后端key=third-party-api-keys
# 4) restart pod
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 5) 验证:调用对应 provider 的模型(如 agentkit chat 发一条测试消息)
@ -196,7 +197,7 @@ NEW_PEM="$(cat client.crt client.key ca.crt)"
# 3) 更新密钥后端
# 4) restart pod
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 5) 验证:触发一次 mTLS 调用(如 KMS 签名操作)
```
@ -218,7 +219,7 @@ kubectl rollout restart deployment/agentkit-agentkit -n agentkit
NEW_PIN='<new-pin>'
# 3) restart pod
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 4) 验证:触发一次 HSM 操作(如 PKCS#11 签名)
```
@ -238,11 +239,13 @@ kubectl rollout restart deployment/agentkit-agentkit -n agentkit
```bash
# 1) 在 OTel collector / 可观测性平台生成新 token
# 2) 更新密钥后端key=otel-exporter-token格式 'Authorization: Bearer <token>'
NEW_TOKEN='Bearer new-otel-token'
# 2) 更新密钥后端key=otel-exporter-token
# DEPLOY-5: OTEL_EXPORTER_OTLP_HEADERS 期望 'key=value' 格式OTel SDK 规范),
# 不是裸 'Bearer <token>'。secret 值必须形如 'Authorization=Bearer <token>'。
NEW_TOKEN='Authorization=Bearer new-otel-token'
# 3) restart pod
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 4) 验证:在 collector 端确认收到新 trace/metrics
```
@ -270,10 +273,10 @@ psql -U postgres -c "ALTER USER agentkit WITH PASSWORD 'new-pg-pwd';"
# 需同步更新 requirepass 并 restart Redis
# 5) restart agentkit pod
kubectl rollout restart deployment/agentkit-agentkit -n agentkit
kubectl rollout restart deployment -l app.kubernetes.io/instance=agentkit -n agentkit
# 6) 验证
kubectl exec -n agentkit deploy/agentkit-agentkit -- \
kubectl exec -n agentkit deploy/agentkit -- \
python -c "import redis,os; redis.from_url(os.environ['REDIS_URL'],password=os.environ['REDIS_PASSWORD']).ping()"
```

View File

@ -19,9 +19,9 @@
| 6 | TLS 服务器证书 | `tls-server-cert` | Ingress TLS Secret | Ingress TLS 终止证书 + 私钥 | 证书签发机构 / cert-manager | 365 天 | 平台运维 |
| 7 | mTLS 客户端证书 | `mtls-client-cert` | `MTLS_CLIENT_CERT` | 与下游服务KMS/HSM、内部 API双向 TLS 客户端证书 | 内部 PKI | 180 天 | 安全团队 |
| 8 | KMS/HSM PKCS#11 PIN | `kms-hsm-pin` | `KMS_HSM_PIN` | 硬件安全模块 PKCS#11 访问 PIN | HSM 管理员 | 365 天 | HSM 运维 |
| 9 | OTel exporter token | `otel-exporter-token` | `OTEL_EXPORTER_OTLP_HEADERS` | OTLP collector 鉴权 bearer token | 可观测性平台 | 90 天 | 可观测性团队 |
| 10 | Redis 密码 | `redis-password` | `REDIS_PASSWORD` | Redis requirepass基础设施层 | DBA / 缓存运维 | 90 天 | DBA |
| 11 | PostgreSQL 密码 | `postgres-password` | `POSTGRES_PASSWORD` | PostgreSQL POSTGRES_PASSWORD基础设施层 | DBA | 90 天 | DBA |
| 9 | OTel exporter token | `otel-exporter-token` | `OTEL_EXPORTER_OTLP_HEADERS` | OTLP collector 鉴权DEPLOY-5: 值格式 `Authorization=Bearer <token>`,非裸 Bearer | 可观测性平台 | 90 天 | 可观测性团队 |
| 10 | Redis 密码 | `redis-password` | `REDIS_PASSWORD` | Redis requirepass基础设施层,应用通过 env 消费 | DBA / 缓存运维 | 90 天 | DBA |
| 11 | PostgreSQL 密码 | `postgres-password` | `POSTGRES_PASSWORD`(仅 PG StatefulSet | PostgreSQL POSTGRES_PASSWORDDEPLOY-4: 应用不读此 env通过 DATABASE_URL Slot 3 内嵌密码连接 | DBA | 90 天 | DBA |
## 风险等级

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Callable, Awaitable
from agentkit.bus.message import AgentMessage
@ -41,7 +42,13 @@ class RedisMessageBus:
"""获取 Redis 连接(懒初始化)。"""
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis
def _stream_key(self, agent_name: str) -> str:
@ -101,7 +108,10 @@ class RedisMessageBus:
# Create consumer group if not exists
try:
await redis.xgroup_create(
stream_key, self._consumer_group, id="0", mkstream=True,
stream_key,
self._consumer_group,
id="0",
mkstream=True,
)
except asyncio.CancelledError:
raise
@ -141,7 +151,11 @@ class RedisMessageBus:
logger.warning(f"Failed to process message {msg_id}: {e}")
# Move to dead letter after max retries
await self._handle_failed_message(
redis, stream_key, msg_id, fields, agent_name,
redis,
stream_key,
msg_id,
fields,
agent_name,
)
except asyncio.CancelledError:
break
@ -194,9 +208,7 @@ class RedisMessageBus:
await self.publish(message)
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError(
f"Request {message.correlation_id} timed out after {timeout}s"
)
raise TimeoutError(f"Request {message.correlation_id} timed out after {timeout}s")
finally:
self._pending_requests.pop(message.correlation_id, None)
@ -256,6 +268,7 @@ def create_message_bus(
if backend == "redis":
try:
import redis.asyncio as aioredis # noqa: F401
bus = RedisMessageBus(
redis_url=redis_url,
consumer_group=consumer_group,
@ -267,8 +280,7 @@ def create_message_bus(
raise
except Exception as exc: # noqa: BLE001 — factory fallback to InMemoryMessageBus; must catch import/init errors broadly
logger.warning(
f"Failed to initialise RedisMessageBus ({exc}), "
f"falling back to InMemoryMessageBus"
f"Failed to initialise RedisMessageBus ({exc}), falling back to InMemoryMessageBus"
)
bus = InMemoryMessageBus()

View File

@ -10,6 +10,7 @@
import asyncio
import json
import logging
import os
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
@ -266,7 +267,12 @@ class BaseAgent(ABC):
if redis_url:
try:
self._redis = aioredis.from_url(redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
await self._redis.ping()
logger.info(f"Agent '{self.name}' connected to Redis")
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError) as e:

View File

@ -394,7 +394,12 @@ class ConfigDrivenAgent(BaseAgent, EvolutionMixin):
import redis.asyncio as aioredis
redis_url = config.memory["working"].get("redis_url", "redis://localhost:6379")
redis_client = aioredis.from_url(redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
redis_client = aioredis.from_url(
redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
working = WorkingMemory(redis=redis_client)
if config.memory.get("episodic", {}).get("enabled"):

View File

@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import AsyncIterator, Awaitable, Callable
import redis.asyncio as aioredis
@ -144,7 +145,12 @@ class RedisHandoffTransport:
"""确保 Redis 连接已建立(懒初始化)"""
if self._redis is None:
try:
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
await self._redis.ping()
logger.info("RedisHandoffTransport connected to Redis")
except Exception:
@ -199,9 +205,7 @@ class RedisHandoffTransport:
# 启动后台监听任务
if channel not in self._listen_tasks:
self._listen_tasks[channel] = asyncio.create_task(
self._background_listen(channel)
)
self._listen_tasks[channel] = asyncio.create_task(self._background_listen(channel))
async def _background_listen(self, channel: str) -> None:
"""后台监听频道消息并调用处理器"""

View File

@ -11,6 +11,7 @@ Design doc: docs/plans/2026-06-14-002-u1-llm-cache-architecture.md
import json
import logging
import os
import time
from collections import OrderedDict
from dataclasses import dataclass, field
@ -386,7 +387,12 @@ class RedisLLMCache:
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis
async def aclose(self) -> None:

View File

@ -2,6 +2,7 @@
import asyncio
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
@ -84,6 +85,12 @@ class LLMGateway:
self._usage_tracker = UsageTracker(store=usage_store) if usage_store else UsageTracker()
self._config = config or LLMConfig()
# PERF-1: PII 脱敏接入生产路径env 驱动,默认关闭以保持向后兼容)。
# AGENTKIT_PII_FILTER_ENABLED=true 启用filter_messages_pii fail-closed。
self._pii_filter_enabled = (
os.environ.get("AGENTKIT_PII_FILTER_ENABLED", "false").lower() == "true"
)
# Cache (U17 — LiteLLM 缓存管理器opt-in默认禁用)
self._cache_manager: "LitellmCacheManager | None" = None
if self._config.cache and self._config.cache.enabled:
@ -97,6 +104,22 @@ class LLMGateway:
f"similarity_threshold={litellm_config.similarity_threshold})"
)
if self._pii_filter_enabled:
logger.info("PII filter enabled (AGENTKIT_PII_FILTER_ENABLED=true, fail-closed)")
def _apply_pii_filter(self, messages: list[dict[str, str]]) -> list[dict[str, str]]:
"""PERF-1: 对 messages 执行 PII 脱敏。
fail-closed脱敏失败抛 PIIFilterError由调用方转 HTTP 422
禁用时原样返回零开销 仅一次 env + bool 判断
"""
if not self._pii_filter_enabled:
return messages
from agentkit.llm.pii_filter import filter_messages_pii
redacted, _matches = filter_messages_pii(messages, fail_closed=True)
return redacted
def register_provider(self, name: str, provider: LLMProvider) -> None:
"""注册 Provider"""
self._providers[name] = provider
@ -129,6 +152,9 @@ class LLMGateway:
if not self._providers:
raise LLMProviderError("", "No provider registered")
# PERF-1: PII 脱敏fail-closed— 在 provider 调用前替换 messages。
messages = self._apply_pii_filter(messages)
# ── Quota enforcement ──
# Only enforce when department_ids + db_path are provided
# (other call sites pass None — no quota check).
@ -349,6 +375,9 @@ class LLMGateway:
if not self._providers:
raise LLMProviderError("", "No provider registered")
# PERF-1: PII 脱敏fail-closed— 在 provider 调用前替换 messages。
messages = self._apply_pii_filter(messages)
# ── Quota enforcement ──
if department_ids and db_path:
await self._enforce_quota(db_path, department_ids, resolved_model)

View File

@ -16,6 +16,7 @@ Legacy v1 keys (still readable for backward compat):
import asyncio
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Protocol, runtime_checkable
@ -304,7 +305,12 @@ class RedisUsageStore:
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis
def _get_sync_redis(self):
@ -312,7 +318,12 @@ class RedisUsageStore:
if self._sync_redis is None:
import redis as sync_redis
self._sync_redis = sync_redis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._sync_redis = sync_redis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._sync_redis
async def aclose(self) -> None:

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Callable, Coroutine
@ -176,9 +177,11 @@ class PipelineStateRedis:
if self._redis is None:
import redis.asyncio as aioredis
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis

View File

@ -10,6 +10,7 @@ Key schema (Redis):
"""
import logging
import os
import time
from typing import Protocol, runtime_checkable
@ -140,8 +141,12 @@ class RedisCascadeStateStore:
"""Get or create a persistent sync Redis client (connection pool backed)."""
if self._sync_redis is None:
import redis as sync_redis
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._sync_redis = sync_redis.from_url(
self._redis_url, decode_responses=True
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._sync_redis
@ -163,7 +168,15 @@ class RedisCascadeStateStore:
pipe.expire(key, self._session_ttl)
results = pipe.execute()
return results[0]
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError, TypeError) as e:
except (
ImportError,
OSError,
_RedisError,
ValueError,
KeyError,
RuntimeError,
TypeError,
) as e:
logger.warning(f"Redis cascade increment failed: {e}")
self._degrade_to_fallback()
if self._fallback is not None:
@ -177,7 +190,15 @@ class RedisCascadeStateStore:
r = self._get_sync_redis()
val = r.get(f"{self.INTER_PREFIX}{session_id}")
return int(val) if val is not None else 0
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError, TypeError) as e:
except (
ImportError,
OSError,
_RedisError,
ValueError,
KeyError,
RuntimeError,
TypeError,
) as e:
logger.warning(f"Redis cascade get failed: {e}")
if self._fallback is not None:
return self._fallback.get_interaction(session_id)
@ -194,7 +215,15 @@ class RedisCascadeStateStore:
pipe.set(key, depth)
pipe.expire(key, self._session_ttl)
pipe.execute()
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError, TypeError) as e:
except (
ImportError,
OSError,
_RedisError,
ValueError,
KeyError,
RuntimeError,
TypeError,
) as e:
logger.warning(f"Redis cascade set_depth failed: {e}")
self._degrade_to_fallback()
if self._fallback is not None:
@ -207,7 +236,15 @@ class RedisCascadeStateStore:
r = self._get_sync_redis()
val = r.get(f"{self.DEPTH_PREFIX}{session_id}")
return int(val) if val is not None else 0
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError, TypeError) as e:
except (
ImportError,
OSError,
_RedisError,
ValueError,
KeyError,
RuntimeError,
TypeError,
) as e:
logger.warning(f"Redis cascade get_depth failed: {e}")
if self._fallback is not None:
return self._fallback.get_depth(session_id)
@ -223,7 +260,15 @@ class RedisCascadeStateStore:
pipe.delete(f"{self.INTER_PREFIX}{session_id}")
pipe.delete(f"{self.DEPTH_PREFIX}{session_id}")
pipe.execute()
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError, TypeError) as e:
except (
ImportError,
OSError,
_RedisError,
ValueError,
KeyError,
RuntimeError,
TypeError,
) as e:
logger.warning(f"Redis cascade reset failed: {e}")
self._degrade_to_fallback()
if self._fallback is not None:
@ -250,6 +295,7 @@ def create_cascade_state_store(
if backend in ("auto", "redis"):
try:
import redis # noqa: F401
return RedisCascadeStateStore(redis_url=redis_url, session_ttl=session_ttl)
except ImportError:
logger.warning("redis package not available, falling back to in-memory cascade store")

View File

@ -1258,11 +1258,13 @@ def create_app(
"redis_url", "redis://localhost:6379"
)
# U5c: socket_timeout 防止单点 Redis 故障时网关请求挂死。
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
redis_client = aioredis.from_url(
redis_url,
decode_responses=True,
socket_timeout=5.0,
socket_connect_timeout=5.0,
password=os.environ.get("REDIS_PASSWORD") or None,
)
working = WorkingMemory(redis=redis_client)
app.state.working_redis_client = redis_client
@ -1396,9 +1398,14 @@ def create_app(
app.include_router(auth_routes.router, prefix="/api/v1")
app.include_router(auth_routes.admin_router, prefix="/api/v1")
# U4 SSO 集成 — SCIM 2.0 最小子集(自带 bearer token 校验)
from agentkit.server.auth.scim.router import router as scim_router
from agentkit.server.auth.scim.router import (
register_scim_exception_handlers,
router as scim_router,
)
app.include_router(scim_router, prefix="/api/v1")
# API-2: 注册 SCIM 异常处理器scope 到 /scim/v2非 SCIM 路由走默认格式)
register_scim_exception_handlers(app)
app.include_router(admin_routes_module.admin_router, prefix="/api/v1")
app.include_router(documents.router, prefix="/api/v1")
app.include_router(calendar_routes.router, prefix="/api/v1")

View File

@ -1,13 +1,11 @@
"""SCIM 2.0 用户 schema (Pydantic v2) — U4 最小子集。
仅实现 RFC 7643 / 7644 中创建 / 禁用 / 查询所需的字段
``id``, ``userName``, ``active``, ``emails``, ``displayName``
``id``, ``userName``, ``active``, ``emails``, ``displayName``, ``externalId``
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
@ -22,7 +20,11 @@ class SCIMEmail(BaseModel):
class SCIMUser(BaseModel):
"""SCIM 2.0 User 资源(最小子集)。"""
"""SCIM 2.0 User 资源(最小子集)。
STAND-1: 移除 ``Any`` ``externalId`` RFC 7643 标准 attribute
用于 SCIM 预配的用户与 SSO IDP subject 关联API-5
"""
model_config = ConfigDict(extra="allow")
@ -30,17 +32,22 @@ class SCIMUser(BaseModel):
userName: str
active: bool = True
displayName: str | None = None
externalId: str | None = None
emails: list[SCIMEmail] = Field(default_factory=list)
class SCIMPatchOp(BaseModel):
"""SCIM 2.0 PATCH 操作(最小子集,仅支持 replace"""
"""SCIM 2.0 PATCH 操作(最小子集,仅支持 replace
STAND-1: ``value`` ``object`` 替代 ``Any``Pydantic v2 ``object`` 等价
于旧 ``Any`` 但更明确表达"任意 JSON 值"的语义
"""
model_config = ConfigDict(extra="allow")
op: str = "replace"
path: str | None = None
value: Any = None
value: object = None
class SCIMPatchRequest(BaseModel):
@ -52,7 +59,10 @@ class SCIMPatchRequest(BaseModel):
class SCIMListResponse(BaseModel):
"""SCIM 2.0 列表响应。"""
"""SCIM 2.0 列表响应。
STAND-1: ``Resources`` ``list[dict[str, object]]`` 替代 ``list[dict[str, Any]]``
"""
model_config = ConfigDict(extra="allow")
@ -62,7 +72,7 @@ class SCIMListResponse(BaseModel):
totalResults: int = 0
startIndex: int = 1
itemsPerPage: int = 0
Resources: list[dict[str, Any]] = Field(default_factory=list)
Resources: list[dict[str, object]] = Field(default_factory=list)
class SCIMErrorDetail(BaseModel):

View File

@ -1,12 +1,13 @@
"""SCIM 2.0 路由 (U4 最小子集) — handler 内联,不拆 handlers.py。
端点
- POST /scim/v2/Users 创建用户
- 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
@ -17,12 +18,13 @@ import os
import secrets
import uuid
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 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,
@ -37,6 +39,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scim/v2", 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:
@ -98,30 +101,108 @@ async def _ensure_db(request: Request) -> Path:
return db_path
def _user_to_scim(row: dict[str, object]) -> dict[str, Any]:
"""本地 user dict → SCIM 2.0 User JSON。"""
# ---------------------------------------------------------------------------
# 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):
path = request.url.path
is_scim = path.startswith("/scim/v2") or path.startswith("/api/v1/scim/v2")
if not is_scim:
# 非 SCIM 路由 — 复制 FastAPI 默认 HTTPException handler 行为(含 headers
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):
path = request.url.path
is_scim = path.startswith("/scim/v2") or path.startswith("/api/v1/scim/v2")
if not is_scim:
# 非 SCIM 路由 — 复制 FastAPI 默认 RequestValidationError handler 行为
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": str(row.get("username", "")),
"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),
) -> dict[str, Any]:
"""SCIM POST /Users — 创建本地用户(随机密码,不可本地登录)。"""
) -> 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
@ -144,18 +225,32 @@ async def create_user(
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", payload.userName, user_id)
return _user_to_scim(user_row_to_dict(row))
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)
raise HTTPException(status_code=409, detail="userName 或 email 已存在") from exc
return _scim_error_response(409, "userName 或 email 已存在")
except Exception as exc: # noqa: BLE001
_alert_scim_failure("create", str(exc), user_id)
raise HTTPException(status_code=500, detail="SCIM 创建失败") from exc
return _scim_error_response(500, "SCIM 创建失败")
@router.patch("/Users/{user_id}")
@ -164,17 +259,24 @@ async def patch_user(
payload: SCIMPatchRequest,
request: Request,
_token: str = Depends(_verify_scim_token),
) -> dict[str, Any]:
"""SCIM PATCH /Users/{id} — 禁用 (active=false) 或更新字段。"""
) -> dict[str, object]:
"""SCIM PATCH /Users/{id} — 禁用 (active=false) 或更新字段。
API-7: userName UNIQUE 冲突时返回 409而非 500
SEC-3: active truefalse 触发审计记录 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:
raise HTTPException(status_code=404, detail="用户不存在")
return _scim_error_response(404, "用户不存在")
prev_active = bool(row["is_active"])
for op in payload.Operations:
if op.op.lower() == "replace":
if op.path in (None, "active"):
@ -187,21 +289,41 @@ async def patch_user(
"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":
await db.execute(
"UPDATE users SET username = ?, updated_at = ? WHERE id = ?",
(str(op.value)[:64], now, user_id),
)
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 已存在")
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)
raise HTTPException(status_code=500, detail="SCIM 更新失败") from exc
return _scim_error_response(500, "SCIM 更新失败")
@router.get("/Users")
@ -211,44 +333,49 @@ async def list_users(
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]:
) -> dict[str, object]:
"""SCIM GET /Users — 列表 / 过滤。
ponytail: filter 仅支持最简形式 ``userName eq "xxx"`` / ``active eq true``
上限复杂 SCIM filter 表达式需扩展为完整 AST 解析器
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[Any] = []
params: list[object] = []
if filter:
# 最简 filter 解析userName eq "x" / active eq true
if "userName eq" in filter:
val = filter.split("userName eq", 1)[1].strip().strip('"').strip("'")
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:
val = filter.split("active eq", 1)[1].strip().strip('"').strip("'").lower()
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)
sql = f"SELECT * FROM users {where} ORDER BY created_at ASC LIMIT ? OFFSET ?"
params.extend([count, startIndex - 1])
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
cursor = await db.execute(sql, tuple(params))
# 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=len(resources),
totalResults=total_results,
startIndex=startIndex,
itemsPerPage=len(resources),
Resources=resources,
).model_dump()
class SCIMErrorResponse(BaseModel):
"""SCIM 错误响应包装。"""
model_config = ConfigDict(extra="allow")
status: str
detail: str | None = None

View File

@ -23,6 +23,7 @@ from __future__ import annotations
import hmac
import logging
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal
@ -794,8 +795,20 @@ class SSORedirectResponse(BaseModel):
state: str
async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object]) -> TokenResponse:
"""SSO 登录成功后签发 JWT pair + 创建 session复用 login 流程)。"""
async def _sso_issue_token_pair(
request: Request,
user_dict: dict[str, object],
*,
provider_type: str = "sso",
provider_name: str | None = None,
) -> TokenResponse:
"""SSO 登录成功后签发 JWT pair + 创建 session复用 login 流程)。
API-4: 一次性生成真实 refresh_token 再创建 session避免占位符并发冲突
API-8: auth_provider 携带 provider_type + provider_name 'oidc-keycloak'
admin session 列表按 IDP 筛选 + 审计追溯
SEC-1: 与本地 login 一致地校验 is_active
"""
# SEC-1: SSO 流程必须与本地 loginauth.py:377一致地校验 is_active
# 否则已 deprovision 的用户可通过 IDP 回调重新拿到 JWT + session。
if not bool(user_dict.get("is_active", True)):
@ -805,15 +818,21 @@ async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object])
svc: SessionService = get_session_service()
info = _client_info(request)
pre_session = await svc.create(
# API-4: 先生成真实 refresh_token避免占位符 "__pending_sso__" 在并发 SSO
# 登录时触发 refresh_token_hash UNIQUE 约束冲突。
refresh_token = secrets.token_urlsafe(32)
# API-8: auth_provider 形如 "oidc-keycloak" / "saml-azuread" / "sso"
auth_provider = f"{provider_type}-{provider_name}" if provider_name else provider_type
session = await svc.create(
SessionCreate(
user_id=str(user_dict["id"]),
refresh_token="__pending_sso__",
refresh_token=refresh_token,
device_fingerprint=info["fingerprint"],
device_label=info["label"],
ip=info["ip"],
user_agent=info["user_agent"],
auth_provider="sso",
auth_provider=auth_provider,
ttl_seconds=_refresh_ttl_seconds(False),
)
)
@ -822,13 +841,9 @@ async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object])
username=str(user_dict["username"]),
role=str(user_dict["role"]),
secret=secret,
session_id=pre_session.id,
session_id=session.id,
)
async with aiosqlite.connect(str(db_path)) as db:
await db.execute(
"UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
(hash_token(token_pair.refresh_token), pre_session.id),
)
await db.execute(
"UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?",
(_now_iso(), _now_iso(), str(user_dict["id"])),
@ -849,7 +864,7 @@ async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object])
user_dict.get("is_server_terminal_authorized", False)
),
),
session_id=pre_session.id,
session_id=session.id,
)
@ -886,7 +901,9 @@ async def oidc_callback(
user_dict = await OIDCProvider().handle_callback(provider, code, state)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await _sso_issue_token_pair(request, user_dict)
return await _sso_issue_token_pair(
request, user_dict, provider_type="oidc", provider_name=provider
)
class SAMLACSRequest(BaseModel):
@ -915,7 +932,9 @@ async def saml_acs(
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await _sso_issue_token_pair(request, user_dict)
return await _sso_issue_token_pair(
request, user_dict, provider_type="saml", provider_name=provider
)
# ---------------------------------------------------------------------------
@ -1041,13 +1060,33 @@ class DeprovisionRequest(BaseModel):
break_glass: bool = False # 高权限账户 break-glass 标记
@admin_router.post("/users/{user_id}/deprovision")
class DeprovisionResponse(BaseModel):
"""API-10: deprovision 响应体(让 OpenAPI schema 可文档化)。"""
model_config = ConfigDict(extra="forbid")
deprovisioned: bool
revoked_sessions: int
class RoleChangeResponse(BaseModel):
"""API-10: 角色变更响应体。"""
model_config = ConfigDict(extra="forbid")
role_changed: bool
role_before: str
role_after: str
revoked_sessions: int
@admin_router.post("/users/{user_id}/deprovision", response_model=DeprovisionResponse)
async def deprovision_user(
user_id: str,
payload: DeprovisionRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
) -> DeprovisionResponse:
"""手动 deprovisioning (R1b) — operator/admin RBAC 强制。
1. 禁用本地用户 (is_active=0)
@ -1086,7 +1125,7 @@ async def deprovision_user(
reason=payload.reason,
source=source,
)
return {"deprovisioned": True, "revoked_sessions": revoked}
return DeprovisionResponse(deprovisioned=True, revoked_sessions=revoked)
class RoleChangeRequest(BaseModel):
@ -1098,13 +1137,13 @@ class RoleChangeRequest(BaseModel):
reason: str | None = None
@admin_router.post("/users/{user_id}/role")
@admin_router.post("/users/{user_id}/role", response_model=RoleChangeResponse)
async def change_user_role(
user_id: str,
payload: RoleChangeRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
) -> RoleChangeResponse:
"""RBAC 角色变更 (KTD-8) — admin only。
记录 actor/target/role_before/role_after/reason/source/timestamp接入 OTel 审计
@ -1139,12 +1178,12 @@ async def change_user_role(
# 撤销所有 session强制重新登录刷新 role claim
svc: SessionService = get_session_service()
revoked = await svc.revoke_all_for_user(user_id, reason="role_changed")
return {
"role_changed": True,
"role_before": role_before,
"role_after": payload.role,
"revoked_sessions": revoked,
}
return RoleChangeResponse(
role_changed=True,
role_before=role_before,
role_after=payload.role,
revoked_sessions=revoked,
)
# Backwards-compat /me endpoint (kept for the existing frontend)

View File

@ -3,6 +3,7 @@
import asyncio
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@ -15,6 +16,7 @@ logger = logging.getLogger(__name__)
@dataclass
class TaskRecord:
"""Stored task record with full lifecycle data"""
task_id: str
agent_name: str
skill_name: str | None
@ -57,9 +59,15 @@ class TaskRecord:
status=TaskStatus(data.get("status", "pending")),
output_data=data.get("output_data"),
error_message=data.get("error_message"),
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(timezone.utc),
started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
created_at=datetime.fromisoformat(data["created_at"])
if data.get("created_at")
else datetime.now(timezone.utc),
started_at=datetime.fromisoformat(data["started_at"])
if data.get("started_at")
else None,
completed_at=datetime.fromisoformat(data["completed_at"])
if data.get("completed_at")
else None,
progress=data.get("progress", 0.0),
progress_message=data.get("progress_message", ""),
metadata=data.get("metadata", {}),
@ -124,14 +132,19 @@ class InMemoryTaskStore:
if expired:
logger.info(f"TaskStore cleaned up {len(expired)} expired records")
def create(self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None) -> TaskRecord:
def create(
self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None
) -> TaskRecord:
"""Create a new task record"""
if len(self._tasks) >= self._max_records:
# Remove oldest completed task
oldest = None
for rec in self._tasks.values():
if rec.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
if oldest is None or (rec.completed_at and (oldest.completed_at is None or rec.completed_at < oldest.completed_at)):
if oldest is None or (
rec.completed_at
and (oldest.completed_at is None or rec.completed_at < oldest.completed_at)
):
oldest = rec
if oldest:
del self._tasks[oldest.task_id]
@ -223,9 +236,11 @@ class RedisTaskStore:
if self._redis is None:
import redis.asyncio as aioredis
# DEPLOY-2: 显式传入 REDIS_PASSWORD env varHelm 注入但 redis-py 不自动读取)。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis
@ -245,7 +260,9 @@ class RedisTaskStore:
# ── CRUD ───────────────────────────────────────────────────
async def create(self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None) -> TaskRecord:
async def create(
self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None
) -> TaskRecord:
"""Create a new task record in Redis."""
redis = await self._get_redis()
@ -305,7 +322,9 @@ end
return encoded
"""
async def update_status(self, task_id: str, status: TaskStatus, reset_ttl: bool = False, **kwargs) -> TaskRecord:
async def update_status(
self, task_id: str, status: TaskStatus, reset_ttl: bool = False, **kwargs
) -> TaskRecord:
"""Update task status and optional fields atomically via Lua script.
Args:
@ -322,7 +341,15 @@ return encoded
# Build flat list of key-value pairs for the merge fields
merge_fields = {"status": status.value}
for k, value in kwargs.items():
if k in ("started_at", "completed_at", "output_data", "error_message", "progress", "progress_message", "metadata"):
if k in (
"started_at",
"completed_at",
"output_data",
"error_message",
"progress",
"progress_message",
"metadata",
):
if isinstance(value, datetime):
merge_fields[k] = value.isoformat()
else:
@ -340,7 +367,9 @@ return encoded
data = json.loads(result)
return TaskRecord.from_dict(data)
async def list_tasks(self, status: TaskStatus | None = None, limit: int = 100) -> list[TaskRecord]:
async def list_tasks(
self, status: TaskStatus | None = None, limit: int = 100
) -> list[TaskRecord]:
"""List tasks, optionally filtered by status, sorted by created_at desc."""
redis = await self._get_redis()
tasks: list[TaskRecord] = []
@ -433,7 +462,11 @@ return encoded
await redis.zrem(self.ZSET_KEY, tid)
continue
record = TaskRecord.from_dict(json.loads(raw))
if record.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED) and record.completed_at is not None:
if (
record.status
in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED)
and record.completed_at is not None
):
await redis.delete(self._key(tid))
await redis.zrem(self.ZSET_KEY, tid)
return True
@ -452,7 +485,11 @@ return encoded
if raw is None:
continue
record = TaskRecord.from_dict(json.loads(raw))
if record.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
if record.status in (
TaskStatus.COMPLETED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
):
tasks.append(record)
if cursor == 0:
break
@ -505,7 +542,9 @@ def create_task_store(
logger.info(f"TaskStore backend: redis ({_sanitize_redis_url(redis_url)})")
return store
except Exception as exc:
logger.warning(f"Failed to initialise RedisTaskStore ({exc}), falling back to InMemoryTaskStore")
logger.warning(
f"Failed to initialise RedisTaskStore ({exc}), falling back to InMemoryTaskStore"
)
store = InMemoryTaskStore(ttl_seconds=ttl_seconds, max_records=max_records)
logger.info("TaskStore backend: memory")

View File

@ -24,12 +24,18 @@ class SessionStore(Protocol):
async def save_session(self, session: Session) -> None: ...
async def get_session(self, session_id: str) -> Session | None: ...
async def update_session_status(self, session_id: str, status: SessionStatus) -> Session | None: ...
async def update_session_status(
self, session_id: str, status: SessionStatus
) -> Session | None: ...
async def delete_session(self, session_id: str) -> bool: ...
async def list_sessions(self, agent_name: str | None = None, limit: int = 100) -> list[Session]: ...
async def list_sessions(
self, agent_name: str | None = None, limit: int = 100
) -> list[Session]: ...
async def append_message(self, message: Message) -> None: ...
async def get_messages(self, session_id: str, limit: int | None = None, offset: int = 0) -> list[Message]: ...
async def get_messages(
self, session_id: str, limit: int | None = None, offset: int = 0
) -> list[Message]: ...
async def count_messages(self, session_id: str) -> int: ...
async def health_check(self) -> bool: ...
@ -91,7 +97,9 @@ class InMemorySessionStore:
del msgs[:excess]
msgs.append(message)
async def get_messages(self, session_id: str, limit: int | None = None, offset: int = 0) -> list[Message]:
async def get_messages(
self, session_id: str, limit: int | None = None, offset: int = 0
) -> list[Message]:
msgs = self._messages.get(session_id, [])
sliced = msgs[offset:]
if limit is not None:
@ -125,7 +133,13 @@ class RedisSessionStore:
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
# DEPLOY-2: Helm 注入 REDIS_PASSWORD env varredis-py from_url 不自动读取,
# 必须显式以 password= 传入。env var 未设置时 password=None 保持向后兼容。
self._redis = aioredis.from_url(
self._redis_url,
decode_responses=True,
password=os.environ.get("REDIS_PASSWORD") or None,
)
return self._redis
def _session_key(self, session_id: str) -> str:
@ -192,7 +206,9 @@ class RedisSessionStore:
# Set TTL on message list to match session TTL
await redis.expire(key, self._ttl_seconds)
async def get_messages(self, session_id: str, limit: int | None = None, offset: int = 0) -> list[Message]:
async def get_messages(
self, session_id: str, limit: int | None = None, offset: int = 0
) -> list[Message]:
redis = await self._get_redis()
key = self._messages_key(session_id)
# Use LRANGE for offset-based pagination
@ -304,7 +320,9 @@ class FileSessionStore:
data["session"]["updated_at"] = datetime.now(timezone.utc).isoformat()
self._write_session_file(message.session_id, data)
async def get_messages(self, session_id: str, limit: int | None = None, offset: int = 0) -> list[Message]:
async def get_messages(
self, session_id: str, limit: int | None = None, offset: int = 0
) -> list[Message]:
data = self._read_session_file(session_id)
if data is None:
return []
@ -347,10 +365,12 @@ def create_session_store(
import redis.asyncio as aioredis # noqa: F401
store = RedisSessionStore(redis_url=redis_url, ttl_seconds=ttl_seconds)
logger.info(f"SessionStore backend: redis")
logger.info("SessionStore backend: redis")
return store
except (ImportError, OSError, _RedisError, ValueError, KeyError, RuntimeError) as exc:
logger.warning(f"Failed to initialise RedisSessionStore ({exc}), falling back to InMemorySessionStore")
logger.warning(
f"Failed to initialise RedisSessionStore ({exc}), falling back to InMemorySessionStore"
)
store = InMemorySessionStore()
logger.info("SessionStore backend: memory")