fix(enterprise): ce-code-review P1 fixes — SSO is_active + span leak + Helm chart integration

Stage 5c applied safe P1/P2/P3 fixes from multi-agent code review:

- SEC-1 (P1, security): SSO 登录绕过 is_active 检查 — _sso_issue_token_pair
  入口加显式校验,与本地 login(auth.py:377)一致。已停用用户无法再通过
  IDP 回调拿 JWT,封堵 R1b deprovisioning 绕过。

- PERF-2/3 (P2, reliability): gateway chat() span 泄漏 + 异常未记录 — try 块
  扩展到覆盖整个 span 生命周期(含 cache 设置),新增 except 分支调用
  record_exception + set_status(ERROR),失败调用在 trace 中不再显示为成功。

- API-3 (P1, test isolation): SCIM router 使用 import-time 捕获的
  DEFAULT_AUTH_DB_PATH — 改为 resolve_auth_db_path() 在调用时读取 env var,
  让测试 monkeypatch.setenv 可见。

- MAINT-1 (P3, dead code): 删除 BitableRepository.delete_view(被
  delete_view_if_not_last 取代,无生产调用方,仅 docs 残留引用)。

- PERF-4/5 (P3, performance): pii_filter._record_pii_metrics 的 from import
  提升至模块顶部 + except: pass 改为 logger.debug 记录失败原因。

- DEPLOY-3 (P1, chart integration): ConfigMap 不可达 — deployment.yaml
  args 增加 --config /etc/agentkit/agentkit.yaml,让应用能加载 chart 渲染
  的非密配置。

- DEPLOY-6/7 (P3, doc/chart consistency): values.yaml 注释残留旧 slot 名
  redisPgPasswords → redisPassword;slot 计数 10 → 11(5 处 chart + 3 处
  deployment docs 同步修正)。

Verification:
- ruff check + format clean (7 modified .py files)
- pytest tests/unit/{auth,bitable} + test_{pii_filter,oidc_provider,
  saml_provider,scim_router,auth_audit,telemetry,prompt_cache_layers}.py
  → 336 passed, 145 skipped
- helm lint + helm template 验证 secret key 一致性

Residual findings (deferred to follow-up):
- PERF-1: PII filter 未接入 gateway 生产路径(manual, 需配置接入决策)
- API-1/2/4/5: SCIM RFC 7644 合规性(totalResults/error format/Location
  header/SSO 预配联动)— manual, 需设计决策
- DEPLOY-1/2/4: Helm chart ↔ app 集成断裂(部署名引用/Redis 鉴权/PG 密码双源)
  — manual, 需 app+chart 联动修改
This commit is contained in:
Chiguyong 2026-07-06 08:36:59 +08:00
parent 86bce129a4
commit 3cf29f4633
12 changed files with 96 additions and 72 deletions

View File

@ -32,6 +32,8 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
args:
- serve
- --config
- /etc/agentkit/agentkit.yaml
- --host
- 0.0.0.0
- --port

View File

@ -1,7 +1,7 @@
{{- /*
External Secrets Operator 模板 — 当 secrets.backend=external-secrets 时渲染。
ExternalSecret 引用运维预创建的 SecretStore指向 Vault / AWS SM / GCP SM / Azure KV
从外部密钥管理服务拉取全部 10 个 secret slotKTD-4
从外部密钥管理服务拉取全部 11 个 secret slotKTD-4
*/ -}}
{{- if eq .Values.secrets.backend "external-secrets" }}
apiVersion: external-secrets.io/v1beta1

View File

@ -1,6 +1,6 @@
{{- /*
Sealed Secrets 模板Bitnami Sealed Secrets controller— 当 secrets.backend=sealed-secrets 时渲染。
覆盖全部 10 个 secret slotKTD-4。encryptedData 由运维通过 kubeseal 生成后以 --set 或
覆盖全部 11 个 secret slotKTD-4。encryptedData 由运维通过 kubeseal 生成后以 --set 或
override values 文件注入。禁止明文 Kubernetes Secret + Git 提交。
*/ -}}
{{- if eq .Values.secrets.backend "sealed-secrets" }}

