feat(cloud-auth): require production credentials

This commit is contained in:
2026-07-12 18:02:49 +08:00
parent 82b66f74a2
commit 7d22b5234d
6 changed files with 161 additions and 4 deletions
+12 -1
View File
@@ -5,7 +5,12 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI 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 from cloud.database import CloudDatabase
@@ -19,13 +24,19 @@ def create_app(
) -> FastAPI: ) -> FastAPI:
"""Create the independently deployable cloud API application.""" """Create the independently deployable cloud API application."""
control_config = config or load_control_config() control_config = config or load_control_config()
validate_control_config(control_config)
build_database = database_factory or _default_database_factory 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
database = build_database(control_config) database = build_database(control_config)
app.state.cloud_config = control_config app.state.cloud_config = control_config
app.state.database = database app.state.database = database
app.state.auth_provider = auth_provider
try: try:
yield yield
finally: finally:
+12 -1
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from cloud_api.app import create_app 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: 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 == []
assert events == ["closed"] 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",
)
)
@@ -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.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.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. - [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. - [ ] 4.5 Add authentication tests covering invalid tokens, missing scopes, host impersonation, plugin administration, and secret redaction.
## 5. Host Agent Internal API ## 5. Host Agent Internal API
+14
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from hashlib import sha256 from hashlib import sha256
from hmac import compare_digest from hmac import compare_digest
from collections.abc import Iterable
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -106,6 +107,19 @@ class ConfiguredBearerAuthProvider:
return matched_principal 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: def _extract_bearer_token(request: object) -> str | None:
headers = getattr(request, "headers", None) headers = getattr(request, "headers", None)
if headers is None: if headers is None:
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from cloud.auth import BearerCredential
EnvironmentName = Literal["local", "test", "production"] EnvironmentName = Literal["local", "test", "production"]
SUPPORTED_DATABASE_PREFIXES = ( SUPPORTED_DATABASE_PREFIXES = (
@@ -27,6 +30,7 @@ class CloudControlConfig:
lease_duration_seconds: float = 60.0 lease_duration_seconds: float = 60.0
max_task_attempts: int = 3 max_task_attempts: int = 3
allow_insecure_anonymous: bool = False allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = ()
def load_control_config( def load_control_config(
@@ -75,12 +79,70 @@ def load_control_config(
values.get("CLOUD_ALLOW_INSECURE_ANONYMOUS"), values.get("CLOUD_ALLOW_INSECURE_ANONYMOUS"),
default=False, 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: if config.environment == "production" and config.allow_insecure_anonymous:
raise CloudConfigurationError( raise CloudConfigurationError(
"anonymous access cannot be enabled in production" "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( def _positive_float(
+59
View File
@@ -2,6 +2,11 @@ from __future__ import annotations
import pytest import pytest
from cloud.auth import (
ConfiguredBearerAuthProvider,
NullAuthProvider,
create_auth_provider,
)
from cloud.control_config import ( from cloud.control_config import (
CloudConfigurationError, CloudConfigurationError,
CloudControlConfig, CloudControlConfig,
@@ -22,6 +27,14 @@ def test_load_control_config_parses_deployment_values() -> None:
"CLOUD_LEASE_REAPER_INTERVAL_SECONDS": "7", "CLOUD_LEASE_REAPER_INTERVAL_SECONDS": "7",
"CLOUD_LEASE_DURATION_SECONDS": "90", "CLOUD_LEASE_DURATION_SECONDS": "90",
"CLOUD_MAX_TASK_ATTEMPTS": "5", "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.database_url.startswith("postgresql+psycopg://")
assert config.scheduler_interval_seconds == 2.5 assert config.scheduler_interval_seconds == 2.5
assert config.max_task_attempts == 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( @pytest.mark.parametrize(
@@ -55,10 +71,53 @@ def test_load_control_config_rejects_unsafe_production_anonymous_mode() -> None:
"CLOUD_ENVIRONMENT": "production", "CLOUD_ENVIRONMENT": "production",
"CLOUD_DATABASE_URL": "postgresql://db/cloud", "CLOUD_DATABASE_URL": "postgresql://db/cloud",
"CLOUD_ALLOW_INSECURE_ANONYMOUS": "true", "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( @pytest.mark.parametrize(
"name,value", "name,value",
[ [