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:
parent
de02ac8a92
commit
86bce129a4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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" . }}
|
||||
|
|
|
|||
|
|
@ -197,10 +197,16 @@ secrets:
|
|||
description: "OTLP exporter 鉴权 token(collector 启用 bearer auth 时)"
|
||||
rotation: 90d
|
||||
|
||||
# 10) Redis-PG 密码 — Redis requirepass + PostgreSQL 服务密码(基础设施层)
|
||||
redisPgPasswords:
|
||||
key: redis-pg-passwords
|
||||
description: "Redis requirepass + PostgreSQL POSTGRES_PASSWORD(JSON 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
## 风险等级
|
||||
|
||||
|
|
|
|||
|
|
@ -302,11 +302,7 @@ class LLMGateway:
|
|||
{"gen_ai.request.model": resolved_model},
|
||||
)
|
||||
# U2 — prompt cache hit/miss OTel counter(Anthropic 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 counter(streaming 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, [])
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
- 正则版(默认):识别手机号 / 邮箱 / 身份证 / 银行卡,替换为 [REDACTED_TYPE:hash8]
|
||||
- ner_hook(可选):客户内网 NER 模型(LAC/HanLP),module:func 路径动态 import
|
||||
- fail-closed:任何异常时拒绝发送(raise PIIFilterError),不降级到原文发送
|
||||
- hash 审计:脱敏前后记录 hash 用于审计(仅 hash,不含原文)
|
||||
- hash 审计:脱敏后记录 hash 用于审计;``PIIMatch.original`` 仅内存中供调用方
|
||||
使用(如展示 / 调试),不得落盘 / 落日志
|
||||
|
||||
ponytail: 用 stdlib(re + 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:
|
||||
# 正则版也失败(极少见),返回原文(最差情况)
|
||||
|
|
|
|||
|
|
@ -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 event(OTel 不可用时 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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
# 拉取 userinfo(OIDC 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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue