From 7d22b5234d8ab9bd7d537ebd8fb3a373446078b3 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 12 Jul 2026 18:02:49 +0800 Subject: [PATCH] feat(cloud-auth): require production credentials --- apps/cloud-api/cloud_api/app.py | 13 +++- apps/cloud-api/tests/test_app.py | 13 +++- .../cloud-control-plane-integration/tasks.md | 2 +- packages/cloud-platform/cloud/auth.py | 14 ++++ .../cloud-platform/cloud/control_config.py | 64 ++++++++++++++++++- tests/test_cloud_control_config.py | 59 +++++++++++++++++ 6 files changed, 161 insertions(+), 4 deletions(-) diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 0e36c7d..6d4435d 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -5,7 +5,12 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from cloud.control_config import CloudControlConfig, load_control_config +from cloud.auth import create_auth_provider +from cloud.control_config import ( + CloudControlConfig, + load_control_config, + validate_control_config, +) from cloud.database import CloudDatabase @@ -19,13 +24,19 @@ def create_app( ) -> FastAPI: """Create the independently deployable cloud API application.""" control_config = config or load_control_config() + validate_control_config(control_config) build_database = database_factory or _default_database_factory + auth_provider = create_auth_provider( + control_config.credentials, + allow_insecure_anonymous=control_config.allow_insecure_anonymous, + ) @asynccontextmanager async def lifespan(app: FastAPI): database = build_database(control_config) app.state.cloud_config = control_config app.state.database = database + app.state.auth_provider = auth_provider try: yield finally: diff --git a/apps/cloud-api/tests/test_app.py b/apps/cloud-api/tests/test_app.py index 5631f1e..b969356 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -1,9 +1,10 @@ from __future__ import annotations +import pytest from fastapi.testclient import TestClient from cloud_api.app import create_app -from cloud.control_config import CloudControlConfig +from cloud.control_config import CloudConfigurationError, CloudControlConfig def test_create_app_returns_independent_cloud_application() -> None: @@ -31,3 +32,13 @@ def test_cloud_application_owns_database_lifecycle() -> None: assert events == [] assert events == ["closed"] + + +def test_production_app_rejects_missing_credentials() -> None: + with pytest.raises(CloudConfigurationError, match="credential"): + create_app( + config=CloudControlConfig( + environment="production", + database_url="postgresql://db/cloud", + ) + ) diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index e3b4648..ebf55b5 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -30,7 +30,7 @@ - [x] 4.1 Extend authenticated principals with scopes and implement constant-time configured bearer-token verification without logging credentials. - [x] 4.2 Add public scopes for task submission/read, pool read, plugin read, and plugin administration and enforce them on every `/v1` route. - [x] 4.3 Add host principals bound to one `host_id` and reject cross-host heartbeat, claim, renewal, or result operations. -- [ ] 4.4 Make missing production credentials a startup/readiness failure and permit anonymous mode only through the explicit non-production override. +- [x] 4.4 Make missing production credentials a startup/readiness failure and permit anonymous mode only through the explicit non-production override. - [ ] 4.5 Add authentication tests covering invalid tokens, missing scopes, host impersonation, plugin administration, and secret redaction. ## 5. Host Agent Internal API diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index 377cd3a..97a4b70 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field from hashlib import sha256 from hmac import compare_digest +from collections.abc import Iterable from typing import Protocol, runtime_checkable @@ -106,6 +107,19 @@ class ConfiguredBearerAuthProvider: return matched_principal +def create_auth_provider( + credentials: Iterable[BearerCredential], + *, + allow_insecure_anonymous: bool, +) -> AuthProvider: + configured = list(credentials) + if configured: + return ConfiguredBearerAuthProvider(configured) + if allow_insecure_anonymous: + return NullAuthProvider() + return ConfiguredBearerAuthProvider([]) + + def _extract_bearer_token(request: object) -> str | None: headers = getattr(request, "headers", None) if headers is None: diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py index 09d22b3..4927539 100644 --- a/packages/cloud-platform/cloud/control_config.py +++ b/packages/cloud-platform/cloud/control_config.py @@ -1,10 +1,13 @@ from __future__ import annotations +import json import os from collections.abc import Mapping from dataclasses import dataclass from typing import Literal +from cloud.auth import BearerCredential + EnvironmentName = Literal["local", "test", "production"] SUPPORTED_DATABASE_PREFIXES = ( @@ -27,6 +30,7 @@ class CloudControlConfig: lease_duration_seconds: float = 60.0 max_task_attempts: int = 3 allow_insecure_anonymous: bool = False + credentials: tuple[BearerCredential, ...] = () def load_control_config( @@ -75,12 +79,70 @@ def load_control_config( values.get("CLOUD_ALLOW_INSECURE_ANONYMOUS"), default=False, ), + credentials=( + *_parse_credentials(values.get("CLOUD_PUBLIC_CREDENTIALS_JSON")), + *_parse_credentials( + values.get("CLOUD_HOST_CREDENTIALS_JSON"), + require_host_id=True, + ), + ), ) + validate_control_config(config) + return config + + +def validate_control_config(config: CloudControlConfig) -> None: if config.environment == "production" and config.allow_insecure_anonymous: raise CloudConfigurationError( "anonymous access cannot be enabled in production" ) - return config + if config.environment == "production" and not config.credentials: + raise CloudConfigurationError( + "production requires at least one configured bearer credential" + ) + + +def _parse_credentials( + raw_value: str | None, + *, + require_host_id: bool = False, +) -> tuple[BearerCredential, ...]: + if raw_value is None or not raw_value.strip(): + return () + try: + payload = json.loads(raw_value) + if not isinstance(payload, list): + raise TypeError + credentials: list[BearerCredential] = [] + for item in payload: + if not isinstance(item, dict): + raise TypeError + principal_id = item.get("principal_id") + token = item.get("token") + scopes = item.get("scopes", []) + host_id = item.get("host_id") + if ( + not isinstance(principal_id, str) + or not isinstance(token, str) + or not isinstance(scopes, list) + or not all(isinstance(scope, str) for scope in scopes) + or (host_id is not None and not isinstance(host_id, str)) + or (require_host_id and not isinstance(host_id, str)) + ): + raise TypeError + credentials.append( + BearerCredential( + principal_id=principal_id, + token=token, + scopes=frozenset(scopes), + host_id=host_id, + ) + ) + return tuple(credentials) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise CloudConfigurationError( + "configured bearer credentials are invalid" + ) from exc def _positive_float( diff --git a/tests/test_cloud_control_config.py b/tests/test_cloud_control_config.py index 77c0497..2bb563c 100644 --- a/tests/test_cloud_control_config.py +++ b/tests/test_cloud_control_config.py @@ -2,6 +2,11 @@ from __future__ import annotations import pytest +from cloud.auth import ( + ConfiguredBearerAuthProvider, + NullAuthProvider, + create_auth_provider, +) from cloud.control_config import ( CloudConfigurationError, CloudControlConfig, @@ -22,6 +27,14 @@ def test_load_control_config_parses_deployment_values() -> None: "CLOUD_LEASE_REAPER_INTERVAL_SECONDS": "7", "CLOUD_LEASE_DURATION_SECONDS": "90", "CLOUD_MAX_TASK_ATTEMPTS": "5", + "CLOUD_PUBLIC_CREDENTIALS_JSON": ( + '[{"principal_id":"sdk","token":"sdk-secret",' + '"scopes":["tasks:submit","tasks:read"]}]' + ), + "CLOUD_HOST_CREDENTIALS_JSON": ( + '[{"principal_id":"agent-a","token":"host-secret",' + '"host_id":"host-a","scopes":["host:agent"]}]' + ), } ) @@ -29,6 +42,9 @@ def test_load_control_config_parses_deployment_values() -> None: assert config.database_url.startswith("postgresql+psycopg://") assert config.scheduler_interval_seconds == 2.5 assert config.max_task_attempts == 5 + assert len(config.credentials) == 2 + assert config.credentials[1].host_id == "host-a" + assert "sdk-secret" not in repr(config) @pytest.mark.parametrize( @@ -55,10 +71,53 @@ def test_load_control_config_rejects_unsafe_production_anonymous_mode() -> None: "CLOUD_ENVIRONMENT": "production", "CLOUD_DATABASE_URL": "postgresql://db/cloud", "CLOUD_ALLOW_INSECURE_ANONYMOUS": "true", + "CLOUD_PUBLIC_CREDENTIALS_JSON": ( + '[{"principal_id":"sdk","token":"secret","scopes":[]}]' + ), } ) +def test_load_control_config_rejects_missing_production_credentials() -> None: + with pytest.raises(CloudConfigurationError, match="credential"): + load_control_config( + { + "CLOUD_ENVIRONMENT": "production", + "CLOUD_DATABASE_URL": "postgresql://db/cloud", + } + ) + + +@pytest.mark.parametrize( + "name,value", + [ + ("CLOUD_PUBLIC_CREDENTIALS_JSON", "not-json"), + ("CLOUD_PUBLIC_CREDENTIALS_JSON", "{}"), + ( + "CLOUD_HOST_CREDENTIALS_JSON", + '[{"principal_id":"agent","token":"secret","scopes":[]}]', + ), + ], +) +def test_load_control_config_rejects_invalid_credentials( + name: str, + value: str, +) -> None: + with pytest.raises(CloudConfigurationError, match="credentials") as error: + load_control_config({name: value}) + assert "secret" not in str(error.value) + + +def test_auth_provider_requires_explicit_anonymous_override() -> None: + secure_provider = create_auth_provider([], allow_insecure_anonymous=False) + insecure_provider = create_auth_provider([], allow_insecure_anonymous=True) + + assert isinstance(secure_provider, ConfiguredBearerAuthProvider) + request = type("Request", (), {"headers": {}})() + assert secure_provider.authenticate(request) is None + assert isinstance(insecure_provider, NullAuthProvider) + + @pytest.mark.parametrize( "name,value", [