diff --git a/docs/api.md b/docs/api.md index 431fc30..5df3c7b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,3 +19,19 @@ Every endpoint except `GET /healthz` requires `POST|DELETE /http-proxies/{name}/hosts/{host_id}` - `GET /doctor` — read-only dependency diagnostics - `GET /healthz` — unauthenticated liveness probe + +## The secrets exchange + +The exchange is a second process, `python -m secrets_exchange`, on a private +port with no service token. Only the proxy and an issuer inside the deployment +reach it. See [Architecture](architecture.md) for the flow. + +- `GET /upstreams` — the hosts the proxy terminates TLS for +- `GET /authorize` — the proxy's question: the header and the real credential + for a placeholder +- `POST /refresh/{host_id}/{service}` — an issuer's order: forget the held + value and fetch a new one now. `200` after the fetch, and after the push + where the provider holds the value. `503` with `Retry-After` when the issuer + gave nothing usable. `404` for an unknown host or service, `409` for a + static entry. The order carries no body. +- `GET /healthz` — liveness probe diff --git a/docs/architecture.md b/docs/architecture.md index d7c0adb..b312537 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,6 +129,13 @@ fetches a fresh value when less than a minute of the pushed one remains and hands it to `push_secret`. A push that fails waits like a fetch that fails. A host that is gone is forgotten on the next pass. +An issuer can end a value before its expiry, as an OAuth provider does when +it revokes the previous token at a refresh. The issuer then orders a refresh +at `POST /refresh//`. The exchange forgets the held value, +fetches now, and on a provider that holds the value pushes at once. It answers +`200` after that, and `503` with `Retry-After` when the issuer gave nothing +usable. The order carries no value: the exchange only asks the issuer again. + Provisioning mints a placeholder per secret. The placeholder names the host and the service, `drk...`. The entry keeps only a fingerprint of the random part. The sandbox receives the auth variable with diff --git a/docs/security.md b/docs/security.md index e028c42..87f7010 100644 --- a/docs/security.md +++ b/docs/security.md @@ -106,7 +106,11 @@ API through it. It logs no credential. The real value is encrypted in the database. It passes through the exchange and the proxy for one request, and the exchange keeps an issuer's value in -memory. On docker-sbx it lives in sbx's own store, scoped to that sandbox, and +memory. An issuer that ends a value before its expiry orders a refresh with +`POST /refresh//` on the exchange's private port. The order +carries no value and no token. It makes the exchange ask the issuer again, so +a stray order costs one fetch and nothing else. On docker-sbx the value lives +in sbx's own store, scoped to that sandbox, and drukbox runs no proxy there. Host deletion removes the sandbox's secrets and value files before the VM goes. The lease in `expires_at` schedules that deletion and does not revoke the credential. Revoke it at its source when a diff --git a/src/secrets_exchange/app.py b/src/secrets_exchange/app.py index 3c9ac9b..b4e650d 100644 --- a/src/secrets_exchange/app.py +++ b/src/secrets_exchange/app.py @@ -1,5 +1,6 @@ import asyncio import logging +import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import timedelta @@ -17,7 +18,6 @@ from host_secrets.placeholder import Placeholder from hosts.models import Host, HostStatus from providers.exceptions import ProviderError -from providers.registry import get_vm_provider from secrets_exchange.secrets import IssuerUnavailableError, Secrets logger = logging.getLogger(__name__) @@ -65,15 +65,11 @@ async def push_active_hosts(secrets: Secrets) -> None: async def push_to_host(secrets: Secrets, host: Host) -> None: try: - injection = get_vm_provider(host.provider).secrets - entries = dict(host.secrets) + for service, entry in host.secrets.items(): + if "issuer" in entry: + await secrets.push(host, service, entry) except (ProviderError, SecretDecryptError) as exc: logger.error("push for host %s failed: %s", host.name, exc) - return - if injection.needs_value: - for service, entry in entries.items(): - if "issuer" in entry: - await secrets.push(host.id, host.name, service, entry, injection) app = FastAPI(title="Drukbox secrets exchange", lifespan=lifespan) @@ -137,7 +133,7 @@ async def authorize( upstream = upstreams[x_forwarded_host] try: - secret = await secrets.current(host.id, placeholder.service, entry) + secret = await secrets.current(host, placeholder.service, entry) except IssuerUnavailableError: return Response( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, headers={"Retry-After": "5"} @@ -151,3 +147,30 @@ async def authorize( UPSTREAM_CREDENTIAL: upstream.credential(secret.value), }, ) + + +@app.post("/refresh/{host_id}/{service}") +async def refresh( + host_id: uuid.UUID, + service: str, + session: Annotated[AsyncSession, Depends(get_session)], + secrets: Annotated[Secrets, Depends(get_secrets)], +) -> Response: + """The issuer ended the held value early. No request carries it again, + and a provider that holds the value gets the new one at once.""" + host = await session.get(Host, host_id) + if not host or service not in host.secrets: + raise HTTPException(status.HTTP_404_NOT_FOUND) + entry = host.secrets[service] + if "value" in entry: + raise HTTPException(status.HTTP_409_CONFLICT) + + try: + await secrets.refresh(host, service, entry) + except (IssuerUnavailableError, ProviderError) as exc: + logger.warning("refresh of %s for %s failed: %s", service, host.name, exc) + return Response( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, headers={"Retry-After": "5"} + ) + logger.info("refreshed %s for %s", service, host.name) + return Response(status_code=status.HTTP_200_OK) diff --git a/src/secrets_exchange/secrets.py b/src/secrets_exchange/secrets.py index da4e9a7..d869c93 100644 --- a/src/secrets_exchange/secrets.py +++ b/src/secrets_exchange/secrets.py @@ -10,8 +10,9 @@ import httpx from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError -from providers.capabilities import SecretInjectionCapability +from hosts.models import Host from providers.exceptions import ProviderError +from providers.registry import get_vm_provider logger = logging.getLogger(__name__) @@ -134,7 +135,8 @@ def refresh_in_background(self, issuer: dict[str, Any], client: httpx.AsyncClien class Secrets: """The current secret per entry. A fetched value is kept in memory, served stale while a refresh runs or fails, and never written back. A provider - that holds the value never asks, so ``push`` hands it a fresh one.""" + that holds the value never asks, so ``push`` hands it a fresh one. An + issuer that ends a value early orders ``refresh``.""" def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -145,10 +147,10 @@ def forget_deleted_hosts(self, existing: set[uuid.UUID]) -> None: key: secret for key, secret in self._refreshable.items() if key[0] in existing } - async def current(self, host_id: uuid.UUID, service: str, entry: dict[str, Any]) -> Secret: + async def current(self, host: Host, service: str, entry: dict[str, Any]) -> Secret: if "value" in entry: return Secret(value=entry["value"]) - refreshable = self._refreshable.setdefault((host_id, service), RefreshableSecret()) + refreshable = self._refreshable.setdefault((host.id, service), RefreshableSecret()) now = datetime.now(UTC) if refreshable.latest and refreshable.latest.is_valid(now): if refreshable.latest.is_stale(now): @@ -157,28 +159,44 @@ async def current(self, host_id: uuid.UUID, service: str, entry: dict[str, Any]) await refreshable.refresh(entry["issuer"], self._client) if refreshable.latest and refreshable.latest.is_valid(datetime.now(UTC)): return refreshable.latest - raise IssuerUnavailableError(f"no valid secret for {host_id}/{service}") + raise IssuerUnavailableError(f"no valid secret for {host.id}/{service}") - async def push( - self, - host_id: uuid.UUID, - vm: str, - service: str, - entry: dict[str, Any], - injection: SecretInjectionCapability, - ) -> None: + async def push(self, host: Host, service: str, entry: dict[str, Any]) -> None: """The first push comes at first sight, since the boot value came from - the API process.""" - refreshable = self._refreshable.setdefault((host_id, service), RefreshableSecret()) - if refreshable.is_due(datetime.now(UTC)): + the API process. A provider that holds no value is never visited.""" + provider = get_vm_provider(host.provider) + refreshable = self._refreshable.setdefault((host.id, service), RefreshableSecret()) + if provider.secrets.needs_value and refreshable.is_due(datetime.now(UTC)): try: pushed = await refreshable.push( entry["issuer"], self._client, - lambda value: injection.push_secret(vm=vm, name=service, value=value), + lambda value: provider.secrets.push_secret( + vm=host.name, name=service, value=value + ), ) except ProviderError as exc: - logger.warning("push of %s to %s failed: %s", service, vm, exc) + logger.warning("push of %s to %s failed: %s", service, host.name, exc) else: if pushed: - logger.info("pushed %s to %s", service, vm) + logger.info("pushed %s to %s", service, host.name) + + async def refresh(self, host: Host, service: str, entry: dict[str, Any]) -> None: + """Forget the held value, fetch now, and hand the new one to a provider + that holds the value. Raises :class:`IssuerUnavailableError` when + nothing valid came back.""" + provider = get_vm_provider(host.provider) + refreshable = self._refreshable[(host.id, service)] = RefreshableSecret() + if provider.secrets.needs_value: + pushed = await refreshable.push( + entry["issuer"], + self._client, + lambda value: provider.secrets.push_secret(vm=host.name, name=service, value=value), + ) + if pushed: + return + else: + await refreshable.refresh(entry["issuer"], self._client) + if refreshable.latest: + return + raise IssuerUnavailableError(f"no valid secret for {host.id}/{service}") diff --git a/src/secrets_exchange/tests/test_app.py b/src/secrets_exchange/tests/test_app.py index 8d303b9..67af0c9 100644 --- a/src/secrets_exchange/tests/test_app.py +++ b/src/secrets_exchange/tests/test_app.py @@ -15,6 +15,7 @@ from hosts.models import Host, HostStatus from hosts.service import utc_now from hosts.tests.conftest import stub_provider # noqa: F401 +from providers.exceptions import ProviderTransportError from providers.registry import get_vm_provider from secrets_exchange.app import UPSTREAM_CREDENTIAL, UPSTREAM_HEADER, app, push_active_hosts from secrets_exchange.secrets import Secrets @@ -244,9 +245,9 @@ async def _create_host( @respx.mock @pytest.mark.usefixtures("stub_provider") async def test_the_timer_pushes_issuer_values_to_a_provider_that_holds_them(edge) -> None: - injection = MagicMock(needs_value=True) - injection.push_secret = AsyncMock() - get_vm_provider("stub").secrets = injection + secrets = MagicMock(needs_value=True) + secrets.push_secret = AsyncMock() + get_vm_provider("stub").secrets = secrets host_id = uuid.uuid4() await _create_host( host_id, @@ -260,7 +261,7 @@ async def test_the_timer_pushes_issuer_values_to_a_provider_that_holds_them(edge await push_active_hosts(app.state.secrets) - injection.push_secret.assert_awaited_once_with( + secrets.push_secret.assert_awaited_once_with( vm=f"sb-{host_id.hex[:12]}", name="anthropic", value="sk-ant-fresh" ) @@ -268,8 +269,8 @@ async def test_the_timer_pushes_issuer_values_to_a_provider_that_holds_them(edge @respx.mock @pytest.mark.usefixtures("stub_provider") async def test_the_timer_forgets_a_deleted_host(edge) -> None: - injection = MagicMock(needs_value=True, push_secret=AsyncMock()) - get_vm_provider("stub").secrets = injection + secrets = MagicMock(needs_value=True, push_secret=AsyncMock()) + get_vm_provider("stub").secrets = secrets host_id = uuid.uuid4() await _create_host( host_id, {"anthropic": {"issuer": ISSUER, "placeholder_fingerprint": "a"}}, provider="stub" @@ -284,15 +285,15 @@ async def test_the_timer_forgets_a_deleted_host(edge) -> None: await push_active_hosts(app.state.secrets) assert (host_id, "anthropic") not in app.state.secrets._refreshable - injection.push_secret.assert_awaited_once() + secrets.push_secret.assert_awaited_once() @respx.mock @pytest.mark.usefixtures("stub_provider") async def test_one_host_in_trouble_costs_no_other_host_its_value(edge, caplog) -> None: - injection = MagicMock(needs_value=True) - injection.push_secret = AsyncMock() - get_vm_provider("stub").secrets = injection + secrets = MagicMock(needs_value=True) + secrets.push_secret = AsyncMock() + get_vm_provider("stub").secrets = secrets troubled, healthy = uuid.uuid4(), uuid.uuid4() entry = {"issuer": ISSUER, "placeholder_fingerprint": "a"} await _create_host(troubled, {"anthropic": entry}, provider="gone") @@ -302,7 +303,116 @@ async def test_one_host_in_trouble_costs_no_other_host_its_value(edge, caplog) - with caplog.at_level(logging.ERROR): await push_active_hosts(app.state.secrets) - injection.push_secret.assert_awaited_once_with( + secrets.push_secret.assert_awaited_once_with( vm=f"sb-{healthy.hex[:12]}", name="anthropic", value="sk-ant-fresh" ) assert f"sb-{troubled.hex[:12]}" in caplog.text + + +@respx.mock +@pytest.mark.usefixtures("stub_provider") +async def test_a_refresh_order_replaces_the_held_value_at_once(edge) -> None: + get_vm_provider("stub").secrets = MagicMock(needs_value=False) + host_id = uuid.uuid4() + minted = Placeholder.mint(host_id, "github") + await _create_host( + host_id, + {"github": {"issuer": ISSUER, "placeholder_fingerprint": minted.fingerprint}}, + provider="stub", + ) + headers = _headers(str(minted), "api.github.com") + route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one"}) + assert (await edge.get("/authorize", headers=headers)).headers[UPSTREAM_CREDENTIAL] == ( + "Bearer ghs_one" + ) + route.respond(json={"value": "ghs_two"}) + + response = await edge.post(f"/refresh/{host_id}/github") + + assert response.status_code == 200 + assert (await edge.get("/authorize", headers=headers)).headers[UPSTREAM_CREDENTIAL] == ( + "Bearer ghs_two" + ) + assert route.call_count == 2 + + +@respx.mock +@pytest.mark.usefixtures("stub_provider") +async def test_a_refresh_order_pushes_the_new_value_to_a_provider_that_holds_it(edge) -> None: + secrets = MagicMock(needs_value=True, push_secret=AsyncMock()) + get_vm_provider("stub").secrets = secrets + host_id = uuid.uuid4() + await _create_host( + host_id, {"anthropic": {"issuer": ISSUER, "placeholder_fingerprint": "a"}}, provider="stub" + ) + route = respx.get(ISSUER["url"]).respond(json={"value": "sk-ant-one"}) + await push_active_hosts(app.state.secrets) + route.respond(json={"value": "sk-ant-two"}) + + response = await edge.post(f"/refresh/{host_id}/anthropic") + + assert response.status_code == 200 + assert [call.kwargs["value"] for call in secrets.push_secret.await_args_list] == [ + "sk-ant-one", + "sk-ant-two", + ] + await push_active_hosts(app.state.secrets) + assert secrets.push_secret.await_count == 2, "the timer finds the pushed value fresh" + + +@respx.mock +@pytest.mark.usefixtures("stub_provider") +async def test_a_refresh_order_that_gets_nothing_usable_answers_503_and_waits(edge) -> None: + get_vm_provider("stub").secrets = MagicMock(needs_value=False) + host_id = uuid.uuid4() + minted = Placeholder.mint(host_id, "github") + await _create_host( + host_id, + {"github": {"issuer": ISSUER, "placeholder_fingerprint": minted.fingerprint}}, + provider="stub", + ) + route = respx.get(ISSUER["url"]).respond(status_code=502) + + response = await edge.post(f"/refresh/{host_id}/github") + + assert response.status_code == 503 + assert response.headers["Retry-After"] == "5" + route.respond(json={"value": "ghs_fine"}) + authorized = await edge.get("/authorize", headers=_headers(str(minted), "api.github.com")) + assert authorized.status_code == 503, "the next request waits out the retry delay" + assert route.call_count == 1 + + +@respx.mock +@pytest.mark.usefixtures("stub_provider") +async def test_a_refresh_order_whose_push_fails_answers_503_without_the_value(edge, caplog) -> None: + secrets = MagicMock(needs_value=True) + secrets.push_secret = AsyncMock(side_effect=ProviderTransportError("sbx is down")) + get_vm_provider("stub").secrets = secrets + host_id = uuid.uuid4() + await _create_host( + host_id, {"anthropic": {"issuer": ISSUER, "placeholder_fingerprint": "a"}}, provider="stub" + ) + respx.get(ISSUER["url"]).respond(json={"value": "sk-ant-one"}) + + with caplog.at_level(logging.WARNING): + response = await edge.post(f"/refresh/{host_id}/anthropic") + + assert response.status_code == 503 + assert "sbx is down" in caplog.text + assert "sk-ant-one" not in caplog.text + + +async def test_a_refresh_order_for_an_unknown_host_or_service_answers_404(edge) -> None: + host_id = uuid.uuid4() + await _create_host(host_id, {"github": {"issuer": ISSUER, "placeholder_fingerprint": "a"}}) + + assert (await edge.post(f"/refresh/{uuid.uuid4()}/github")).status_code == 404 + assert (await edge.post(f"/refresh/{host_id}/anthropic")).status_code == 404 + + +async def test_a_refresh_order_for_a_static_entry_answers_409(edge) -> None: + host_id = uuid.uuid4() + await _create_host(host_id, {"github": {"value": "ghs_real", "placeholder_fingerprint": "a"}}) + + assert (await edge.post(f"/refresh/{host_id}/github")).status_code == 409 diff --git a/src/secrets_exchange/tests/test_secrets.py b/src/secrets_exchange/tests/test_secrets.py index 6a926a5..2561660 100644 --- a/src/secrets_exchange/tests/test_secrets.py +++ b/src/secrets_exchange/tests/test_secrets.py @@ -8,7 +8,10 @@ import pytest import respx +from hosts.models import Host +from hosts.tests.conftest import stub_provider # noqa: F401 from providers.exceptions import ProviderTransportError +from providers.registry import get_vm_provider from secrets_exchange.secrets import ( IssuerError, IssuerUnavailableError, @@ -25,10 +28,15 @@ VM = "sb-one" -def _holding_injection() -> MagicMock: - injection = MagicMock(needs_value=True) - injection.push_secret = AsyncMock() - return injection +def _host(provider: str = "exe") -> Host: + return Host(id=uuid.uuid4(), name=VM, provider=provider) + + +def _holding() -> MagicMock: + """The stub provider holds the value, like sbx.""" + stub = get_vm_provider("stub") + stub.secrets = MagicMock(needs_value=True, push_secret=AsyncMock()) + return stub.secrets @pytest.fixture @@ -41,13 +49,13 @@ async def secrets(): async def test_an_issuer_is_fetched_once_and_kept_until_it_ages(secrets) -> None: route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one"}) - first = await secrets.current(uuid.uuid4(), "github", ENTRY) - second = await secrets.current(first_key := uuid.uuid4(), "github", ENTRY) + first = await secrets.current(_host(), "github", ENTRY) + second = await secrets.current(first_host := _host(), "github", ENTRY) assert first.value == second.value == "ghs_one" assert route.calls[0].request.headers["Authorization"] == "Bearer d2d" assert route.call_count == 2, "each entry has its own secret" - assert (await secrets.current(first_key, "github", ENTRY)).value == "ghs_one" + assert (await secrets.current(first_host, "github", ENTRY)).value == "ghs_one" assert route.call_count == 2, "a held secret is not fetched again" @@ -55,7 +63,7 @@ async def test_an_issuer_is_fetched_once_and_kept_until_it_ages(secrets) -> None async def test_a_static_entry_never_touches_the_issuer(secrets) -> None: route = respx.get(ISSUER["url"]) - secret = await secrets.current(uuid.uuid4(), "github", {"value": "ghs_static"}) + secret = await secrets.current(_host(), "github", {"value": "ghs_static"}) assert secret == Secret(value="ghs_static") assert route.call_count == 0 @@ -74,16 +82,16 @@ async def test_the_issuer_expiry_wins_over_the_refresh_interval() -> None: async def test_a_value_near_its_end_is_fetched_again_and_the_old_one_serves_meanwhile( secrets, ) -> None: - host_id = uuid.uuid4() + host = _host() soon = (datetime.now(UTC) + timedelta(seconds=30)).isoformat() route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_old", "expires_at": soon}) - assert (await secrets.current(host_id, "github", ENTRY)).value == "ghs_old" + assert (await secrets.current(host, "github", ENTRY)).value == "ghs_old" route.respond(json={"value": "ghs_new", "expires_at": soon}) - assert (await secrets.current(host_id, "github", ENTRY)).value == "ghs_old" + assert (await secrets.current(host, "github", ENTRY)).value == "ghs_old" await _eventually(lambda: route.call_count == 2) - assert (await secrets.current(host_id, "github", ENTRY)).value == "ghs_new" + assert (await secrets.current(host, "github", ENTRY)).value == "ghs_new" @respx.mock @@ -98,14 +106,14 @@ async def test_a_value_near_its_end_is_fetched_again_and_the_old_one_serves_mean ], ) async def test_nothing_usable_and_nothing_held_is_unavailable_then_waits(secrets, answer) -> None: - host_id = uuid.uuid4() + host = _host() route = respx.get(ISSUER["url"]).respond(**answer) with pytest.raises(IssuerUnavailableError): - await secrets.current(host_id, "github", ENTRY) + await secrets.current(host, "github", ENTRY) route.respond(json={"value": "ghs_fine"}) with pytest.raises(IssuerUnavailableError): - await secrets.current(host_id, "github", ENTRY) + await secrets.current(host, "github", ENTRY) assert route.call_count == 1, "the second request waits out the retry delay" @@ -115,7 +123,7 @@ async def test_a_wrong_answer_is_logged_without_its_content(secrets, caplog) -> respx.get(ISSUER["url"]).respond(json={"token": "secret-xyz"}) with caplog.at_level(logging.WARNING), pytest.raises(IssuerUnavailableError): - await secrets.current(uuid.uuid4(), "github", ENTRY) + await secrets.current(_host(), "github", ENTRY) assert "answer has the wrong shape" in caplog.text assert "secret-xyz" not in caplog.text @@ -127,13 +135,13 @@ async def test_an_answer_that_has_already_expired_is_a_failure(secrets) -> None: respx.get(ISSUER["url"]).respond(json={"value": "ghs_dead", "expires_at": past}) with pytest.raises(IssuerUnavailableError): - await secrets.current(uuid.uuid4(), "github", ENTRY) + await secrets.current(_host(), "github", ENTRY) async def test_a_value_that_expires_during_a_failed_fetch_is_not_served( secrets, monkeypatch ) -> None: - key = (uuid.uuid4(), "github") + key = (_host(), "github") async def slow_failure(cls, issuer, client): await asyncio.sleep(0.3) @@ -151,7 +159,7 @@ async def slow_failure(cls, issuer, client): async def test_the_old_value_serves_while_a_fetch_is_under_way(secrets, monkeypatch) -> None: - key = (uuid.uuid4(), "github") + key = (_host(), "github") with respx.mock: soon = (datetime.now(UTC) + timedelta(seconds=30)).isoformat() respx.get(ISSUER["url"]).respond(json={"value": "ghs_old", "expires_at": soon}) @@ -178,7 +186,7 @@ async def paused_fetch(cls, issuer, client): async def test_the_retry_wait_starts_when_a_slow_fetch_fails(secrets, monkeypatch) -> None: - key = (uuid.uuid4(), "github") + key = (_host(), "github") attempts = 0 async def slow_failure(cls, issuer, client): @@ -204,30 +212,32 @@ async def _eventually(check) -> None: @respx.mock +@pytest.mark.usefixtures("stub_provider") async def test_a_held_value_is_fetched_and_pushed_once_while_it_lasts(secrets) -> None: - injection = _holding_injection() - host_id = uuid.uuid4() + holding = _holding() + host = _host("stub") route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one"}) - await secrets.push(host_id, VM, "github", ENTRY, injection) - await secrets.push(host_id, VM, "github", ENTRY, injection) + await secrets.push(host, "github", ENTRY) + await secrets.push(host, "github", ENTRY) - injection.push_secret.assert_awaited_once_with(vm=VM, name="github", value="ghs_one") + holding.push_secret.assert_awaited_once_with(vm=VM, name="github", value="ghs_one") assert route.call_count == 1 @respx.mock +@pytest.mark.usefixtures("stub_provider") async def test_a_held_value_that_nears_its_end_is_fetched_and_pushed_again(secrets) -> None: - injection = _holding_injection() - host_id = uuid.uuid4() + holding = _holding() + host = _host("stub") soon = (datetime.now(UTC) + timedelta(seconds=59)).isoformat() route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one", "expires_at": soon}) - await secrets.push(host_id, VM, "github", ENTRY, injection) + await secrets.push(host, "github", ENTRY) route.respond(json={"value": "ghs_two", "expires_at": soon}) - await secrets.push(host_id, VM, "github", ENTRY, injection) + await secrets.push(host, "github", ENTRY) - assert [call.kwargs["value"] for call in injection.push_secret.await_args_list] == [ + assert [call.kwargs["value"] for call in holding.push_secret.await_args_list] == [ "ghs_one", "ghs_two", ] @@ -235,35 +245,50 @@ async def test_a_held_value_that_nears_its_end_is_fetched_and_pushed_again(secre @respx.mock +@pytest.mark.usefixtures("stub_provider") async def test_a_push_that_fails_waits_then_goes_again_without_a_new_fetch(secrets, caplog) -> None: - injection = _holding_injection() - injection.push_secret.side_effect = [ProviderTransportError("sbx is down"), None] - host_id = uuid.uuid4() + holding = _holding() + holding.push_secret.side_effect = [ProviderTransportError("sbx is down"), None] + host = _host("stub") route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one"}) with caplog.at_level(logging.WARNING): - await secrets.push(host_id, VM, "github", ENTRY, injection) - await secrets.push(host_id, VM, "github", ENTRY, injection) - assert injection.push_secret.await_count == 1, "a failed push waits" + await secrets.push(host, "github", ENTRY) + await secrets.push(host, "github", ENTRY) + assert holding.push_secret.await_count == 1, "a failed push waits" assert "github" in caplog.text and VM in caplog.text and "sbx is down" in caplog.text assert "ghs_one" not in caplog.text - secrets._refreshable[(host_id, "github")].next_attempt = datetime.now(UTC) - await secrets.push(host_id, VM, "github", ENTRY, injection) + secrets._refreshable[(host.id, "github")].next_attempt = datetime.now(UTC) + await secrets.push(host, "github", ENTRY) - assert injection.push_secret.await_count == 2 - assert injection.push_secret.await_args.kwargs["value"] == "ghs_one" + assert holding.push_secret.await_count == 2 + assert holding.push_secret.await_args.kwargs["value"] == "ghs_one" assert route.call_count == 1 @respx.mock +@pytest.mark.usefixtures("stub_provider") async def test_a_fetch_that_fails_pushes_nothing(secrets) -> None: - injection = _holding_injection() + holding = _holding() respx.get(ISSUER["url"]).respond(status_code=500) - await secrets.push(uuid.uuid4(), VM, "github", ENTRY, injection) + await secrets.push(_host("stub"), "github", ENTRY) + + holding.push_secret.assert_not_awaited() - injection.push_secret.assert_not_awaited() + +@respx.mock +@pytest.mark.usefixtures("stub_provider") +async def test_a_provider_that_holds_no_value_is_never_pushed(secrets) -> None: + stub = get_vm_provider("stub") + stub.secrets = MagicMock(needs_value=False, push_secret=AsyncMock()) + route = respx.get(ISSUER["url"]).respond(json={"value": "ghs_one"}) + + await secrets.push(_host("stub"), "github", ENTRY) + + stub.secrets.push_secret.assert_not_awaited() + assert route.call_count == 0 async def test_a_header_h11_refuses_never_reaches_the_error_chain() -> None: