diff --git a/backend/app/gateway/auth/errors.py b/backend/app/gateway/auth/errors.py index 1c8065d2..85b30fd6 100644 --- a/backend/app/gateway/auth/errors.py +++ b/backend/app/gateway/auth/errors.py @@ -22,6 +22,7 @@ class AuthErrorCode(StrEnum): NOT_AUTHENTICATED = "not_authenticated" SYSTEM_ALREADY_INITIALIZED = "system_already_initialized" WORKSPACE_REQUIRED = "workspace_required" + INSUFFICIENT_SCOPE = "insufficient_scope" class TokenError(StrEnum): diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 7dcaf90c..8abf4e2c 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -55,6 +55,28 @@ def _is_public(path: str) -> bool: return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) +# Data-plane / SDK route prefixes a service principal (API key) may reach. +# Everything else (global control plane: models/mcp/memory/skills/channels/ +# agents, plus management/auth endpoints) is denied by default for API keys. +# NOTE: nginx rewrites /api/langgraph/(.*) -> /api/$1 before the gateway, so +# AuthMiddleware never sees /api/langgraph; the SDK surface arrives as +# /api/threads, /api/runs, /api/assistants. assistants.search()/get() is +# required for langgraph-sdk client init, so /api/assistants is allowed. +_DATAPLANE_PREFIXES: tuple[str, ...] = ( + "/api/threads", + "/api/v1/threads", + "/api/runs", + "/api/v1/runs", + "/api/assistants", +) + + +def _is_dataplane_path(path: str) -> bool: + """True if an API key request may reach this path. Reusable by a future + Pattern B service-token branch.""" + return any(path.startswith(prefix) for prefix in _DATAPLANE_PREFIXES) + + class AuthMiddleware(BaseHTTPMiddleware): """Strict auth gate: reject requests without a valid session. diff --git a/backend/tests/test_api_key_control_plane.py b/backend/tests/test_api_key_control_plane.py new file mode 100644 index 00000000..dc2351b4 --- /dev/null +++ b/backend/tests/test_api_key_control_plane.py @@ -0,0 +1,53 @@ +"""API key control-plane default-deny tests (Stage 1 收口). + +service principal (API key) 只能访问数据平面 (threads/runs/assistants); +控制平面 (models/mcp/memory/skills/channels/agents 与管理/auth) 一律 403。 +真人 cookie 路径不受影响。设计见 spec +docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md。 +""" + +from __future__ import annotations + +import pytest + +from app.gateway.auth_middleware import _is_dataplane_path + + +@pytest.mark.parametrize( + "path", + [ + "/api/threads", + "/api/threads/abc", + "/api/v1/threads", + "/api/v1/threads/abc/runs/xyz/feedback", + "/api/runs", + "/api/runs/stream", + "/api/v1/runs/stream", + "/api/assistants", + "/api/assistants/search", + ], +) +def test_dataplane_paths_allowed(path): + assert _is_dataplane_path(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/api/models", + "/api/v1/models", + "/api/mcp/config", + "/api/v1/mcp/config", + "/api/v1/memory", + "/api/v1/skills/install", + "/api/v1/channels/restart", + "/api/v1/agents", + "/api/v1/service-accounts", + "/api/v1/api-keys", + "/api/v1/auth/me", + "/api/v1/assistants", # assistants 是 LangGraph 兼容 shim,无 /api/v1 孪生:只放行 /api/assistants,缺 v1 变体是有意为之 + "/api/langgraph/threads", # nginx 死代码:中间件本看不到,真混进来也应 deny + ], +) +def test_control_plane_paths_denied(path): + assert _is_dataplane_path(path) is False