fix(enterprise): ce-debug batch — PERF-1 PII filter + DEPLOY-1/2/4/5 + SCIM RFC 7643/7644
修复 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:
parent
444667e2f0
commit
cb278bb5f0
|
|
@ -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_URL(Slot 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
|
||||
|
|
|
|||
|
|
@ -194,19 +194,19 @@ secrets:
|
|||
# 9) OTel exporter token — OTLP collector 鉴权 token
|
||||
otelExporterToken:
|
||||
key: otel-exporter-token
|
||||
description: "OTLP exporter 鉴权 token(collector 启用 bearer auth 时)"
|
||||
description: "OTLP exporter 鉴权 token(DEPLOY-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_PASSWORD(DEPLOY-4: 仅 PG StatefulSet 初始化消费,应用通过 DATABASE_URL Slot 3 内嵌密码连接,不读此 env)"
|
||||
rotation: 90d
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 "==> 完成。验证:"
|
||||
|
|
|
|||
|
|
@ -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 名为 agentkit(fullname 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)
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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_PASSWORD(DEPLOY-4: 应用不读此 env,通过 DATABASE_URL Slot 3 内嵌密码连接) | DBA | 90 天 | DBA |
|
||||
|
||||
## 风险等级
|
||||
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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()
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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:
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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"):
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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:
|
||||
"""后台监听频道消息并调用处理器"""
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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 var(Helm 注入但 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:
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 redis-py 不自动读取)。
|
||||
self._redis = aioredis.from_url(
|
||||
self._redis_url,
|
||||
decode_responses=True,
|
||||
password=os.environ.get("REDIS_PASSWORD") or None,
|
||||
)
|
||||
return self._redis
|
||||
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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")
|
||||
|
|
|
|||
|
|
@ -1258,11 +1258,13 @@ def create_app(
|
|||
"redis_url", "redis://localhost:6379"
|
||||
)
|
||||
# U5c: socket_timeout 防止单点 Redis 故障时网关请求挂死。
|
||||
# DEPLOY-2: 显式传入 REDIS_PASSWORD env var(Helm 注入但 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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 流程必须与本地 login(auth.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)
|
||||
|
|
|
|||
|
|
@ -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 var(Helm 注入但 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")
|
||||
|
|
|
|||
|
|
@ -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 var,redis-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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue