feat(cloud-auth): require production credentials
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user