View File

@ -110,7 +110,7 @@ otel:
# ---------------------------------------------------------------------------
redis:
url: redis://redis:6379/0
# 实际密码通过 env REDIS_PASSWORD 注入(来自 secrets.redisPgPasswords
# 实际密码通过 env REDIS_PASSWORD 注入(来自 secrets.redisPassword
postgres:
# DATABASE_URL不含密码密码通过 ${POSTGRES_PASSWORD} 占位由 env 注入)
@ -124,7 +124,7 @@ auth:
idps: [] # IDP 配置列表(非密部分),密钥引用 secrets.idpSigningCerts
# ---------------------------------------------------------------------------
# 密钥清单 — 10 个 secret slotKTD-4 / R2a
# 密钥清单 — 11 个 secret slotKTD-4 / R2a
# ---------------------------------------------------------------------------
# backend 选择:
# sealed-secrets — 使用 Bitnami Sealed Secrets模板 templates/sealed-secret.yaml
@ -140,7 +140,7 @@ secrets:
# 后端类型vault | aws | gcp | azure
backend: vault
# --- 10 个 secret slot ---
# --- 11 个 secret slot ---
# 每个 slotname=Kubernetes Secret keydescription=用途rotation=轮换周期
# 1) JWT 签名密钥HS256— access(15m) + refresh(7d) token 签名

View File

@ -32,7 +32,7 @@
### 2.1 准备密钥
`docs/deployment/secret-inventory.md` 准备 10 个 secret slot 的明文值,
`docs/deployment/secret-inventory.md` 准备 11 个 secret slot 的明文值,
然后根据所选 backend 生成密钥资源。
**Sealed Secrets 方式**(推荐单集群 / 离线场景):

View File

@ -1,6 +1,6 @@
# Fischer AgentKit — 密钥轮换 Runbook
本文档给出全部 10 个 secret slotKTD-4 / R2a的轮换操作流程。
本文档给出全部 11 个 secret slotKTD-4 / R2a的轮换操作流程。
所有轮换遵循「双密钥并行 → 切换 → 旧密钥退役」原则,避免服务中断。
## 通用轮换原则

View File

@ -1,6 +1,6 @@
# Fischer AgentKit — 密钥清单Secret Inventory
本文档列出 Helm Chart 涉及的全部 10 个 secret slotKTD-4 / R2a
本文档列出 Helm Chart 涉及的全部 11 个 secret slotKTD-4 / R2a
每个 slot 含:名称、用途、来源、轮换周期、归属角色、对应环境变量。
> 密钥后端二选一Sealed Secrets`secrets.backend: sealed-secrets`)或

View File

