refactor(enterprise): simplify U1-U6 — fix span leak, dedup helpers, stdlib parsing

lfg Step 3 (ce-simplify-code) — 16 files, -89 net lines.

P1 fixes (functional):
- audit.py / scim/router.py: fix OTel span leak — start_span() returned a
  context manager but was used as a plain object, so spans never ended
  and never exported. Wrap in `with`.
- helm chart: split combined `redisPgPasswords` slot (single JSON key)
  into `redisPassword` + `postgresPassword` slots — the combined `key`
  field was dead config never referenced by any template; deployment.yaml
  hardcoded `redis-password`/`postgres-password` keys were inconsistent
  with values.yaml schema.

P2 dedup (reuse):
- Promote `resolve_auth_db_path()` to models.py (canonical); remove 3
  duplicate `_resolve_db_path` defs in oidc.py / saml.py / audit.py.
- Reuse `_now_iso` from models.py; remove 5 duplicates across audit /
  oidc / saml / scim / local providers.
- Reuse `_row_to_user` from local.py in oidc.py / saml.py (verbatim
  copies removed).
- pii_filter._redact: collapse redundant `finditer` + `sub` (2 regex
  passes + 2x sha256 per match on hot path) into a single `sub` with a
  callback that collects matches and computes hashes once.

P3 simplifications:
- RoleChangeRequest.role: `str` + manual `if not in (...)` → `Literal`
  (Pydantic validates automatically).
- Extract `_record_pii_metrics(status, redacted_count)` helper to dedup
  3 nearly-identical OTel metric emission blocks.
- Extract `LLMGateway._record_prompt_cache_metric(usage, model)` to dedup
  non-stream + stream cache hit/miss counters; switch to use the
  `TokenUsage.cache_hit` property (previously added but unused).
- middleware: split `WHITELIST_PATHS` + `PREFIX_MATCH_PATHS` (redundant
  cross-reference) into `WHITELIST_PATHS` (exact) + `PREFIX_WHITELIST_PATHS`
  (prefix) — single source of truth per kind.
- OIDC token exchange: replace f-string form body with `urlencode` to
  correctly escape `&` / `=` in code / redirect_uri.
- auth.py OIDC redirect route: replace hand-rolled `url.split("state=")`
  with `urlparse + parse_qs`.
- PII module docstring: clarify that `PIIMatch.original` is in-memory
  only (must not be logged / persisted) — the previous "仅 hash, 不含
  原文" wording was misleading.

Skipped findings (false-positive or not worth):
- OIDC/SAML `_find_or_create_user` JIT 60-line dedup — high blast
  radius across SQL + transaction boundaries; defers to dedicated refactor.
- SAML `_extract_host` hand-rolled split — already has ponytail comment;
  stdlib `urlparse` swap is behavior-equivalent for current inputs but
  changes OneLogin `http_host` field semantics; conservative keep.
- gateway.py department-quota / usage-record sequential await —
  concurrency gain unclear when dept count is typically ≤2.

Verification:
- ruff check (scoped to 12 touched files) clean
- ruff format (1 file reformatted)
- pytest (PII + OIDC + SAML + SCIM + audit + bitable + team_orchestrator):
  265 passed, 145 skipped (PG/Docker), 0 failures
- helm lint clean; helm template renders correctly for both
  sealed-secrets + external-secrets backends
This commit is contained in:
Chiguyong 2026-07-06 08:02:40 +08:00
parent de02ac8a92
commit 86bce129a4
16 changed files with 145 additions and 234 deletions

View File

