diff --git a/solstone/apps/backup/routes.py b/solstone/apps/backup/routes.py index abece61b7..f46410f15 100644 --- a/solstone/apps/backup/routes.py +++ b/solstone/apps/backup/routes.py @@ -389,7 +389,7 @@ def _restore_hosted_thunk( if binding is None: return OpOutcome("error", "failed") try: - creds = fetch_hosted_credentials(binding, scope="maintenance") + creds = fetch_hosted_credentials(binding, scope="operated") except HostedCredsUnavailable as exc: return OpOutcome("error", exc.reason_code) diff --git a/solstone/apps/backup/tests/test_routes.py b/solstone/apps/backup/tests/test_routes.py index 54aedcb91..fe3d841ac 100644 --- a/solstone/apps/backup/tests/test_routes.py +++ b/solstone/apps/backup/tests/test_routes.py @@ -551,7 +551,7 @@ def test_restore_hosted_approved_works_without_local_keys( assert response.get_json()["operation"]["portal_url"] == CONSENT_URL assert final["reason_code"] is None save_hosted_binding.assert_called_once_with(binding) - fetch_hosted_credentials.assert_called_once_with(binding, scope="maintenance") + fetch_hosted_credentials.assert_called_once_with(binding, scope="operated") restore_journal_operated.assert_called_once_with(binding, _creds(), "A" * 64) diff --git a/solstone/think/backup/engine.py b/solstone/think/backup/engine.py index 1b195dc73..ce3ba37df 100644 --- a/solstone/think/backup/engine.py +++ b/solstone/think/backup/engine.py @@ -127,7 +127,7 @@ def _resolve_runtime(scope: str) -> _Runtime | None: binding = load_hosted_binding() if binding is None: return None - credential_scope = "maintenance" if scope == "backup" else scope + credential_scope = "operated" if scope == "backup" else scope creds = fetch_hosted_credentials(binding, scope=credential_scope) destination = operated_destination(binding, creds) else: @@ -205,7 +205,6 @@ def _runtime_backend( return with hosted_restic_session( runtime.binding, - scope=scope, initial_credentials=runtime.hosted_credentials, ) as session: yield session diff --git a/solstone/think/backup/hosted_provider.py b/solstone/think/backup/hosted_provider.py index f9a1d3dd4..1e114fc22 100644 --- a/solstone/think/backup/hosted_provider.py +++ b/solstone/think/backup/hosted_provider.py @@ -1,30 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Authenticated loopback credential provider for long operated restic runs.""" +"""Operation-scoped credential sessions for hosted restic runs.""" from __future__ import annotations -import json -import secrets -import threading from collections.abc import Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from solstone.think.backup.destination import Destination +from solstone.think.backup.destination import Destination, assemble_backend_env from solstone.think.backup.hosted import ( HostedBinding, HostedCredentials, - HostedCredsUnavailable, - fetch_hosted_credentials, - operated_repository, + operated_destination, ) -_CREDENTIAL_PATH = "/credentials" - @dataclass(frozen=True) class HostedResticSession: @@ -33,131 +25,17 @@ class HostedResticSession: global_options: tuple[str, ...] = () -class _CredentialState: - def __init__( - self, - binding: HostedBinding, - scope: str, - initial_credentials: HostedCredentials, - ) -> None: - self.binding = binding - self.scope = scope - self._initial_credentials: HostedCredentials | None = initial_credentials - self._lock = threading.Lock() - - def next_credentials(self) -> HostedCredentials: - with self._lock: - if self._initial_credentials is not None: - credentials = self._initial_credentials - self._initial_credentials = None - return credentials - return fetch_hosted_credentials(self.binding, scope=self.scope) - - -class _CredentialServer(ThreadingHTTPServer): - daemon_threads = True - - def __init__( - self, - state: _CredentialState, - authorization_token: str, - ) -> None: - self.state = state - self.authorization_token = authorization_token - super().__init__(("127.0.0.1", 0), _CredentialHandler) - - -class _CredentialHandler(BaseHTTPRequestHandler): - server: _CredentialServer - - def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract - if self.path != _CREDENTIAL_PATH: - self.send_error(404) - return - if self.headers.get("Authorization") != self.server.authorization_token: - self.send_error(401) - return - - try: - credentials = self.server.state.next_credentials() - except HostedCredsUnavailable: - self.send_error(503) - return - - body = json.dumps( - { - "AccessKeyId": credentials.access_key_id, - "SecretAccessKey": credentials.secret_access_key, - "Token": credentials.session_token, - "Expiration": credentials.expires_at, - }, - separators=(",", ":"), - ).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, _format: str, *_args: object) -> None: - return - - -@contextmanager -def _credential_provider( - binding: HostedBinding, - *, - scope: str, - initial_credentials: HostedCredentials | None = None, -) -> Iterator[tuple[HostedCredentials, dict[str, str]]]: - credentials = initial_credentials or fetch_hosted_credentials(binding, scope=scope) - authorization_token = secrets.token_urlsafe(32) - state = _CredentialState(binding, scope, credentials) - server = _CredentialServer(state, authorization_token) - thread = threading.Thread( - target=server.serve_forever, - kwargs={"poll_interval": 0.05}, - name="spb-credential-provider", - daemon=True, - ) - thread.start() - host, port = server.server_address - try: - yield ( - credentials, - { - "AWS_CONTAINER_CREDENTIALS_FULL_URI": ( - f"http://{host}:{port}{_CREDENTIAL_PATH}" - ), - "AWS_CONTAINER_AUTHORIZATION_TOKEN": authorization_token, - }, - ) - finally: - server.shutdown() - server.server_close() - thread.join() - - @contextmanager def hosted_restic_session( binding: HostedBinding, *, - scope: str, - initial_credentials: HostedCredentials | None = None, + initial_credentials: HostedCredentials, ) -> Iterator[HostedResticSession]: - with _credential_provider( - binding, - scope=scope, - initial_credentials=initial_credentials, - ) as (credentials, provider_env): - yield HostedResticSession( - destination=Destination( - repository=operated_repository(binding, credentials), - backend="s3", - credentials={}, - ), - backend_env=provider_env, - ) + destination = operated_destination(binding, initial_credentials) + yield HostedResticSession( + destination=destination, + backend_env=assemble_backend_env(destination), + ) @contextmanager @@ -165,37 +43,34 @@ def hosted_append_only_restic_session( binding: HostedBinding, *, rclone_path: Path, - initial_credentials: HostedCredentials | None = None, + initial_credentials: HostedCredentials, ) -> Iterator[HostedResticSession]: """Serve an operated repo through rclone's lock-aware append-only adapter.""" - with _credential_provider( - binding, - scope="maintenance", - initial_credentials=initial_credentials, - ) as (credentials, provider_env): - backend_env = { - **provider_env, - "RCLONE_CONFIG_SPB_TYPE": "s3", - "RCLONE_CONFIG_SPB_PROVIDER": "Cloudflare", - "RCLONE_CONFIG_SPB_ENV_AUTH": "true", - "RCLONE_CONFIG_SPB_ENDPOINT": credentials.endpoint, - "RCLONE_CONFIG_SPB_REGION": "auto", - "RCLONE_CONFIG_SPB_NO_CHECK_BUCKET": "true", - } - yield HostedResticSession( - destination=Destination( - repository=f"rclone:spb:{binding.bucket}/{binding.prefix}", - backend="rclone", - credentials={}, - ), - backend_env=backend_env, - global_options=( - "-o", - f"rclone.program={rclone_path}", - "-o", - "rclone.args=serve restic --stdio --append-only --config /dev/null", - ), - ) + backend_env = { + "RCLONE_CONFIG_SPB_TYPE": "s3", + "RCLONE_CONFIG_SPB_PROVIDER": "Cloudflare", + "RCLONE_CONFIG_SPB_ENV_AUTH": "false", + "RCLONE_CONFIG_SPB_ACCESS_KEY_ID": initial_credentials.access_key_id, + "RCLONE_CONFIG_SPB_SECRET_ACCESS_KEY": initial_credentials.secret_access_key, + "RCLONE_CONFIG_SPB_SESSION_TOKEN": initial_credentials.session_token, + "RCLONE_CONFIG_SPB_ENDPOINT": initial_credentials.endpoint, + "RCLONE_CONFIG_SPB_REGION": "auto", + "RCLONE_CONFIG_SPB_NO_CHECK_BUCKET": "true", + } + yield HostedResticSession( + destination=Destination( + repository=f"rclone:spb:{binding.bucket}/{binding.prefix}", + backend="rclone", + credentials={}, + ), + backend_env=backend_env, + global_options=( + "-o", + f"rclone.program={rclone_path}", + "-o", + "rclone.args=serve restic --stdio --append-only --config /dev/null", + ), + ) __all__ = [ diff --git a/tests/test_backup_engine.py b/tests/test_backup_engine.py index 9a6aa282e..61fac0432 100644 --- a/tests/test_backup_engine.py +++ b/tests/test_backup_engine.py @@ -753,12 +753,6 @@ def test_operated_backup_fetches_creds_and_builds_repo( backup_args = backup_call[0] backup_kwargs = backup_call[1] backend_env = backup_kwargs["backend_env"] - assert backend_env["AWS_CONTAINER_CREDENTIALS_FULL_URI"].startswith( - "http://127.0.0.1:" - ) - assert backend_env["AWS_CONTAINER_AUTHORIZATION_TOKEN"] - for secret in ("AKID", "SAK", "SESS"): - assert secret not in json.dumps(backend_env) assert backup_kwargs["repository"] == "rclone:spb:bkt/users/acct/inst" assert backup_args[:4] == [ "-o", @@ -769,10 +763,15 @@ def test_operated_backup_fetches_creds_and_builds_repo( assert backup_args[4] == "backup" assert backend_env["RCLONE_CONFIG_SPB_TYPE"] == "s3" assert backend_env["RCLONE_CONFIG_SPB_PROVIDER"] == "Cloudflare" - assert backend_env["RCLONE_CONFIG_SPB_ENV_AUTH"] == "true" + assert backend_env["RCLONE_CONFIG_SPB_ENV_AUTH"] == "false" + assert backend_env["RCLONE_CONFIG_SPB_ACCESS_KEY_ID"] == "AKID" + assert backend_env["RCLONE_CONFIG_SPB_SECRET_ACCESS_KEY"] == "SAK" + assert backend_env["RCLONE_CONFIG_SPB_SESSION_TOKEN"] == "SESS" + assert "AWS_CONTAINER_CREDENTIALS_FULL_URI" not in backend_env + assert "AWS_CONTAINER_AUTHORIZATION_TOKEN" not in backend_env for secret in ("AKID", "SAK", "SESS"): assert secret not in backup_kwargs["repository"] - assert captured["scope"] == "maintenance" + assert captured["scope"] == "operated" def test_operated_prune_requests_maintenance_scope( @@ -831,9 +830,11 @@ def test_operated_prune_requests_maintenance_scope( assert result.status == "ok" forget_call = next(call for call in calls if call[0][0] == "forget") assert captured["scope"] == "maintenance" - assert forget_call[1]["backend_env"][ - "AWS_CONTAINER_CREDENTIALS_FULL_URI" - ].startswith("http://127.0.0.1:") + assert forget_call[1]["backend_env"] == { + "AWS_ACCESS_KEY_ID": "AKID", + "AWS_SECRET_ACCESS_KEY": "SAK", + "AWS_SESSION_TOKEN": "SESS", + } def test_backup_timeout_is_long_only_until_first_snapshot( @@ -1018,7 +1019,7 @@ def test_operated_does_not_persist_or_log_secrets( *, scope: str, ) -> HostedCredentials: - assert scope in {"backup", "maintenance"} + assert scope in {"operated", "maintenance"} return HostedCredentials( access_key_id="AKID-SECRET", secret_access_key="SAK-SECRET", diff --git a/tests/test_backup_hosted.py b/tests/test_backup_hosted.py index c19e7ccd1..131c42b50 100644 --- a/tests/test_backup_hosted.py +++ b/tests/test_backup_hosted.py @@ -11,7 +11,6 @@ import urllib.error import urllib.request from pathlib import Path from typing import Any -from unittest.mock import Mock import pytest @@ -307,76 +306,27 @@ def test_broker_token_not_logged_on_degrade( assert "the-token" not in caplog.text -def test_hosted_restic_session_serves_initial_then_renewed_credentials( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_hosted_restic_session_uses_one_operation_scoped_credential() -> None: initial = _credentials() - renewed = HostedCredentials( - access_key_id="AKID-2", - secret_access_key="SAK-2", - session_token="SESS-2", - endpoint="https://acct.r2.cloudflarestorage.com/", - expires_at="2026-07-13T13:00:00Z", - ) - fetch = Mock(return_value=renewed) - monkeypatch.setattr( - "solstone.think.backup.hosted_provider.fetch_hosted_credentials", - fetch, - ) with hosted_restic_session( _binding(prefix="users/acct/inst/"), - scope="backup", initial_credentials=initial, ) as session: - uri = session.backend_env["AWS_CONTAINER_CREDENTIALS_FULL_URI"] - token = session.backend_env["AWS_CONTAINER_AUTHORIZATION_TOKEN"] - assert uri.startswith("http://127.0.0.1:") - assert session.destination.credentials == {} - - with pytest.raises(urllib.error.HTTPError) as exc_info: - urllib.request.urlopen(uri, timeout=1) - assert exc_info.value.code == 401 - - first_request = urllib.request.Request( - uri, - headers={"Authorization": token}, + assert session.destination.repository == ( + "s3:https://acct.r2.cloudflarestorage.com/bkt/users/acct/inst/" ) - with urllib.request.urlopen(first_request, timeout=1) as response: - first = json.loads(response.read()) - assert first == { - "AccessKeyId": "AKID", - "SecretAccessKey": "SAK", - "Token": "SESS", - "Expiration": "2026-07-13T12:00:00Z", + assert session.backend_env == { + "AWS_ACCESS_KEY_ID": "AKID", + "AWS_SECRET_ACCESS_KEY": "SAK", + "AWS_SESSION_TOKEN": "SESS", } - fetch.assert_not_called() - - with urllib.request.urlopen(first_request, timeout=1) as response: - second = json.loads(response.read()) - assert second["AccessKeyId"] == "AKID-2" - assert second["Expiration"] == "2026-07-13T13:00:00Z" - fetch.assert_called_once_with( - _binding(prefix="users/acct/inst/"), scope="backup" - ) + assert "AWS_CONTAINER_CREDENTIALS_FULL_URI" not in session.backend_env + assert "AWS_CONTAINER_AUTHORIZATION_TOKEN" not in session.backend_env -def test_append_only_session_uses_maintenance_creds_without_static_secrets( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_append_only_session_uses_one_operation_scoped_credential() -> None: initial = _credentials() - renewed = HostedCredentials( - access_key_id="AKID-2", - secret_access_key="SAK-2", - session_token="SESS-2", - endpoint="https://acct.r2.cloudflarestorage.com/", - expires_at="2026-07-13T13:00:00Z", - ) - fetch = Mock(return_value=renewed) - monkeypatch.setattr( - "solstone.think.backup.hosted_provider.fetch_hosted_credentials", - fetch, - ) binding = _binding(prefix="users/acct/inst/") with hosted_append_only_restic_session( @@ -394,21 +344,16 @@ def test_append_only_session_uses_maintenance_creds_without_static_secrets( ) assert session.backend_env["RCLONE_CONFIG_SPB_TYPE"] == "s3" assert session.backend_env["RCLONE_CONFIG_SPB_PROVIDER"] == "Cloudflare" - assert session.backend_env["RCLONE_CONFIG_SPB_ENV_AUTH"] == "true" + assert session.backend_env["RCLONE_CONFIG_SPB_ENV_AUTH"] == "false" + assert session.backend_env["RCLONE_CONFIG_SPB_ACCESS_KEY_ID"] == "AKID" + assert session.backend_env["RCLONE_CONFIG_SPB_SECRET_ACCESS_KEY"] == "SAK" + assert session.backend_env["RCLONE_CONFIG_SPB_SESSION_TOKEN"] == "SESS" assert session.backend_env["RCLONE_CONFIG_SPB_ENDPOINT"] == initial.endpoint for name in ( "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", ): assert name not in session.backend_env - - uri = session.backend_env["AWS_CONTAINER_CREDENTIALS_FULL_URI"] - token = session.backend_env["AWS_CONTAINER_AUTHORIZATION_TOKEN"] - request = urllib.request.Request(uri, headers={"Authorization": token}) - with urllib.request.urlopen(request, timeout=1) as response: - assert json.loads(response.read())["AccessKeyId"] == "AKID" - with urllib.request.urlopen(request, timeout=1) as response: - assert json.loads(response.read())["AccessKeyId"] == "AKID-2" - - fetch.assert_called_once_with(binding, scope="maintenance") diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 551ed3d0b..d6ef42874 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -528,8 +528,14 @@ def test_restore_operated_success_persists_mode_and_key_without_destination( "set_recovery_key_confirmed", "scan_journal", ] - assert calls[0][1]["backend_env"]["AWS_CONTAINER_CREDENTIALS_FULL_URI"].startswith( - "http://127.0.0.1:" + assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_ACCESS_KEY_ID"] == ( + "AKID-OPERATED" + ) + assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_SECRET_ACCESS_KEY"] == ( + "SAK-OPERATED" + ) + assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_SESSION_TOKEN"] == ( + "SESSION-OPERATED" ) assert calls[0][0][:4] == [ "-o", @@ -538,7 +544,7 @@ def test_restore_operated_success_persists_mode_and_key_without_destination( "rclone.args=serve restic --stdio --append-only --config /dev/null", ] assert calls[0][1]["repository"] == ("rclone:spb:journal-backups/users/acct/inst/") - assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_ENV_AUTH"] == "true" + assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_ENV_AUTH"] == "false" config = _read_config(tmp_path) serialized = json.dumps(config) assert config["backup"]["mode"] == "operated"