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