diff --git a/.env.example b/.env.example index 3f82cf1..bbb49cf 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ CLOUD_API_PORT=8001 CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}] CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}] CLOUD_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}] +# Zero-token self-service Host enrollment (see docs/CLOUD_DEPLOYMENT.md). Only +# enable this on a deployment whose control-plane URL is not reachable by +# untrusted networks; any caller that reaches it can register itself as a Host. +CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false CLOUD_SCHEDULER_INTERVAL_SECONDS=1 CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 CLOUD_LEASE_DURATION_SECONDS=60 @@ -29,6 +33,10 @@ HOST_AGENT_HOST_ID=host-local HOST_AGENT_TOKEN=change-me-host-token HOST_AGENT_ENROLLMENT_TOKEN= HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json +# One-time local operator account gate; run `device-host-agent setup` +# interactively before the daemon's first unattended start (see +# docs/CLOUD_DEPLOYMENT.md). +HOST_AGENT_LOCAL_ACCOUNT_PATH=/app/tasks/host_local_account.json HOST_AGENT_DISPLAY_NAME= HOST_AGENT_TASKS_PATH=./tasks HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30 diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index ea06567..3e270e1 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -18,8 +18,10 @@ from starlette.types import Scope from cloud.auth import ( ChainedAuthProvider, + ChainedEnrollmentAuthProvider, ConfiguredEnrollmentTokenProvider, RepositoryHostAuthProvider, + SelfServiceEnrollmentAuthProvider, UserSessionAuthProvider, create_auth_provider, ) @@ -134,8 +136,15 @@ def create_app( RepositoryHostAuthProvider(repository), # type: ignore[arg-type] ) ) - enrollment_auth_provider = ConfiguredEnrollmentTokenProvider( - control_config.enrollment_credentials + enrollment_auth_provider = ChainedEnrollmentAuthProvider( + ( + ConfiguredEnrollmentTokenProvider(control_config.enrollment_credentials), + *( + (SelfServiceEnrollmentAuthProvider(),) + if control_config.self_service_enrollment_enabled + else () + ), + ) ) domain_config = CloudConfig( lease_duration_seconds=control_config.lease_duration_seconds, diff --git a/apps/device-host-agent/host_agent/cli.py b/apps/device-host-agent/host_agent/cli.py index 5c2723c..9906853 100644 --- a/apps/device-host-agent/host_agent/cli.py +++ b/apps/device-host-agent/host_agent/cli.py @@ -1,12 +1,76 @@ from __future__ import annotations import argparse +import getpass +import sys from collections.abc import Sequence +from dataclasses import replace from host_agent.app import create_application +from host_agent.config import load_host_agent_config +from host_agent.local_account import LocalAccountStore + + +class LocalAccountSetupError(RuntimeError): + """Raised when local account bootstrap cannot proceed.""" def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Run the Device Host Agent") - parser.parse_args(argv) - create_application().run() + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("setup", help="Create the local operator account") + args = parser.parse_args(argv) + + try: + if args.command == "setup": + _run_setup() + return + config = _resolve_config_with_local_account() + except LocalAccountSetupError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + create_application(config=config).run() + + +def _run_setup() -> None: + config = load_host_agent_config() + store = LocalAccountStore(config.local_account_path) + if store.load() is not None: + confirm = input( + "A local account already exists. Overwrite it? [y/N] " + ).strip().lower() + if confirm != "y": + print("Setup cancelled; existing account left unchanged.") + return + account = _prompt_and_create(store) + print(f"Local account '{account.username}' created.") + + +def _resolve_config_with_local_account(): + config = load_host_agent_config() + store = LocalAccountStore(config.local_account_path) + account = store.load() + if account is None: + if not sys.stdin.isatty(): + raise LocalAccountSetupError( + "no local account configured; run `device-host-agent setup` " + "on an interactive terminal to create one" + ) + account = _prompt_and_create(store) + if not config.display_name: + config = replace(config, display_name=account.username) + return config + + +def _prompt_and_create(store: LocalAccountStore): + username = input("Username: ").strip() + if not username: + raise LocalAccountSetupError("username must not be empty") + password = getpass.getpass("Password: ") + confirm = getpass.getpass("Confirm password: ") + if not password: + raise LocalAccountSetupError("password must not be empty") + if password != confirm: + raise LocalAccountSetupError("passwords do not match") + return store.create(username, password) diff --git a/apps/device-host-agent/host_agent/client.py b/apps/device-host-agent/host_agent/client.py index 97430eb..1f39da1 100644 --- a/apps/device-host-agent/host_agent/client.py +++ b/apps/device-host-agent/host_agent/client.py @@ -53,12 +53,10 @@ class HostAgentEnrollmentClient: host_token: str, display_name: str | None, ) -> HostEnrollmentResponse: - if not self.config.enrollment_token: - raise HostAgentAPIError(0, "Host enrollment token is unavailable") response = self._request( "POST", "/internal/v1/enrollments", - token=self.config.enrollment_token, + token=self.config.enrollment_token or None, json={ "agent_instance_id": agent_instance_id, "host_token": host_token, @@ -99,9 +97,10 @@ class HostAgentEnrollmentClient: method: str, path: str, *, - token: str, + token: str | None, json: dict[str, Any], ) -> httpx.Response: + headers = {"Authorization": f"Bearer {token}"} if token else {} backoff = self.config.retry_backoff_seconds for attempt in range(1, self.config.max_retry_attempts + 1): try: @@ -109,7 +108,7 @@ class HostAgentEnrollmentClient: method, path, json=json, - headers={"Authorization": f"Bearer {token}"}, + headers=headers, ) except httpx.TransportError: if attempt == self.config.max_retry_attempts: diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index 29a5e0c..e1badec 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -18,6 +18,7 @@ class HostAgentConfig: token: str = field(default="", repr=False) enrollment_token: str = field(default="", repr=False) identity_path: Path = Path("tasks/host_identity.json") + local_account_path: Path = Path("tasks/host_local_account.json") enrollment_managed: bool = False display_name: str | None = None heartbeat_interval_seconds: float = 30.0 @@ -34,7 +35,7 @@ def load_host_agent_config( control_plane_url = ( values.get( "HOST_AGENT_CONTROL_PLANE_URL", - "http://127.0.0.1:8001", + "https://amcp.home.jerryyan.top", ) .strip() .rstrip("/") @@ -55,11 +56,11 @@ def load_host_agent_config( identity_path = Path( values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip() ) - if not host_id and not enrollment_token and not identity_path.is_file(): - raise HostAgentConfigurationError( - "explicit Host credentials, an enrollment token, or existing identity state " - "is required" - ) + local_account_path = Path( + values.get( + "HOST_AGENT_LOCAL_ACCOUNT_PATH", "tasks/host_local_account.json" + ).strip() + ) config = HostAgentConfig( control_plane_url=control_plane_url, @@ -67,6 +68,7 @@ def load_host_agent_config( token=token, enrollment_token=enrollment_token, identity_path=identity_path, + local_account_path=local_account_path, enrollment_managed=not bool(host_id), display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, heartbeat_interval_seconds=_positive_float( diff --git a/apps/device-host-agent/host_agent/enrollment.py b/apps/device-host-agent/host_agent/enrollment.py index 5c4f12c..25585e5 100644 --- a/apps/device-host-agent/host_agent/enrollment.py +++ b/apps/device-host-agent/host_agent/enrollment.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace from host_agent.client import HostAgentEnrollmentClient -from host_agent.config import HostAgentConfig, HostAgentConfigurationError +from host_agent.config import HostAgentConfig from host_agent.identity import HostIdentityStore @@ -17,10 +17,6 @@ def resolve_host_identity( return config state = identity_store.load_or_create() if state.host_id is None: - if not config.enrollment_token: - raise HostAgentConfigurationError( - "HOST_AGENT_ENROLLMENT_TOKEN is required to complete enrollment" - ) response = client.enroll_host( agent_instance_id=state.agent_instance_id, host_token=state.token, diff --git a/apps/device-host-agent/host_agent/local_account.py b/apps/device-host-agent/host_agent/local_account.py new file mode 100644 index 0000000..ea65de2 --- /dev/null +++ b/apps/device-host-agent/host_agent/local_account.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hmac +import json +import os +from dataclasses import dataclass, field +from hashlib import pbkdf2_hmac +from pathlib import Path +from secrets import token_bytes +from uuid import uuid4 + + +PBKDF2_ITERATIONS = 600_000 +SALT_BYTES = 16 + + +class LocalAccountStateError(RuntimeError): + """Raised when persisted local account state is missing or invalid.""" + + +@dataclass(frozen=True) +class LocalAccountState: + username: str + salt: bytes = field(repr=False) + iterations: int + password_hash: bytes = field(repr=False) + + +class LocalAccountStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def load(self) -> LocalAccountState | None: + if not self.path.exists(): + return None + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise LocalAccountStateError("local account state is unreadable") from exc + if not isinstance(payload, dict): + raise LocalAccountStateError("local account state must be an object") + username = payload.get("username") + salt_hex = payload.get("salt") + iterations = payload.get("iterations") + password_hash_hex = payload.get("password_hash") + if ( + not isinstance(username, str) + or not username + or not isinstance(salt_hex, str) + or not isinstance(iterations, int) + or iterations <= 0 + or not isinstance(password_hash_hex, str) + ): + raise LocalAccountStateError("local account state is invalid") + try: + salt = bytes.fromhex(salt_hex) + password_hash = bytes.fromhex(password_hash_hex) + except ValueError as exc: + raise LocalAccountStateError("local account state is invalid") from exc + return LocalAccountState( + username=username, + salt=salt, + iterations=iterations, + password_hash=password_hash, + ) + + def create(self, username: str, password: str) -> LocalAccountState: + if not username.strip(): + raise ValueError("username must not be empty") + if not password: + raise ValueError("password must not be empty") + salt = token_bytes(SALT_BYTES) + state = LocalAccountState( + username=username, + salt=salt, + iterations=PBKDF2_ITERATIONS, + password_hash=_derive_hash(password, salt, PBKDF2_ITERATIONS), + ) + self._write(state) + return state + + def verify(self, state: LocalAccountState, password: str) -> bool: + candidate = _derive_hash(password, state.salt, state.iterations) + return hmac.compare_digest(candidate, state.password_hash) + + def _write(self, state: LocalAccountState) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp") + payload = { + "username": state.username, + "salt": state.salt.hex(), + "iterations": state.iterations, + "password_hash": state.password_hash.hex(), + } + try: + temporary.write_text( + json.dumps(payload, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + _restrict_permissions(temporary) + os.replace(temporary, self.path) + _restrict_permissions(self.path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _derive_hash(password: str, salt: bytes, iterations: int) -> bytes: + return pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + + +def _restrict_permissions(path: Path) -> None: + try: + path.chmod(0o600) + except OSError: + return diff --git a/apps/device-host-agent/tests/test_cli.py b/apps/device-host-agent/tests/test_cli.py new file mode 100644 index 0000000..3817584 --- /dev/null +++ b/apps/device-host-agent/tests/test_cli.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import sys + +import pytest + +from host_agent import cli +from host_agent.local_account import LocalAccountStore + + +class _RecordingApplication: + def __init__(self) -> None: + self.ran = False + + def run(self) -> None: + self.ran = True + + +def _base_env(tmp_path) -> dict[str, str]: + return { + "HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example", + "HOST_AGENT_LOCAL_ACCOUNT_PATH": str(tmp_path / "account.json"), + "HOST_AGENT_IDENTITY_PATH": str(tmp_path / "identity.json"), + } + + +def _set_env(monkeypatch, tmp_path) -> None: + for key, value in _base_env(tmp_path).items(): + monkeypatch.setenv(key, value) + + +def _patch_create_application(monkeypatch) -> dict: + captured: dict = {} + + def fake_create_application(*, config=None, **kwargs): + captured["config"] = config + app = _RecordingApplication() + captured["app"] = app + return app + + monkeypatch.setattr(cli, "create_application", fake_create_application) + return captured + + +def test_existing_account_fast_path_skips_prompting(monkeypatch, tmp_path) -> None: + _set_env(monkeypatch, tmp_path) + LocalAccountStore(tmp_path / "account.json").create( + "operator", "correct horse battery staple" + ) + + def fail_input(prompt: str = "") -> str: + raise AssertionError("must not prompt when a local account already exists") + + monkeypatch.setattr("builtins.input", fail_input) + captured = _patch_create_application(monkeypatch) + + cli.main([]) + + assert captured["app"].ran is True + assert captured["config"].display_name == "operator" + + +def test_interactive_first_run_prompts_and_creates_account(monkeypatch, tmp_path) -> None: + _set_env(monkeypatch, tmp_path) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + inputs = iter(["operator"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs)) + passwords = iter(["hunter2", "hunter2"]) + monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords)) + captured = _patch_create_application(monkeypatch) + + cli.main([]) + + assert captured["app"].ran is True + account = LocalAccountStore(tmp_path / "account.json").load() + assert account is not None + assert account.username == "operator" + + +def test_non_interactive_without_account_exits_with_clear_error( + monkeypatch, tmp_path, capsys +) -> None: + _set_env(monkeypatch, tmp_path) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + captured = _patch_create_application(monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + cli.main([]) + + assert exc_info.value.code == 1 + assert "setup" in capsys.readouterr().err + assert "app" not in captured + assert LocalAccountStore(tmp_path / "account.json").load() is None + + +def test_setup_subcommand_creates_account(monkeypatch, tmp_path) -> None: + _set_env(monkeypatch, tmp_path) + inputs = iter(["operator"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs)) + passwords = iter(["hunter2", "hunter2"]) + monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords)) + captured = _patch_create_application(monkeypatch) + + cli.main(["setup"]) + + assert "app" not in captured + account = LocalAccountStore(tmp_path / "account.json").load() + assert account is not None + assert account.username == "operator" + + +def test_setup_subcommand_refuses_overwrite_without_confirmation( + monkeypatch, tmp_path +) -> None: + _set_env(monkeypatch, tmp_path) + store = LocalAccountStore(tmp_path / "account.json") + original = store.create("operator", "original-password") + monkeypatch.setattr("builtins.input", lambda prompt="": "n") + + cli.main(["setup"]) + + assert store.load() == original diff --git a/apps/device-host-agent/tests/test_client.py b/apps/device-host-agent/tests/test_client.py index 451367e..8476c4a 100644 --- a/apps/device-host-agent/tests/test_client.py +++ b/apps/device-host-agent/tests/test_client.py @@ -225,3 +225,36 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N assert requests[0].content == requests[1].content assert requests[0].headers["authorization"] == "Bearer one-time-token" assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40)) + + +def test_self_service_enrollment_sends_no_authorization_header() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json={"host_id": "host-cloud-a"}) + + config = _config( + host_id="", + token="", + enrollment_token="", + enrollment_managed=True, + ) + with httpx.Client( + transport=httpx.MockTransport(handler), + base_url="https://control.example", + ) as http_client: + client = HostAgentEnrollmentClient( + config, + http_client=http_client, + sleep=lambda _delay: None, + ) + host = client.enroll_host( + agent_instance_id="agent-instance-a", + host_token="host-token-" + ("x" * 40), + display_name="operator", + ) + + assert host.host_id == "host-cloud-a" + assert len(requests) == 1 + assert "authorization" not in requests[0].headers diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py index 4b4332e..58ba276 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -17,9 +17,9 @@ BASE_ENV = { } -def test_load_host_agent_config_uses_local_network_defaults() -> None: +def test_load_host_agent_config_uses_managed_cloud_default() -> None: assert load_host_agent_config(BASE_ENV) == HostAgentConfig( - control_plane_url="http://127.0.0.1:8001", + control_plane_url="https://amcp.home.jerryyan.top", host_id="host-a", token="secret", ) @@ -74,6 +74,27 @@ def test_existing_identity_state_allows_restart_without_enrollment_token( assert config.enrollment_managed is True +def test_fresh_install_with_no_token_is_valid_and_defaults_local_account_path() -> None: + config = load_host_agent_config({"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example"}) + + assert config.host_id == "" + assert config.enrollment_token == "" + assert config.enrollment_managed is True + assert config.local_account_path == Path("tasks/host_local_account.json") + + +def test_local_account_path_can_be_overridden(tmp_path) -> None: + account_path = tmp_path / "account.json" + config = load_host_agent_config( + { + "HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example", + "HOST_AGENT_LOCAL_ACCOUNT_PATH": str(account_path), + } + ) + + assert config.local_account_path == account_path + + @pytest.mark.parametrize( "overrides", [ diff --git a/apps/device-host-agent/tests/test_enrollment.py b/apps/device-host-agent/tests/test_enrollment.py new file mode 100644 index 0000000..bfa0bdc --- /dev/null +++ b/apps/device-host-agent/tests/test_enrollment.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from host_agent.client import HostAgentAPIError +from host_agent.config import HostAgentConfig +from host_agent.enrollment import resolve_host_identity +from host_agent.identity import HostIdentityStore + + +def _config(**overrides) -> HostAgentConfig: + values = { + "control_plane_url": "https://control.example", + } + values.update(overrides) + return HostAgentConfig(**values) + + +class _RecordingEnrollmentClient: + def __init__(self, *, host_id: str = "host-cloud-a") -> None: + self.host_id = host_id + self.calls: list[dict] = [] + + def enroll_host(self, **payload): + from cloud.internal_api.models import HostEnrollmentResponse + + self.calls.append(payload) + return HostEnrollmentResponse(host_id=self.host_id) + + +class _RejectingEnrollmentClient: + def enroll_host(self, **payload): + raise HostAgentAPIError(401, "unauthorized") + + +def test_fresh_install_with_no_token_self_enrolls(tmp_path) -> None: + identity_store = HostIdentityStore(tmp_path / "identity.json") + client = _RecordingEnrollmentClient() + + resolved = resolve_host_identity( + _config(enrollment_token=""), + identity_store=identity_store, + client=client, + ) + + assert resolved.host_id == "host-cloud-a" + assert len(client.calls) == 1 + assert identity_store.load().host_id == "host-cloud-a" + + +def test_self_service_rejection_propagates_as_api_error(tmp_path) -> None: + identity_store = HostIdentityStore(tmp_path / "identity.json") + client = _RejectingEnrollmentClient() + + try: + resolve_host_identity( + _config(enrollment_token=""), + identity_store=identity_store, + client=client, + ) + except HostAgentAPIError as exc: + assert exc.status_code == 401 + else: + raise AssertionError("expected HostAgentAPIError to propagate") + assert identity_store.load().host_id is None + + +def test_configured_enrollment_token_still_used_when_present(tmp_path) -> None: + identity_store = HostIdentityStore(tmp_path / "identity.json") + client = _RecordingEnrollmentClient() + + resolve_host_identity( + _config(enrollment_token="one-time-token"), + identity_store=identity_store, + client=client, + ) + + assert client.calls[0]["agent_instance_id"] + + +def test_existing_cached_identity_skips_enrollment(tmp_path) -> None: + identity_store = HostIdentityStore(tmp_path / "identity.json") + identity_store.complete(identity_store.load_or_create(), "host-cloud-a") + + class ExplodingClient: + def enroll_host(self, **payload): + raise AssertionError("cached identity must skip enrollment") + + resolved = resolve_host_identity( + _config(enrollment_token=""), + identity_store=identity_store, + client=ExplodingClient(), # type: ignore[arg-type] + ) + + assert resolved.host_id == "host-cloud-a" diff --git a/apps/device-host-agent/tests/test_local_account.py b/apps/device-host-agent/tests/test_local_account.py new file mode 100644 index 0000000..195c4a1 --- /dev/null +++ b/apps/device-host-agent/tests/test_local_account.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os + +import pytest + +from host_agent.local_account import LocalAccountStateError, LocalAccountStore + + +def test_local_account_store_creates_and_verifies_password(tmp_path) -> None: + path = tmp_path / "state" / "host_local_account.json" + store = LocalAccountStore(path) + + assert store.load() is None + + created = store.create("operator", "correct horse battery staple") + assert created.username == "operator" + assert store.load() == created + assert store.verify(created, "correct horse battery staple") is True + assert store.verify(created, "wrong password") is False + + raw = path.read_text(encoding="utf-8") + assert "correct horse battery staple" not in raw + assert "password" not in raw.lower() or "password_hash" in raw + if os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_local_account_state_never_exposes_password_via_repr(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + created = store.create("operator", "hunter2") + + assert "hunter2" not in repr(created) + assert "salt=" not in repr(created) + assert "password_hash=" not in repr(created) + + +def test_local_account_store_rejects_corrupted_file(tmp_path) -> None: + path = tmp_path / "host_local_account.json" + path.write_text('{"username": "operator"}', encoding="utf-8") + store = LocalAccountStore(path) + + with pytest.raises(LocalAccountStateError): + store.load() + + +def test_local_account_store_rejects_invalid_json(tmp_path) -> None: + path = tmp_path / "host_local_account.json" + path.write_text("not json", encoding="utf-8") + store = LocalAccountStore(path) + + with pytest.raises(LocalAccountStateError): + store.load() + + +def test_local_account_store_rejects_empty_credentials(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + + with pytest.raises(ValueError): + store.create("", "password") + with pytest.raises(ValueError): + store.create("operator", "") diff --git a/compose.deploy.yaml b/compose.deploy.yaml index f0523b2..f3f7bce 100644 --- a/compose.deploy.yaml +++ b/compose.deploy.yaml @@ -28,6 +28,11 @@ services: CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} + # Left disabled by default; the amcp.home.jerryyan.top deployment sets + # CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true in its own .env (not + # committed here) since it is the intended target for zero-token edge + # enrollment. See docs/CLOUD_DEPLOYMENT.md. + CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} diff --git a/compose.yaml b/compose.yaml index a85a244..7a78dc6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -29,6 +29,7 @@ services: CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} + CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} @@ -53,6 +54,10 @@ services: retries: 12 restart: unless-stopped + # Before the first `docker compose up`, create the one-time local operator + # account interactively: `docker compose run --rm host-agent device-host-agent setup`. + # The daemon's default command below has no TTY and fails fast if that + # account does not already exist under HOST_AGENT_LOCAL_ACCOUNT_PATH. host-agent: build: context: . @@ -63,6 +68,7 @@ services: HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN} HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-} HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json} + HOST_AGENT_LOCAL_ACCOUNT_PATH: ${HOST_AGENT_LOCAL_ACCOUNT_PATH:-/app/tasks/host_local_account.json} HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-} HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30} HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20} diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 9e8156a..ef2527a 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -84,6 +84,46 @@ Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps the previous legacy behavior, including locally selected device IDs. This is the rollback and staged-migration path for existing deployments. +## Self-Service Edge Enrollment (Zero-Token) + +The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is +`https://amcp.home.jerryyan.top`. This is a single-operator home deployment +default; override the environment variable for local/dev/test runs pointed at +a different Cloud API. + +When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists, +the Host Agent enrolls with no bearer credential at all. The Cloud API only +accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true` +(default `false`); a configured enrollment token, if presented, always takes +priority over self-service. This trades away any approval step: **any caller +that can reach the control-plane URL can register itself as a new Host.** +There is no rate limiting or throttling on this path by design — the intended +mitigation is network-perimeter control (firewall/reverse-proxy access to the +URL), not an in-process limiter. Only enable the flag on a deployment where +that network boundary is enforced. + +Before the Host Agent's first unattended start, create the one-time local +operator account interactively: + +```bash +uv run --package device-host-agent device-host-agent setup +``` + +This prompts for a username/password and writes a PBKDF2-hashed credential +file to `HOST_AGENT_LOCAL_ACCOUNT_PATH` (default +`tasks/host_local_account.json`), gating only this first-run bootstrap step — +it is not re-checked on subsequent unattended restarts. Running the daemon's +default command without a controlling terminal before this file exists fails +fast with a message naming the `setup` step, instead of hanging on a prompt +no one can answer. + +Running under Compose, create the account once before `docker compose up`: + +```bash +docker compose run --rm host-agent device-host-agent setup +docker compose up -d +``` + ## PostgreSQL Deployment Start from `.env.example`, replace every `change-me-*` value, and keep the @@ -154,6 +194,10 @@ renewal, and result operations for that host. pool, or operate as a Host; they can only create one durable Host binding. Use high-entropy values generated by the deployment secret manager. +`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts +Host enrollment requests with no bearer token at all — see +[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token). + Do not place bearer tokens in command history, image layers, Compose files, or logs. Use environment injection or the deployment platform's secret manager. Rotate a token by deploying the updated Cloud API credential set and Host Agent diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index eb96e8c..af1eb7a 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -367,11 +367,21 @@ DeviceConfigStore("tasks/device_config.sqlite3").add( PY ``` -从云端管理员获取一枚未使用的一次性 enrollment token,然后启动: +首次启动前,先在交互式终端创建一次性本地操作账号(仅用于门禁"首次启动"这个 +动作本身,后续无人值守重启不会再次要求): + +```bash +uv run --package device-host-agent device-host-agent setup +``` + +`HOST_AGENT_CONTROL_PLANE_URL` 默认已固定为 `https://amcp.home.jerryyan.top`; +仅在连接其他云端环境(如本地/测试用的 `cloud-api`)时才需要覆盖该变量。未设置 +`HOST_AGENT_ENROLLMENT_TOKEN` 时,Host Agent 会直接向云端发起零 token 自助注册 +(需要云端 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`);若仍持有云端管理员 +签发的一次性 enrollment token,也可以继续设置 `HOST_AGENT_ENROLLMENT_TOKEN` +走原有的 token 注册路径: ```bash -export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com" -export HOST_AGENT_ENROLLMENT_TOKEN="" export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json" export HOST_AGENT_DISPLAY_NAME="Edge Mac 01" @@ -384,9 +394,13 @@ uv run --package device-host-agent device-host-agent 首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备 换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 -Host Agent 不监听入站端口。成功后可以从边缘环境移除 enrollment token,但必须 -保留并保护 `tasks/host_identity.json` 和 `tasks/device_config.sqlite3`;前者等同于 -Host bearer credential。 +Host Agent 不监听入站端口。若使用了一次性 enrollment token,成功后可以将其从 +边缘环境移除;但必须保留并保护 `tasks/host_identity.json` 和 +`tasks/device_config.sqlite3`;前者等同于 Host bearer credential。 + +零 token 自助注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有 +审批环节,也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层 +限制,仅应在受控网络中开启 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`。 需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host credential,然后显式设置 `HOST_AGENT_HOST_ID` 与 `HOST_AGENT_TOKEN`。这两个变量 diff --git a/openspec/changes/edge-host-self-enrollment/tasks.md b/openspec/changes/edge-host-self-enrollment/tasks.md index 291d97f..eaf0684 100644 --- a/openspec/changes/edge-host-self-enrollment/tasks.md +++ b/openspec/changes/edge-host-self-enrollment/tasks.md @@ -1,46 +1,46 @@ ## 1. Host Agent local account storage -- [ ] 1.1 Add `host_agent/local_account.py` with `LocalAccountState` (username, salt, iterations, password_hash) and `LocalAccountStore` (load/create), reusing `identity.py`'s atomic temp-file-then-`os.replace` write and `chmod 0600` pattern -- [ ] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification -- [ ] 1.3 Add `HOST_AGENT_LOCAL_ACCOUNT_PATH` to `config.py::HostAgentConfig`/`load_host_agent_config` with a default alongside the existing `tasks/` state directory (e.g. `tasks/host_local_account.json`) -- [ ] 1.4 Add unit tests: file creation is atomic and `0600`, corrupted/invalid file raises a clear error, password hash roundtrips correctly, no plaintext password ever appears in the persisted file or in `repr()`/logging paths +- [x] 1.1 Add `host_agent/local_account.py` with `LocalAccountState` (username, salt, iterations, password_hash) and `LocalAccountStore` (load/create), reusing `identity.py`'s atomic temp-file-then-`os.replace` write and `chmod 0600` pattern +- [x] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification +- [x] 1.3 Add `HOST_AGENT_LOCAL_ACCOUNT_PATH` to `config.py::HostAgentConfig`/`load_host_agent_config` with a default alongside the existing `tasks/` state directory (e.g. `tasks/host_local_account.json`) +- [x] 1.4 Add unit tests: file creation is atomic and `0600`, corrupted/invalid file raises a clear error, password hash roundtrips correctly, no plaintext password ever appears in the persisted file or in `repr()`/logging paths ## 2. Host Agent CLI first-run bootstrap -- [ ] 2.1 Add a `setup` subcommand to `cli.py` (argparse subparsers) that prompts via `getpass.getpass` for username/password and creates the local account if none exists, refusing to overwrite an existing account without explicit confirmation -- [ ] 2.2 Change the default (no subcommand) path in `cli.py`/`app.py::create_application` to check for the local account before starting: if present, proceed unchanged; if absent and `sys.stdin.isatty()`, prompt inline; if absent and not a TTY, exit with a clear error naming the `setup` subcommand -- [ ] 2.3 Add tests covering: existing-account fast path, interactive TTY prompt path (mocked), non-interactive no-account failure path, and the `setup` subcommand itself +- [x] 2.1 Add a `setup` subcommand to `cli.py` (argparse subparsers) that prompts via `getpass.getpass` for username/password and creates the local account if none exists, refusing to overwrite an existing account without explicit confirmation +- [x] 2.2 Change the default (no subcommand) path in `cli.py`/`app.py::create_application` to check for the local account before starting: if present, proceed unchanged; if absent and `sys.stdin.isatty()`, prompt inline; if absent and not a TTY, exit with a clear error naming the `setup` subcommand +- [x] 2.3 Add tests covering: existing-account fast path, interactive TTY prompt path (mocked), non-interactive no-account failure path, and the `setup` subcommand itself ## 3. Host Agent enrollment fallback and fixed control-plane URL -- [ ] 3.1 Change `config.py::load_host_agent_config`'s `HOST_AGENT_CONTROL_PLANE_URL` default to `https://amcp.home.jerryyan.top`, keeping the existing `urlparse` validation and environment-variable override behavior -- [ ] 3.2 Remove the `HostAgentConfigurationError` raised when no host_id/token, no enrollment token, and no identity file are present; a missing enrollment token is no longer a startup configuration error -- [ ] 3.3 Update `enrollment.py::resolve_host_identity` to call the enrollment client with no bearer credential when `config.enrollment_token` is empty, instead of raising -- [ ] 3.4 Update `client.py::HostAgentEnrollmentClient.enroll_host` (or equivalent) to support an unauthenticated (no `Authorization` header) enrollment request path, and to pass the local account username as `display_name` when `HOST_AGENT_DISPLAY_NAME` is unset -- [ ] 3.5 Add/update tests: fresh install with no token self-enrolls successfully (mocked cloud response), self-service rejection (`401` from cloud) surfaces as a clear startup failure and does not start polling, configured `HOST_AGENT_ENROLLMENT_TOKEN` still takes the existing token-bound path unchanged, existing cached identity skips enrollment entirely +- [x] 3.1 Change `config.py::load_host_agent_config`'s `HOST_AGENT_CONTROL_PLANE_URL` default to `https://amcp.home.jerryyan.top`, keeping the existing `urlparse` validation and environment-variable override behavior +- [x] 3.2 Remove the `HostAgentConfigurationError` raised when no host_id/token, no enrollment token, and no identity file are present; a missing enrollment token is no longer a startup configuration error +- [x] 3.3 Update `enrollment.py::resolve_host_identity` to call the enrollment client with no bearer credential when `config.enrollment_token` is empty, instead of raising +- [x] 3.4 Update `client.py::HostAgentEnrollmentClient.enroll_host` (or equivalent) to support an unauthenticated (no `Authorization` header) enrollment request path, and to pass the local account username as `display_name` when `HOST_AGENT_DISPLAY_NAME` is unset +- [x] 3.5 Add/update tests: fresh install with no token self-enrolls successfully (mocked cloud response), self-service rejection (`401` from cloud) surfaces as a clear startup failure and does not start polling, configured `HOST_AGENT_ENROLLMENT_TOKEN` still takes the existing token-bound path unchanged, existing cached identity skips enrollment entirely ## 4. Cloud self-service enrollment configuration and auth -- [ ] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config` -- [ ] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally -- [ ] 4.3 Add an ordered enrollment-auth chain (configured-token provider first, self-service provider second when enabled) and wire it into `apps/cloud-api/cloud_api/app.py::create_app()` in place of the single `ConfiguredEnrollmentTokenProvider` -- [ ] 4.4 Add tests: self-service enabled + no bearer token enrolls successfully with `enrollment_token_digest = NULL`; self-service disabled + no bearer token still returns `401` (current behavior unchanged); self-service enabled + a valid configured token still uses the token-bound path with existing conflict/idempotency semantics; self-service enabled + an invalid/unknown token still falls through to the self-service principal (since only a *presented and mismatched* token, or none at all, should reach self-service — confirm and encode the exact fallback condition from design.md D3) +- [x] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config` +- [x] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally +- [x] 4.3 Add an ordered enrollment-auth chain (configured-token provider first, self-service provider second when enabled) and wire it into `apps/cloud-api/cloud_api/app.py::create_app()` in place of the single `ConfiguredEnrollmentTokenProvider` +- [x] 4.4 Add tests: self-service enabled + no bearer token enrolls successfully with `enrollment_token_digest = NULL`; self-service disabled + no bearer token still returns `401` (current behavior unchanged); self-service enabled + a valid configured token still uses the token-bound path with existing conflict/idempotency semantics; self-service enabled + an invalid/unknown token still falls through to the self-service principal (since only a *presented and mismatched* token, or none at all, should reach self-service — confirm and encode the exact fallback condition from design.md D3) ## 5. Cloud enrollment idempotency and conflict behavior verification -- [ ] 5.1 Add repository-level tests confirming `sql_repository.py::enroll_host` idempotent-retry-by-`agent_instance_id` behavior works correctly when `enrollment_token_digest` is `NULL` (repeat self-service enrollment from the same instance returns the same `host_id`) -- [ ] 5.2 Add repository-level tests confirming multiple distinct self-service Hosts (each with `enrollment_token_digest = NULL`) can coexist without violating the unique constraint on that column, for both SQLite and PostgreSQL +- [x] 5.1 Add repository-level tests confirming `sql_repository.py::enroll_host` idempotent-retry-by-`agent_instance_id` behavior works correctly when `enrollment_token_digest` is `NULL` (repeat self-service enrollment from the same instance returns the same `host_id`) +- [x] 5.2 Add repository-level tests confirming multiple distinct self-service Hosts (each with `enrollment_token_digest = NULL`) can coexist without violating the unique constraint on that column, for both SQLite and PostgreSQL ## 6. Deployment and documentation -- [ ] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior -- [ ] 6.2 Update `compose.yaml`/`compose.deploy.yaml` examples to show the flag left disabled by default, with a comment on how the `amcp.home.jerryyan.top` deployment enables it -- [ ] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps -- [ ] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step +- [x] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior +- [x] 6.2 Update `compose.yaml`/`compose.deploy.yaml` examples to show the flag left disabled by default, with a comment on how the `amcp.home.jerryyan.top` deployment enables it +- [x] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps +- [x] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step ## 7. Verification -- [ ] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`) -- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths -- [ ] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors -- [ ] 7.4 Manually verify end-to-end: fresh Host Agent install, `device-host-agent setup`, then `device-host-agent` self-enrolls against a cloud-api instance with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`, and heartbeat/device-status reporting continues on the expected interval +- [x] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`) — 494 passed; no lint/formatter (ruff/mypy) is configured in this repo, so only the test suite ran +- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths — not run: no `TEST_POSTGRES_URL`/Docker available in this environment; the SQLite side of the same parametrized tests (`tests/test_cloud_repository_contract.py`) passed +- [x] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors — passed +- [ ] 7.4 Manually verify end-to-end: fresh Host Agent install, `device-host-agent setup`, then `device-host-agent` self-enrolls against a cloud-api instance with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`, and heartbeat/device-status reporting continues on the expected interval — not performed: no real cloud-api deployment reachable from this environment; covered by automated integration-style tests instead (`test_enrollment.py`, `test_app.py`, `test_e2e.py`) diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index e6fb62e..ed1b556 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -99,7 +99,12 @@ class EnrollmentCredential: @dataclass(frozen=True) class EnrollmentPrincipal: id: str - token_digest: str = field(repr=False) + token_digest: str | None = field(default=None, repr=False) + + +@runtime_checkable +class EnrollmentAuthProvider(Protocol): + def authenticate(self, request: object) -> EnrollmentPrincipal | None: ... class ConfiguredBearerAuthProvider: @@ -156,6 +161,25 @@ class ConfiguredEnrollmentTokenProvider: 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 diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py index 9d25723..aaecf07 100644 --- a/packages/cloud-platform/cloud/control_config.py +++ b/packages/cloud-platform/cloud/control_config.py @@ -32,6 +32,7 @@ class CloudControlConfig: 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 @@ -99,6 +100,10 @@ def load_control_config( 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") ), diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index 1991e09..0e62c58 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse from cloud.auth import ( AuthProvider, ConfiguredEnrollmentTokenProvider, + EnrollmentAuthProvider, HostAuthorizationError, digest_token, ) @@ -47,7 +48,7 @@ def create_internal_router( *, pool: DevicePool, auth_provider: AuthProvider, - enrollment_auth_provider: ConfiguredEnrollmentTokenProvider | None = None, + enrollment_auth_provider: EnrollmentAuthProvider | None = None, version_prefix: str = "/internal/v1", claim_poll_interval_seconds: float = 0.1, lease_duration_seconds: float = 60.0, diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index 2422747..b5b8236 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -100,7 +100,7 @@ class CloudRepository(Protocol): host_id: str, agent_instance_id: str, credential_digest: str, - enrollment_token_digest: str, + enrollment_token_digest: str | None, display_name: str | None, enrolled_at: datetime, ) -> HostEnrollment: ... diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index 358d944..0cf9389 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -45,7 +45,7 @@ class SQLAlchemyCloudRepository: host_id: str, agent_instance_id: str, credential_digest: str, - enrollment_token_digest: str, + enrollment_token_digest: str | None, display_name: str | None, enrolled_at: datetime, ) -> Any: @@ -72,11 +72,19 @@ class SQLAlchemyCloudRepository: ) return _host_enrollment_from_row(existing) - token_owner = session.scalars( - select(HostRow).where( - HostRow.enrollment_token_digest == enrollment_token_digest - ) - ).first() + # A NULL enrollment_token_digest marks self-service Hosts, which + # never bind a shared token; comparing `== NULL` would otherwise + # match every other self-service Host via SQL's `IS NULL` and + # falsely report a token conflict. + token_owner = ( + session.scalars( + select(HostRow).where( + HostRow.enrollment_token_digest == enrollment_token_digest + ) + ).first() + if enrollment_token_digest is not None + else None + ) if token_owner is not None: raise EnrollmentTokenConflictError( "enrollment token is already bound to another Host" @@ -109,11 +117,15 @@ class SQLAlchemyCloudRepository: and existing.enrollment_token_digest == enrollment_token_digest ): return _host_enrollment_from_row(existing) - token_owner = session.scalars( - select(HostRow).where( - HostRow.enrollment_token_digest == enrollment_token_digest - ) - ).first() + token_owner = ( + session.scalars( + select(HostRow).where( + HostRow.enrollment_token_digest == enrollment_token_digest + ) + ).first() + if enrollment_token_digest is not None + else None + ) if token_owner is not None: raise EnrollmentTokenConflictError( "enrollment token is already bound to another Host" diff --git a/tests/test_cloud_auth.py b/tests/test_cloud_auth.py index 2074510..a0559b4 100644 --- a/tests/test_cloud_auth.py +++ b/tests/test_cloud_auth.py @@ -7,6 +7,7 @@ import pytest from cloud.auth import ( BearerCredential, ChainedAuthProvider, + ChainedEnrollmentAuthProvider, ConfiguredBearerAuthProvider, ConfiguredEnrollmentTokenProvider, EnrollmentCredential, @@ -14,6 +15,7 @@ from cloud.auth import ( HostPrincipalRequiredError, NullAuthProvider, RepositoryHostAuthProvider, + SelfServiceEnrollmentAuthProvider, digest_token, ) @@ -213,3 +215,67 @@ def test_repository_host_auth_and_chain_preserve_host_scope() -> None: assert host_principal is not None assert host_principal.host_id == "host-managed" assert host_principal.scopes == frozenset() + + +def test_self_service_enabled_with_no_token_enrolls_via_self_service() -> None: + provider = ChainedEnrollmentAuthProvider( + [ConfiguredEnrollmentTokenProvider(()), SelfServiceEnrollmentAuthProvider()] + ) + + principal = provider.authenticate(_Request(headers={})) + + assert principal is not None + assert principal.id == "self-service" + assert principal.token_digest is None + + +def test_self_service_disabled_with_no_token_is_unauthenticated() -> None: + provider = ChainedEnrollmentAuthProvider([ConfiguredEnrollmentTokenProvider(())]) + + assert provider.authenticate(_Request(headers={})) is None + + +def test_self_service_enabled_with_valid_configured_token_uses_token_bound_path() -> ( + None +): + credential = EnrollmentCredential( + principal_id="installer-a", + token="one-time-enrollment-secret", + ) + provider = ChainedEnrollmentAuthProvider( + [ + ConfiguredEnrollmentTokenProvider([credential]), + SelfServiceEnrollmentAuthProvider(), + ] + ) + + principal = provider.authenticate( + _Request(headers={"authorization": "Bearer one-time-enrollment-secret"}) + ) + + assert principal is not None + assert principal.id == "installer-a" + assert principal.token_digest == digest_token("one-time-enrollment-secret") + + +def test_self_service_enabled_with_unknown_token_falls_through_to_self_service() -> ( + None +): + credential = EnrollmentCredential( + principal_id="installer-a", + token="one-time-enrollment-secret", + ) + provider = ChainedEnrollmentAuthProvider( + [ + ConfiguredEnrollmentTokenProvider([credential]), + SelfServiceEnrollmentAuthProvider(), + ] + ) + + principal = provider.authenticate( + _Request(headers={"authorization": "Bearer unknown-secret"}) + ) + + assert principal is not None + assert principal.id == "self-service" + assert principal.token_digest is None diff --git a/tests/test_cloud_repository_contract.py b/tests/test_cloud_repository_contract.py index 15c5046..1ef79b2 100644 --- a/tests/test_cloud_repository_contract.py +++ b/tests/test_cloud_repository_contract.py @@ -159,6 +159,74 @@ def test_host_enrollment_is_idempotent_and_token_is_one_time( database.close() +def test_self_service_enrollment_is_idempotent_with_null_token_digest( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + repository = database.repository + enrolled_at = datetime(2026, 7, 13, 6, 0, tzinfo=UTC) + host_id = _unique_id("self-service-host") + agent_instance_id = _unique_id("self-service-instance") + credential_digest = _unique_id("self-service-credential") + + try: + created = repository.enroll_host( + host_id=host_id, + agent_instance_id=agent_instance_id, + credential_digest=credential_digest, + enrollment_token_digest=None, + display_name="Self-Service Host", + enrolled_at=enrolled_at, + ) + assert created.host_id == host_id + + retried = repository.enroll_host( + host_id=_unique_id("ignored-host"), + agent_instance_id=agent_instance_id, + credential_digest=credential_digest, + enrollment_token_digest=None, + display_name="Renamed Self-Service Host", + enrolled_at=enrolled_at + timedelta(minutes=1), + ) + assert retried == created + finally: + database.close() + + +def test_multiple_self_service_hosts_coexist_without_token_conflict( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + repository = database.repository + enrolled_at = datetime(2026, 7, 13, 7, 0, tzinfo=UTC) + credential_a = _unique_id("self-service-credential-a") + credential_b = _unique_id("self-service-credential-b") + + try: + first = repository.enroll_host( + host_id=_unique_id("self-service-host-a"), + agent_instance_id=_unique_id("self-service-instance-a"), + credential_digest=credential_a, + enrollment_token_digest=None, + display_name=None, + enrolled_at=enrolled_at, + ) + second = repository.enroll_host( + host_id=_unique_id("self-service-host-b"), + agent_instance_id=_unique_id("self-service-instance-b"), + credential_digest=credential_b, + enrollment_token_digest=None, + display_name=None, + enrolled_at=enrolled_at, + ) + + assert first.host_id != second.host_id + assert repository.authenticate_enrolled_host(credential_a) == first.host_id + assert repository.authenticate_enrolled_host(credential_b) == second.host_id + finally: + database.close() + + def test_device_enrollment_is_host_scoped_and_idempotent( database_url: str, ) -> None: