diff --git a/deploy/helm/agentkit/templates/deployment.yaml b/deploy/helm/agentkit/templates/deployment.yaml index b917ab8..4446d9f 100644 --- a/deploy/helm/agentkit/templates/deployment.yaml +++ b/deploy/helm/agentkit/templates/deployment.yaml @@ -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 diff --git a/deploy/helm/agentkit/templates/external-secret.yaml b/deploy/helm/agentkit/templates/external-secret.yaml index bc24ef6..ed8a879 100644 --- a/deploy/helm/agentkit/templates/external-secret.yaml +++ b/deploy/helm/agentkit/templates/external-secret.yaml @@ -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 }} diff --git a/deploy/helm/agentkit/templates/sealed-secret.yaml b/deploy/helm/agentkit/templates/sealed-secret.yaml index 92489ec..b22d926 100644 --- a/deploy/helm/agentkit/templates/sealed-secret.yaml +++ b/deploy/helm/agentkit/templates/sealed-secret.yaml @@ -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" . }} diff --git a/deploy/helm/agentkit/values.yaml b/deploy/helm/agentkit/values.yaml index 9f65727..e01ee5f 100644 --- a/deploy/helm/agentkit/values.yaml +++ b/deploy/helm/agentkit/values.yaml @@ -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 # --------------------------------------------------------------------------- diff --git a/docs/deployment/secret-inventory.md b/docs/deployment/secret-inventory.md index a766ce6..96b8724 100644 --- a/docs/deployment/secret-inventory.md +++ b/docs/deployment/secret-inventory.md @@ -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 | ## 风险等级 diff --git a/src/agentkit/llm/gateway.py b/src/agentkit/llm/gateway.py index 7496f43..d74d753 100644 --- a/src/agentkit/llm/gateway.py +++ b/src/agentkit/llm/gateway.py @@ -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, []) diff --git a/src/agentkit/llm/pii_filter.py b/src/agentkit/llm/pii_filter.py index 70f1aea..633f108 100644 --- a/src/agentkit/llm/pii_filter.py +++ b/src/agentkit/llm/pii_filter.py @@ -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: # 正则版也失败(极少见),返回原文(最差情况) diff --git a/src/agentkit/server/auth/audit.py b/src/agentkit/server/auth/audit.py index 797a3ae..6d2d4dc 100644 --- a/src/agentkit/server/auth/audit.py +++ b/src/agentkit/server/auth/audit.py @@ -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 diff --git a/src/agentkit/server/auth/middleware.py b/src/agentkit/server/auth/middleware.py index 09e3f64..82c0bbf 100644 --- a/src/agentkit/server/auth/middleware.py +++ b/src/agentkit/server/auth/middleware.py @@ -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.""" diff --git a/src/agentkit/server/auth/models.py b/src/agentkit/server/auth/models.py index aa76261..66504f5 100644 --- a/src/agentkit/server/auth/models.py +++ b/src/agentkit/server/auth/models.py @@ -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 # --------------------------------------------------------------------------- diff --git a/src/agentkit/server/auth/providers/__init__.py b/src/agentkit/server/auth/providers/__init__.py index 5c9a90b..e956203 100644 --- a/src/agentkit/server/auth/providers/__init__.py +++ b/src/agentkit/server/auth/providers/__init__.py @@ -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). diff --git a/src/agentkit/server/auth/providers/local.py b/src/agentkit/server/auth/providers/local.py index 6bf0de8..ffab51e 100644 --- a/src/agentkit/server/auth/providers/local.py +++ b/src/agentkit/server/auth/providers/local.py @@ -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() diff --git a/src/agentkit/server/auth/providers/oidc.py b/src/agentkit/server/auth/providers/oidc.py index 5b52948..fdb8a30 100644 --- a/src/agentkit/server/auth/providers/oidc.py +++ b/src/agentkit/server/auth/providers/oidc.py @@ -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"], - ) diff --git a/src/agentkit/server/auth/providers/saml.py b/src/agentkit/server/auth/providers/saml.py index bea4c01..8bbade7 100644 --- a/src/agentkit/server/auth/providers/saml.py +++ b/src/agentkit/server/auth/providers/saml.py @@ -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"], - ) diff --git a/src/agentkit/server/auth/scim/router.py b/src/agentkit/server/auth/scim/router.py index 57dd779..9645c27 100644 --- a/src/agentkit/server/auth/scim/router.py +++ b/src/agentkit/server/auth/scim/router.py @@ -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 diff --git a/src/agentkit/server/routes/auth.py b/src/agentkit/server/routes/auth.py index 05fbd14..a7b95bc 100644 --- a/src/agentkit/server/routes/auth.py +++ b/src/agentkit/server/routes/auth.py @@ -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)