@ -89,12 +89,12 @@ spec:
valueFrom:
secretKeyRef:
name: {{ include "agentkit.secretName" . }}
key: redis-password
key: {{ .Values.secrets.redisPassword.key }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "agentkit.secretName" . }}
key: postgres-password
key: {{ .Values.secrets.postgresPassword.key }}
# --- 非密配置OTel endpoint、service name---
{{- if .Values.otel.enabled }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT

View File

@ -55,11 +55,12 @@ spec:
- secretKey: {{ .Values.secrets.otelExporterToken.key }}
remoteRef:
key: agentkit/otel-exporter-token
# 10) Redis-PG 密码(拆为两个 key
- secretKey: redis-password
# 10) Redis 密码
- secretKey: {{ .Values.secrets.redisPassword.key }}
remoteRef:
key: agentkit/redis-password
- secretKey: postgres-password
# 11) PostgreSQL 密码
- secretKey: {{ .Values.secrets.postgresPassword.key }}
remoteRef:
key: agentkit/postgres-password
{{- end }}

View File

@ -31,9 +31,10 @@ spec:
{{ .Values.secrets.kmsHsmPin.key }}: {{ .Values.secrets.kmsHsmPin.encryptedData | default "" | quote }}
# 9) OTel exporter token
{{ .Values.secrets.otelExporterToken.key }}: {{ .Values.secrets.otelExporterToken.encryptedData | default "" | quote }}
# 10) Redis-PG 密码(拆为 redis-password + postgres-password 两个 key
redis-password: {{ .Values.secrets.redisPgPasswords.encryptedRedis | default "" | quote }}
postgres-password: {{ .Values.secrets.redisPgPasswords.encryptedPostgres | default "" | quote }}
# 10) Redis 密码
{{ .Values.secrets.redisPassword.key }}: {{ .Values.secrets.redisPassword.encryptedData | default "" | quote }}
# 11) PostgreSQL 密码
{{ .Values.secrets.postgresPassword.key }}: {{ .Values.secrets.postgresPassword.encryptedData | default "" | quote }}
template:
metadata:
name: {{ include "agentkit.secretName" . }}

View File

@ -197,10 +197,16 @@ secrets:
description: "OTLP exporter 鉴权 tokencollector 启用 bearer auth 时)"
rotation: 90d
# 10) Redis-PG 密码 — Redis requirepass + PostgreSQL 服务密码(基础设施层)
redisPgPasswords:
key: redis-pg-passwords
description: "Redis requirepass + PostgreSQL POSTGRES_PASSWORDJSON map: {redis, postgres}"
# 10) Redis 密码 — Redis requirepass基础设施层
redisPassword:
key: redis-password
description: "Redis requirepass基础设施层"
rotation: 90d
# 11) PostgreSQL 密码 — POSTGRES_PASSWORD基础设施层
postgresPassword:
key: postgres-password
description: "PostgreSQL POSTGRES_PASSWORD基础设施层"
rotation: 90d
# ---------------------------------------------------------------------------

View File

@ -20,7 +20,8 @@
| 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-PG 密码 | `redis-password` + `postgres-password` | `REDIS_PASSWORD` / `POSTGRES_PASSWORD` | Redis requirepass + PostgreSQL 服务密码(基础设施层) | DBA / 缓存运维 | 90 天 | DBA |
| 10 | Redis 密码 | `redis-password` | `REDIS_PASSWORD` | Redis requirepass基础设施层 | DBA / 缓存运维 | 90 天 | DBA |
| 11 | PostgreSQL 密码 | `postgres-password` | `POSTGRES_PASSWORD` | PostgreSQL POSTGRES_PASSWORD基础设施层 | DBA | 90 天 | DBA |
## 风险等级

View File

@ -302,11 +302,7 @@ class LLMGateway:
{"gen_ai.request.model": resolved_model},
)
# U2 — prompt cache hit/miss OTel counterAnthropic cache_read_input_tokens > 0 → hit
cache_attrs = {"gen_ai.request.model": resolved_model}
if response.usage.cache_read_input_tokens > 0:
prompt_cache_hit_counter().add(1, cache_attrs)
else:
prompt_cache_miss_counter().add(1, cache_attrs)
self._record_prompt_cache_metric(response.usage, resolved_model)
return response
finally:
@ -415,11 +411,7 @@ class LLMGateway:
department_ids=department_ids,
)
# U2 — prompt cache hit/miss OTel counterstreaming path
stream_cache_attrs = {"gen_ai.request.model": final_model}
if final_usage.cache_read_input_tokens > 0:
prompt_cache_hit_counter().add(1, stream_cache_attrs)
else:
prompt_cache_miss_counter().add(1, stream_cache_attrs)
self._record_prompt_cache_metric(final_usage, final_model)
# Empty stream detection: if no content was produced,
# raise error so the caller (ReActEngine) can retry with a different model.
@ -455,6 +447,15 @@ class LLMGateway:
"", f"No provider available for streaming '{resolved_model}'"
)
@staticmethod
def _record_prompt_cache_metric(usage: TokenUsage, model: str) -> None:
"""Record prompt cache hit/miss OTel counter — Anthropic cache_read_input_tokens > 0 → hit."""
attrs = {"gen_ai.request.model": model}
if usage.cache_hit:
prompt_cache_hit_counter().add(1, attrs)
else:
prompt_cache_miss_counter().add(1, attrs)
def _get_models_to_try(self, resolved_model: str) -> list[str]:
"""Return [primary_model] + fallback_models for the given resolved model."""
fallback_models = self._config.fallbacks.get(resolved_model, [])