@ -486,13 +486,6 @@ class BitableRepository:
await session.commit()
return View.model_validate(entity) if entity else None
async def delete_view(self, view_id: str) -> bool:
"""Delete a view. Returns True if a row was deleted."""
async with self._session_factory() as session:
result = await session.execute(delete(ViewModel).where(ViewModel.id == view_id))
await session.commit()
return result.rowcount > 0
async def delete_view_if_not_last(self, view_id: str, table_id: str) -> bool:
"""Atomically delete a view only if it's not the last one in its table (DR-4 TOCTOU fix).

View File

@ -157,54 +157,55 @@ class LLMGateway:
_span = _span_cm.__enter__()
start = time.monotonic()
# ── Cache check (U17 — LiteLLM cache via cache_key in request) ──
# LiteLLM 在 litellm.acompletion 内部处理缓存读写gateway 只需:
# 1. 构建 per-user + ACL-scoped cache_key安全要求 a, b
# 2. 将 cache 参数注入 kwargs 透传到 provider
# 3. 检测响应的 cache_hit 标志,用于 usage trackingcost=0
if self._cache_manager is not None:
from agentkit.llm.cache import LitellmCacheManager
# 解析 KB caching_disabled安全要求 c
# 非 RAG 请求kb_id=None→ 默认启用缓存(无 KB 数据需保护)。
# RAG 请求kb_id!=None→ fail-closed默认禁用缓存仅在 settings
# 明确返回 caching_disabled=False 时启用。防止 DB 异常时 fail-open
# 导致禁用缓存的 KB 数据泄漏到缓存。
kb_caching_disabled = kb_id is not None
if kb_id is not None:
try:
from agentkit.rag_platform.settings import get_settings_store
settings = await get_settings_store().get_settings(kb_id)
if settings is not None:
kb_caching_disabled = settings.caching_disabled
# settings 为 NoneKB 不存在)→ 保持 Truefail-closed
except Exception as e:
logger.warning(f"Failed to read KB cache settings for kb_id={kb_id}: {e}")
# 读取异常 → 保持 Truefail-closed禁用缓存
if self._cache_manager.should_cache(kb_caching_disabled, user_id):
cache_key = self._cache_manager.build_cache_key(
model=resolved_model,
messages=messages,
temperature=kwargs.get("temperature", 0.7),
tools=tools,
tool_choice=tool_choice,
max_tokens=kwargs.get("max_tokens", 2000),
user_id=user_id,
kb_acl_hash=kb_acl_hash,
)
kwargs["cache"] = LitellmCacheManager.cache_params_for_hit(cache_key)
else:
kwargs["cache"] = LitellmCacheManager.cache_params_for_no_cache()
# ── Normal provider call ──
models_to_try = self._get_models_to_try(resolved_model)
last_error: LLMProviderError | None = None
response: LLMResponse | None = None
# PERF-2: try 块必须覆盖整个 span 生命周期(含 cache 设置),
# 否则 cache_key 构建异常会泄漏 span。PERF-3: except 记录异常到 span。
try:
# ── Cache check (U17 — LiteLLM cache via cache_key in request) ──
# LiteLLM 在 litellm.acompletion 内部处理缓存读写gateway 只需:
# 1. 构建 per-user + ACL-scoped cache_key安全要求 a, b
# 2. 将 cache 参数注入 kwargs 透传到 provider
# 3. 检测响应的 cache_hit 标志,用于 usage trackingcost=0
if self._cache_manager is not None:
from agentkit.llm.cache import LitellmCacheManager
# 解析 KB caching_disabled安全要求 c
# 非 RAG 请求kb_id=None→ 默认启用缓存(无 KB 数据需保护)。
# RAG 请求kb_id!=None→ fail-closed默认禁用缓存仅在 settings
# 明确返回 caching_disabled=False 时启用。防止 DB 异常时 fail-open
# 导致禁用缓存的 KB 数据泄漏到缓存。
kb_caching_disabled = kb_id is not None
if kb_id is not None:
try:
from agentkit.rag_platform.settings import get_settings_store
settings = await get_settings_store().get_settings(kb_id)
if settings is not None:
kb_caching_disabled = settings.caching_disabled
# settings 为 NoneKB 不存在)→ 保持 Truefail-closed
except Exception as e:
logger.warning(f"Failed to read KB cache settings for kb_id={kb_id}: {e}")
# 读取异常 → 保持 Truefail-closed禁用缓存
if self._cache_manager.should_cache(kb_caching_disabled, user_id):
cache_key = self._cache_manager.build_cache_key(
model=resolved_model,
messages=messages,
temperature=kwargs.get("temperature", 0.7),
tools=tools,
tool_choice=tool_choice,
max_tokens=kwargs.get("max_tokens", 2000),
user_id=user_id,
kb_acl_hash=kb_acl_hash,
)
kwargs["cache"] = LitellmCacheManager.cache_params_for_hit(cache_key)
else:
kwargs["cache"] = LitellmCacheManager.cache_params_for_no_cache()
# ── Normal provider call ──
models_to_try = self._get_models_to_try(resolved_model)
last_error: LLMProviderError | None = None
response: LLMResponse | None = None
for model_name in models_to_try:
try:
provider, actual_model = self._resolve_model(model_name)
@ -305,6 +306,18 @@ class LLMGateway:
self._record_prompt_cache_metric(response.usage, resolved_model)
return response
except Exception as e:
# PERF-3: 记录异常到 span — __exit__(None, None, None) 会丢失异常信息,
# 失败的 LLM 调用在 trace 中显示为成功。手动 record_exception + 设 ERROR 状态。
if _span is not None:
try:
_span.record_exception(e)
from opentelemetry.trace import Status, StatusCode
_span.set_status(Status(StatusCode.ERROR, str(e)))
except Exception: # noqa: BLE001 — span 记录失败不影响异常向上传播
pass
raise
finally:
if _span_cm is not None:
_span_cm.__exit__(None, None, None)

View File

@ -20,6 +20,11 @@ import logging
import re
from dataclasses import dataclass, field
from agentkit.telemetry.metrics import (
pii_filter_coverage_counter,
pii_filter_redacted_counter,
)
logger = logging.getLogger(__name__)
@ -120,18 +125,18 @@ def _redact(text: str, ner_hook=None) -> PIIFilterResult:
def _record_pii_metrics(status: str, redacted_count: int | None = None) -> None:
"""记录 PII 脱敏 OTel 指标 — 失败静默降级(不影响主流程)。"""
try:
from agentkit.telemetry.metrics import (
pii_filter_coverage_counter,
pii_filter_redacted_counter,
)
"""记录 PII 脱敏 OTel 指标 — 失败静默降级(不影响主流程)。
PERF-4: logger.debug 记录失败原因而非 except: pass
保留静默降级语义同时让排障可见
PERF-5: import 提升至模块顶部避免热路径每次 from import 开销
"""
try:
pii_filter_coverage_counter().add(1, {"pii_filter.status": status})
if redacted_count is not None:
pii_filter_redacted_counter().add(redacted_count)
except Exception:
pass
except Exception as e: # noqa: BLE001 — 指标失败不影响 PII 过滤主流程
logger.debug("PII metric recording failed: %s", e)
def filter_pii(

View File

@ -23,7 +23,12 @@ import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, ConfigDict
from ...auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db, user_row_to_dict, _now_iso
from ...auth.models import (
init_auth_db,
resolve_auth_db_path as _resolve_default_db_path,
user_row_to_dict,
_now_iso,
)
from ...auth.password import hash_password
from .models import SCIMListResponse, SCIMPatchRequest, SCIMUser
@ -36,7 +41,9 @@ _SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
def _resolve_db_path(request: Request) -> Path:
path = getattr(request.app.state, "auth_db_path", None)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
# API-3: 用 resolve_auth_db_path() 在调用时读取 env var
# 让测试 monkeypatch.setenv("AGENTKIT_AUTH_DB", tmp) 可见。
return Path(path) if path else _resolve_default_db_path()
def _resolve_scim_token(request: Request) -> str | None:

View File

@ -796,6 +796,10 @@ class SSORedirectResponse(BaseModel):
async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object]) -> TokenResponse:
"""SSO 登录成功后签发 JWT pair + 创建 session复用 login 流程)。"""
# SEC-1: SSO 流程必须与本地 loginauth.py:377一致地校验 is_active
# 否则已 deprovision 的用户可通过 IDP 回调重新拿到 JWT + session。
if not bool(user_dict.get("is_active", True)):
raise HTTPException(status_code=403, detail="Account is disabled")
db_path = await _ensure_db(request)
secret = _resolve_jwt_secret(request)
svc: SessionService = get_session_service()