This commit is contained in:
@@ -84,29 +84,6 @@ class _StoredBearerCredential:
|
||||
token_digest: bytes = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnrollmentCredential:
|
||||
principal_id: str
|
||||
token: str = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.principal_id.strip():
|
||||
raise ValueError("enrollment credential principal_id must not be empty")
|
||||
if not self.token:
|
||||
raise ValueError("enrollment credential token must not be empty")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnrollmentPrincipal:
|
||||
id: str
|
||||
token_digest: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EnrollmentAuthProvider(Protocol):
|
||||
def authenticate(self, request: object) -> EnrollmentPrincipal | None: ...
|
||||
|
||||
|
||||
class ConfiguredBearerAuthProvider:
|
||||
"""Authenticate configured bearer tokens without exposing credential text."""
|
||||
|
||||
@@ -136,50 +113,6 @@ class ConfiguredBearerAuthProvider:
|
||||
return matched_principal
|
||||
|
||||
|
||||
class ConfiguredEnrollmentTokenProvider:
|
||||
def __init__(self, credentials: Iterable[EnrollmentCredential]) -> None:
|
||||
self._credentials = tuple(
|
||||
EnrollmentPrincipal(
|
||||
id=credential.principal_id,
|
||||
token_digest=digest_token(credential.token),
|
||||
)
|
||||
for credential in credentials
|
||||
)
|
||||
|
||||
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
|
||||
candidate = bearer_token_digest(request)
|
||||
if candidate is None:
|
||||
return None
|
||||
candidate_bytes = bytes.fromhex(candidate)
|
||||
matched: EnrollmentPrincipal | None = None
|
||||
for credential in self._credentials:
|
||||
if compare_digest(
|
||||
candidate_bytes,
|
||||
bytes.fromhex(credential.token_digest),
|
||||
):
|
||||
matched = credential
|
||||
return matched
|
||||
|
||||
|
||||
class SelfServiceEnrollmentAuthProvider:
|
||||
"""Unconditionally authorizes Host enrollment with no pre-issued token."""
|
||||
|
||||
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
|
||||
return EnrollmentPrincipal(id="self-service", token_digest=None)
|
||||
|
||||
|
||||
class ChainedEnrollmentAuthProvider:
|
||||
def __init__(self, providers: Iterable[EnrollmentAuthProvider]) -> None:
|
||||
self.providers = tuple(providers)
|
||||
|
||||
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
|
||||
for provider in self.providers:
|
||||
principal = provider.authenticate(request)
|
||||
if principal is not None:
|
||||
return principal
|
||||
return None
|
||||
|
||||
|
||||
class RepositoryHostAuthProvider:
|
||||
def __init__(self, repository: CloudRepository) -> None:
|
||||
self.repository = repository
|
||||
@@ -230,17 +163,17 @@ class ChainedAuthProvider:
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_provider(
|
||||
credentials: Iterable[BearerCredential],
|
||||
*,
|
||||
allow_insecure_anonymous: bool,
|
||||
) -> AuthProvider:
|
||||
configured = list(credentials)
|
||||
if configured:
|
||||
return ConfiguredBearerAuthProvider(configured)
|
||||
class RejectingAuthProvider:
|
||||
"""Safe default for deployments that do not configure static API tokens."""
|
||||
|
||||
def authenticate(self, request: object) -> Principal | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_provider(*, allow_insecure_anonymous: bool) -> AuthProvider:
|
||||
if allow_insecure_anonymous:
|
||||
return NullAuthProvider()
|
||||
return ConfiguredBearerAuthProvider([])
|
||||
return RejectingAuthProvider()
|
||||
|
||||
|
||||
def _extract_bearer_token(request: object) -> str | None:
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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, EnrollmentCredential
|
||||
|
||||
|
||||
EnvironmentName = Literal["local", "test", "production"]
|
||||
SUPPORTED_DATABASE_PREFIXES = (
|
||||
@@ -30,9 +27,6 @@ class CloudControlConfig:
|
||||
lease_duration_seconds: float = 60.0
|
||||
max_task_attempts: int = 3
|
||||
allow_insecure_anonymous: bool = False
|
||||
credentials: tuple[BearerCredential, ...] = ()
|
||||
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
||||
self_service_enrollment_enabled: bool = False
|
||||
cors_allowed_origins: tuple[str, ...] = ()
|
||||
console_static_dir: str | None = None
|
||||
user_session_idle_seconds: int = 28_800
|
||||
@@ -90,20 +84,6 @@ 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,
|
||||
),
|
||||
),
|
||||
enrollment_credentials=_parse_enrollment_credentials(
|
||||
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
|
||||
),
|
||||
self_service_enrollment_enabled=_parse_bool(
|
||||
values.get("CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED"),
|
||||
default=False,
|
||||
),
|
||||
cors_allowed_origins=_parse_cors_origins(
|
||||
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
||||
),
|
||||
@@ -145,10 +125,6 @@ def validate_control_config(config: CloudControlConfig) -> None:
|
||||
raise CloudConfigurationError(
|
||||
"anonymous access cannot be enabled in production"
|
||||
)
|
||||
if config.environment == "production" and not config.credentials:
|
||||
raise CloudConfigurationError(
|
||||
"production requires at least one configured bearer credential"
|
||||
)
|
||||
if config.user_session_absolute_seconds < config.user_session_idle_seconds:
|
||||
raise CloudConfigurationError(
|
||||
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
|
||||
@@ -159,76 +135,6 @@ def validate_control_config(config: CloudControlConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
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 _parse_enrollment_credentials(
|
||||
raw_value: str | None,
|
||||
) -> tuple[EnrollmentCredential, ...]:
|
||||
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[EnrollmentCredential] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError
|
||||
principal_id = item.get("principal_id")
|
||||
token = item.get("token")
|
||||
if not isinstance(principal_id, str) or not isinstance(token, str):
|
||||
raise TypeError
|
||||
credentials.append(
|
||||
EnrollmentCredential(principal_id=principal_id, token=token)
|
||||
)
|
||||
return tuple(credentials)
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise CloudConfigurationError(
|
||||
"configured enrollment credentials are invalid"
|
||||
) from exc
|
||||
|
||||
|
||||
def _positive_float(
|
||||
values: Mapping[str, str],
|
||||
name: str,
|
||||
|
||||
@@ -12,8 +12,6 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from cloud.auth import (
|
||||
AuthProvider,
|
||||
ConfiguredEnrollmentTokenProvider,
|
||||
EnrollmentAuthProvider,
|
||||
HostAuthorizationError,
|
||||
digest_token,
|
||||
)
|
||||
@@ -35,7 +33,6 @@ from cloud.internal_api.models import (
|
||||
)
|
||||
from cloud.repository import (
|
||||
DeviceEnrollmentConflictError,
|
||||
EnrollmentTokenConflictError,
|
||||
HostEnrollmentConflictError,
|
||||
)
|
||||
from core.models import Device, utc_now
|
||||
@@ -48,7 +45,6 @@ def create_internal_router(
|
||||
*,
|
||||
pool: DevicePool,
|
||||
auth_provider: AuthProvider,
|
||||
enrollment_auth_provider: EnrollmentAuthProvider | None = None,
|
||||
version_prefix: str = "/internal/v1",
|
||||
claim_poll_interval_seconds: float = 0.1,
|
||||
lease_duration_seconds: float = 60.0,
|
||||
@@ -59,8 +55,6 @@ def create_internal_router(
|
||||
if lease_duration_seconds <= 0:
|
||||
raise ValueError("lease_duration_seconds must be greater than zero")
|
||||
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
|
||||
enrollment_auth = enrollment_auth_provider or ConfiguredEnrollmentTokenProvider(())
|
||||
|
||||
def authorize_host(request: Request, host_id: str) -> None:
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
@@ -84,25 +78,17 @@ def create_internal_router(
|
||||
)
|
||||
def enroll_host(
|
||||
payload: HostEnrollmentRequest,
|
||||
request: Request,
|
||||
) -> HostEnrollmentResponse:
|
||||
enrollment_principal = enrollment_auth.authenticate(request)
|
||||
if enrollment_principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
enrollment = pool.store.enroll_host(
|
||||
host_id=f"host-{uuid4().hex}",
|
||||
agent_instance_id=payload.agent_instance_id,
|
||||
credential_digest=digest_token(payload.host_token),
|
||||
enrollment_token_digest=enrollment_principal.token_digest,
|
||||
enrollment_token_digest=None,
|
||||
display_name=payload.display_name,
|
||||
enrolled_at=utc_now(),
|
||||
)
|
||||
except (EnrollmentTokenConflictError, HostEnrollmentConflictError) as exc:
|
||||
except HostEnrollmentConflictError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
|
||||
Reference in New Issue
Block a user