View File

@ -6,7 +6,8 @@
- 正则版默认识别手机号 / 邮箱 / 身份证 / 银行卡替换为 [REDACTED_TYPE:hash8]
- ner_hook可选客户内网 NER 模型LAC/HanLPmodule:func 路径动态 import
- fail-closed任何异常时拒绝发送raise PIIFilterError不降级到原文发送
- hash 审计脱敏前后记录 hash 用于审计 hash不含原文
- hash 审计脱敏后记录 hash 用于审计``PIIMatch.original`` 仅内存中供调用方
使用如展示 / 调试不得落盘 / 落日志
ponytail: stdlibre + hashlib不引入 NER 依赖
升级路径ner_hook 接入客户内网 NER 模型正则版作为 fallback
@ -97,26 +98,42 @@ def _redact(text: str, ner_hook=None) -> PIIFilterResult:
("bank_card", _BANK_CARD_RE),
]
for pii_type, pattern in patterns:
for m in pattern.finditer(redacted):
# 单次 sub + 回调同时收集 matches 和替换文本,
# 避免二次正则扫描 + 双重 sha256 计算(热路径)
def _replace(m, _t=pii_type):
original = m.group()
pii_hash = _hash_pii(original)
replacement = f"[REDACTED_{pii_type.upper()}:{pii_hash}]"
replacement = f"[REDACTED_{_t.upper()}:{pii_hash}]"
matches.append(
PIIMatch(
pii_type=pii_type,
pii_type=_t,
original=original,
redacted=replacement,
hash=pii_hash,
)
)
redacted = pattern.sub(
lambda m: f"[REDACTED_{pii_type.upper()}:{_hash_pii(m.group())}]",
redacted,
)
return replacement
redacted = pattern.sub(_replace, redacted)
return PIIFilterResult(redacted_text=redacted, matches=matches)
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_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
def filter_pii(
text: str,
*,
@ -138,44 +155,19 @@ def filter_pii(
"""
try:
result = _redact(text, ner_hook=ner_hook)
# U2 — OTel 指标:脱敏覆盖率 + 脱敏实体数
try:
from agentkit.telemetry.metrics import (
pii_filter_coverage_counter,
pii_filter_redacted_counter,
)
pii_filter_coverage_counter().add(1, {"pii_filter.status": "success"})
pii_filter_redacted_counter().add(result.redacted_count)
except Exception:
# OTel 指标失败不影响脱敏主流程
pass
_record_pii_metrics("success", result.redacted_count)
return result
except Exception as e:
logger.error(f"PII filter failed: {e}")
if fail_closed:
# fail-closed拒绝发送抛异常
try:
from agentkit.telemetry.metrics import pii_filter_coverage_counter
pii_filter_coverage_counter().add(1, {"pii_filter.status": "failed_closed"})
except Exception:
pass
_record_pii_metrics("failed_closed")
raise PIIFilterError(f"PII filter failed (fail-closed): {e}") from e
# fail-open降级到正则版无 ner_hook重试
logger.warning("fail_closed=False, falling back to regex-only redaction")
try:
result = _redact(text, ner_hook=None)
try:
from agentkit.telemetry.metrics import (
pii_filter_coverage_counter,
pii_filter_redacted_counter,
)
pii_filter_coverage_counter().add(1, {"pii_filter.status": "fallback_regex"})
pii_filter_redacted_counter().add(result.redacted_count)
except Exception:
pass
_record_pii_metrics("fallback_regex", result.redacted_count)
return result
except Exception as fallback_err:
# 正则版也失败(极少见),返回原文(最差情况)

View File

@ -13,15 +13,14 @@
from __future__ import annotations
import logging
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from agentkit.telemetry.tracer import get_tracer
from .models import DEFAULT_AUTH_DB_PATH, audit_log_row_to_dict
from .models import DEFAULT_AUTH_DB_PATH, audit_log_row_to_dict, _now_iso
logger = logging.getLogger(__name__)
@ -36,17 +35,6 @@ SOURCE_CRON = "cron_reconciliation"
SOURCE_BREAKGLASS = "break_glass"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _resolve_db_path() -> Path:
import os
env = os.environ.get("AGENTKIT_AUTH_DB")
return Path(env) if env else DEFAULT_AUTH_DB_PATH
class AuditService:
"""审计日志服务 — 写 ``auth_audit_log`` 表 + OTel span event。
@ -55,8 +43,6 @@ class AuditService:
"""
def __init__(self, db_path: str | Path | None = None) -> None:
import os
if db_path is not None:
self._db_path = Path(db_path)
else:
@ -165,18 +151,18 @@ class AuditService:
# OTel 审计通道 — 发 span eventOTel 不可用时 NoOpTracer 静默降级)
try:
tracer = get_tracer()
span = tracer.start_span(f"audit.{event_type}")
span.set_attribute("audit.event_type", event_type)
span.set_attribute("audit.actor", actor_username or actor_user_id or "system")
span.set_attribute("audit.target", target_username or target_user_id)
span.set_attribute("audit.source", source)
if role_before or role_after:
span.set_attribute("audit.role_before", role_before or "")
span.set_attribute("audit.role_after", role_after or "")
span.add_event(
"audit.record",
{"actor": actor_username or "", "target": target_username or ""},
)
with tracer.start_span(f"audit.{event_type}") as span:
span.set_attribute("audit.event_type", event_type)
span.set_attribute("audit.actor", actor_username or actor_user_id or "system")
span.set_attribute("audit.target", target_username or target_user_id)
span.set_attribute("audit.source", source)
if role_before or role_after:
span.set_attribute("audit.role_before", role_before or "")
span.set_attribute("audit.role_after", role_after or "")
span.add_event(
"audit.record",
{"actor": actor_username or "", "target": target_username or ""},
)
except Exception: # noqa: BLE001 — OTel 失败不影响审计写入
pass
logger.info(
@ -221,7 +207,3 @@ def set_audit_service(service: AuditService | None) -> None:
"""注入自定义 AuditService测试用"""
global _service
_service = service
# 全局别名便于调用 — 保留 Any 仅用于 typing 占位(不违反 no-any 规则)
_ = Any

View File

@ -41,33 +41,30 @@ class AuthMiddleware(BaseHTTPMiddleware):
client_keys: Mapping of ``client_name -> api_key`` (from clients.yaml).
"""
# 精确匹配路径 — 静态端点(无动态路径段)
WHITELIST_PATHS = (
"/",
"/login",
"/assets", # Static assets (exact match covers root; prefix below covers sub-paths)
"/api/v1/health",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout",
"/api/v1/auth/whoami", # Route does its own auth (access OR refresh)
# U4 SSO — 动态 provider 路径,前缀匹配(下方 PREFIX_MATCH_PATHS
"/api/v1/auth/oauth/", # OIDC redirect/callback
"/api/v1/auth/saml/", # SAML ACS
"/api/v1/scim/v2/", # SCIM 2.0(自带 bearer token 校验)
"/docs",
"/openapi.json",
"/redoc",
)
# 前缀匹配路径 — 动态路径段provider 名 / 资源 id),仅这些前缀走 startswith
PREFIX_MATCH_PATHS = (
# 前缀匹配路径 — 动态路径段provider 名 / 资源 id / 静态资源子路径)
PREFIX_WHITELIST_PATHS = (
"/docs",
"/openapi.json",
"/redoc",
"/assets",
"/api/v1/auth/oauth/",
"/api/v1/auth/saml/",
"/api/v1/scim/v2/",
# U4 SSO — 动态 provider 路径
"/api/v1/auth/oauth/", # OIDC redirect/callback
"/api/v1/auth/saml/", # SAML ACS
"/api/v1/scim/v2/", # SCIM 2.0(自带 bearer token 校验)
)
def __init__(
@ -90,17 +87,13 @@ class AuthMiddleware(BaseHTTPMiddleware):
def _is_whitelisted(self, path: str) -> bool:
"""Return True if ``path`` matches a whitelisted route.
Uses exact match for auth routes (so ``/auth/logout`` does NOT
whitelist ``/auth/logout-others``) and prefix match for docs and assets.
Uses exact match for static auth routes (so ``/auth/logout`` does NOT
whitelist ``/auth/logout-others``) and prefix match for docs, assets,
and U4 SSO dynamic-provider routes.
"""
for prefix in self.WHITELIST_PATHS:
if path == prefix:
return True
# Prefix match for documentation paths, static assets, and U4
# SSO dynamic-provider routes (/auth/oauth/{provider}/...).
if path.startswith(prefix) and prefix in self.PREFIX_MATCH_PATHS:
return True
return False
if path in self.WHITELIST_PATHS:
return True
return any(path.startswith(p) for p in self.PREFIX_WHITELIST_PATHS)
def _is_dev_mode(self) -> bool:
"""Dev mode = no JWT secret, no global API key, no client keys."""

View File

@ -45,6 +45,18 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def resolve_auth_db_path() -> Path:
"""Lazily resolve the auth DB path from env var at call time.
``DEFAULT_AUTH_DB_PATH`` is captured at module-import time and cannot
see test-time ``AGENTKIT_AUTH_DB`` mutations. This helper re-reads the
env var on every call so tests can ``monkeypatch.setenv`` before
constructing a provider/service.
"""
env = os.environ.get("AGENTKIT_AUTH_DB")
return Path(env) if env else DEFAULT_AUTH_DB_PATH
# ---------------------------------------------------------------------------
# SQLAlchemy 2 declarative models
# ---------------------------------------------------------------------------

View File

@ -33,13 +33,13 @@ from __future__ import annotations
import logging
import os
from functools import lru_cache
from pathlib import Path
from .base import AuthProvider
from .exceptions import AuthProviderError, InvalidCredentials, ProviderNotImplemented
from .local import LocalAuthProvider
from .oidc_stub import StubOIDCProvider
from .user import User
from ..models import resolve_auth_db_path as _resolve_db_path
logger = logging.getLogger(__name__)
@ -71,16 +71,6 @@ def _resolve_provider_name() -> str:
return name
def _resolve_db_path() -> Path:
"""Return the auth DB path (overridable for tests via env var)."""
from ..models import DEFAULT_AUTH_DB_PATH
env = os.environ.get("AGENTKIT_AUTH_DB")
if env:
return Path(env)
return DEFAULT_AUTH_DB_PATH
@lru_cache(maxsize=1)
def get_auth_provider() -> AuthProvider:
"""Return the configured :class:`AuthProvider` (memoized singleton).

View File

@ -21,34 +21,22 @@ implementation.
from __future__ import annotations
import logging
import os
import uuid
from pathlib import Path
import aiosqlite
from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..models import (
resolve_auth_db_path as _resolve_db_path,
_now_iso,
user_row_to_dict,
)
from ..password import hash_password, verify_password
from .user import User
logger = logging.getLogger(__name__)
def _resolve_db_path() -> Path:
"""Resolve the auth DB path with runtime env-var priority.
The :data:`models.DEFAULT_AUTH_DB_PATH` constant is captured at
module-import time and therefore cannot see test-time env mutations.
Re-reading ``AGENTKIT_AUTH_DB`` here keeps the provider
"test-friendly" (tests can ``monkeypatch.setenv`` before constructing
the provider) without giving up the default path when no env is set.
"""
env = os.environ.get("AGENTKIT_AUTH_DB")
if env:
return Path(env)
return DEFAULT_AUTH_DB_PATH
class LocalAuthProvider:
"""AuthProvider backed by the local SQLite ``users`` table + bcrypt.
@ -242,10 +230,3 @@ def _row_to_user(row: aiosqlite.Row) -> User:
last_login_at=row["last_login_at"],
created_by=row["created_by"],
)
def _now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()

View File

@ -21,17 +21,17 @@ import logging
import secrets
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
import aiosqlite
from authlib.integrations.httpx_client import AsyncOAuth2Client
from ..idp_registry import get_idp_registry
from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..models import user_row_to_dict, _now_iso, resolve_auth_db_path as _resolve_db_path
from ..password import hash_password
from .exceptions import ProviderNotImplemented
from .local import _row_to_user
from .user import User
logger = logging.getLogger(__name__)
@ -75,17 +75,6 @@ def get_state_cache() -> _StateCache:
return _state_cache
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _resolve_db_path() -> Path:
import os
env = os.environ.get("AGENTKIT_AUTH_DB")
return Path(env) if env else DEFAULT_AUTH_DB_PATH
class OIDCProvider:
"""OIDC 认证适配器 — authlib redirect flow + JIT 用户创建。
@ -156,7 +145,13 @@ class OIDCProvider:
token = await client.fetch_token(
cfg.server_metadata_url, # token endpoint URL 或 discovery URL
authorization_response=code,
body=f"grant_type=authorization_code&code={code}&redirect_uri={cfg.redirect_uri}",
body=urlencode(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": cfg.redirect_uri,
}
),
)
# 拉取 userinfoOIDC discovery 的标准端点)
userinfo = await self._fetch_userinfo(client, token, cfg.server_metadata_url)
@ -309,19 +304,3 @@ class OIDCProvider:
)
await db.commit()
logger.info(f"OIDC provider 禁用用户 {user_id}")
def _row_to_user(row: aiosqlite.Row) -> User:
return User(
id=row["id"],
username=row["username"],
email=row["email"],
role=row["role"],
is_active=bool(row["is_active"]),
is_terminal_authorized=bool(row["is_terminal_authorized"]),
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
last_login_at=row["last_login_at"],
created_by=row["created_by"],
)

View File

@ -17,32 +17,20 @@ import asyncio
import logging
import secrets
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from ..idp_registry import get_idp_registry
from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..models import user_row_to_dict, _now_iso, resolve_auth_db_path as _resolve_db_path
from ..password import hash_password
from .exceptions import ProviderNotImplemented
from .local import _row_to_user
from .user import User
logger = logging.getLogger(__name__)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _resolve_db_path() -> Path:
import os
env = os.environ.get("AGENTKIT_AUTH_DB")
return Path(env) if env else DEFAULT_AUTH_DB_PATH
class SAMLProvider:
"""SAML 2.0 认证适配器 — OneLogin python3-saml ACS 消费。
@ -258,19 +246,3 @@ def _extract_host(url: str) -> str:
if "://" in url:
url = url.split("://", 1)[1]
return url.split("/", 1)[0] if url else ""
def _row_to_user(row: aiosqlite.Row) -> User:
return User(
id=row["id"],
username=row["username"],
email=row["email"],
role=row["role"],
is_active=bool(row["is_active"]),
is_terminal_authorized=bool(row["is_terminal_authorized"]),
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
last_login_at=row["last_login_at"],
created_by=row["created_by"],
)

View File

@ -16,7 +16,6 @@ import logging
import os
import secrets
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@ -24,7 +23,7 @@ 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
from ...auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db, user_row_to_dict, _now_iso
from ...auth.password import hash_password
from .models import SCIMListResponse, SCIMPatchRequest, SCIMUser
@ -35,10 +34,6 @@ router = APIRouter(prefix="/scim/v2", tags=["scim"])
_SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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
@ -80,12 +75,12 @@ def _alert_scim_failure(operation: str, detail: str, user_id: str | None = None)
from agentkit.telemetry.tracer import get_tracer
tracer = get_tracer()
span = tracer.start_span("scim.push.failure")
span.set_attribute("scim.operation", operation)
span.set_attribute("scim.error", detail)
if user_id:
span.set_attribute("scim.user_id", user_id)
span.add_event("scim.alert", {"operation": operation, "detail": detail})
with tracer.start_span("scim.push.failure") as span:
span.set_attribute("scim.operation", operation)
span.set_attribute("scim.error", detail)
if user_id:
span.set_attribute("scim.user_id", user_id)
span.add_event("scim.alert", {"operation": operation, "detail": detail})
except Exception: # noqa: BLE001 — OTel 不可用不阻断主流程
pass

View File

@ -25,7 +25,7 @@ import hmac
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from typing import Any, Literal
import aiosqlite
import jwt
@ -794,9 +794,7 @@ class SSORedirectResponse(BaseModel):
state: str
async def _sso_issue_token_pair(
request: Request, user_dict: dict[str, object]
) -> TokenResponse:
async def _sso_issue_token_pair(request: Request, user_dict: dict[str, object]) -> TokenResponse:
"""SSO 登录成功后签发 JWT pair + 创建 session复用 login 流程)。"""
db_path = await _ensure_db(request)
secret = _resolve_jwt_secret(request)
@ -843,7 +841,9 @@ async def _sso_issue_token_pair(
role=str(user_dict["role"]),
is_active=bool(user_dict.get("is_active", True)),
is_terminal_authorized=bool(user_dict.get("is_terminal_authorized", False)),
is_server_terminal_authorized=bool(user_dict.get("is_server_terminal_authorized", False)),
is_server_terminal_authorized=bool(
user_dict.get("is_server_terminal_authorized", False)
),
),
session_id=pre_session.id,
)
@ -859,9 +859,11 @@ async def oidc_redirect(provider: str, request: Request) -> SSORedirectResponse:
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# state 已由 provider 写入 _state_cache从 URL 中提取回传前端
state = ""
if "state=" in url:
state = url.split("state=", 1)[1].split("&", 1)[0]
from urllib.parse import parse_qs, urlparse
parsed = urlparse(url)
state_values = parse_qs(parsed.query).get("state", [])
state = state_values[0] if state_values else ""
return SSORedirectResponse(redirect_url=url, state=state)
@ -1088,7 +1090,7 @@ class RoleChangeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role: str # member / operator / admin
role: Literal["member", "operator", "admin"]
reason: str | None = None
@ -1104,9 +1106,9 @@ async def change_user_role(
记录 actor/target/role_before/role_after/reason/source/timestamp接入 OTel 审计
变更后撤销该用户所有现有 session强制重新登录以刷新 JWT role claim
"""
if payload.role not in ("member", "operator", "admin"):
raise HTTPException(status_code=400, detail="role 必须是 member/operator/admin 之一")
db_path = await _ensure_db(request)
from agentkit.server.auth.audit import SOURCE_MANUAL, get_audit_service
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,))
@ -1120,8 +1122,6 @@ async def change_user_role(
)
await db.commit()
# 审计记录
from agentkit.server.auth.audit import get_audit_service
await get_audit_service().record_role_change(
actor_user_id=str(admin.get("user_id") or ""),
actor_username=str(admin.get("username") or ""),
@ -1130,12 +1130,17 @@ async def change_user_role(
role_before=role_before,
role_after=payload.role,
reason=payload.reason,
source="manual",
source=SOURCE_MANUAL,
)
# 撤销所有 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 {
"role_changed": True,
"role_before": role_before,
"role_after": payload.role,
"revoked_sessions": revoked,
}
# Backwards-compat /me endpoint (kept for the existing frontend)