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 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:
+12 -1
View File
@@ -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",
)
)
@@ -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
+14
View File
@@ -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:
@@ -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(
+59
View File
@@ -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",
[