diff --git a/docs/user/reference/api.md b/docs/user/reference/api.md index 824c73e..c04f65a 100644 --- a/docs/user/reference/api.md +++ b/docs/user/reference/api.md @@ -19,6 +19,14 @@ Anything else, including every name starting with `_`, is internal. ::: intercede.registry +## Credentials + +::: intercede.auth.models + +::: intercede.auth.protocols + +::: intercede.auth.providers + ## Exceptions ::: intercede.exceptions diff --git a/src/intercede/_backends/credentials.py b/src/intercede/_backends/credentials.py new file mode 100644 index 0000000..ee1ce29 --- /dev/null +++ b/src/intercede/_backends/credentials.py @@ -0,0 +1,178 @@ +"""What a backend asks its credential provider for, when, and what it accepts back. + +``credential_requirements`` turns a configuration into what to ask for. This is +also the one place freshness is decided. A backend keeps the set it was +last given, privately, and before each operation calls ``refresh_if_stale``: +the provider is asked only when that set is missing a required kind or one of +its credentials is about to expire. Where credentials come from, and how they +are renewed, stays the consumer's business, inside its provider. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from intercede.auth.models import ( + BearerToken, + Credential, + CredentialRequirements, + CredentialSet, + SSHPassword, + SSHPrivateKey, + X509Proxy, +) +from intercede.auth.protocols import CredentialProvider +from intercede.config import ( + ARCConfig, + BackendConfig, + BatchSSHConfig, + HTCondorCEConfig, +) +from intercede.exceptions import AuthenticationError, InterCEdeError + +# What each configured auth mode asks the credential provider for. +# "agent" is absent on purpose: an SSH agent holds the key, so nothing is asked. +AUTH_KINDS: dict[str, type[Credential]] = { + "token": BearerToken, + "proxy": X509Proxy, + "key": SSHPrivateKey, + "password": SSHPassword, +} + +# Ask again this long before a credential expires, so it cannot run out in the +# middle of an operation. A library default; whether a provider should be able +# to change it is still open. +DEFAULT_MARGIN = timedelta(minutes=5) + + +def credential_requirements( + config: BackendConfig, scopes: frozenset[str] = frozenset() +) -> CredentialRequirements | None: + """Say what a backend must ask its provider for. + + Args: + config: The backend's validated configuration. + scopes: The scopes of the operation about to run, for token-based + backends; ignored by the others. + + Returns: + What to ask for, or None when the backend needs no credential: it runs + on this host, or an SSH agent holds the key. + """ + if isinstance(config, ARCConfig | HTCondorCEConfig): + # The endpoint is the resource, so a token is minted for this CE's audience. + return CredentialRequirements( + resource=config.endpoint, + kinds=frozenset(AUTH_KINDS[mode] for mode in config.auth), + scopes=scopes, + ) + if isinstance(config, BatchSSHConfig) and config.auth != "agent": + # The resource is the login, ``user@host:port``: a provider finds the key + # or password by it, and the same host can take different users. + return CredentialRequirements( + resource=f"{config.user}@{config.host}:{config.port}", + kinds=frozenset({AUTH_KINDS[config.auth]}), + ) + return None + + +def is_stale( + credentials: CredentialSet | None, + requirements: CredentialRequirements, + *, + margin: timedelta = DEFAULT_MARGIN, + now: datetime | None = None, +) -> bool: + """Tell whether ``credentials`` cannot serve ``requirements`` for ``margin`` more. + + A credential that never expires is never stale. + + Args: + credentials: The set the backend holds, or None before its first request. + requirements: What the backend needs for the operation about to run. + margin: How long the credentials must stay valid from now. + now: The current time; tests pass a fixed one. + + Returns: + True when there are no credentials yet, when a required kind is + missing, or when a required credential expires within ``margin``. + """ + if credentials is None: + return True + moment = now or datetime.now(UTC) + for kind in requirements.kinds: + credential = credentials.get(kind) + if credential is None: + return True + if ( + credential.expires_at is not None + and credential.expires_at - margin <= moment + ): + return True + return False + + +async def refresh_if_stale( + current: CredentialSet | None, + requirements: CredentialRequirements, + provider: CredentialProvider, + *, + margin: timedelta = DEFAULT_MARGIN, + now: datetime | None = None, +) -> CredentialSet: + """Return ``current`` while it is fresh, otherwise a new set from ``provider``. + + Args: + current: The set the backend holds, or None before its first request. + requirements: What the backend needs for the operation about to run. + provider: The consumer's provider, asked only when ``current`` is stale. + margin: How long the credentials must stay valid from now. + now: The current time; tests pass a fixed one. + + Returns: + The set to authenticate with: ``current``, or the provider's new set. + + Raises: + AuthenticationError: The provider failed, left out a required kind, or + returned a credential that has already expired. Always a failure of + the whole operation, never a per-job outcome. + """ + if not is_stale(current, requirements, margin=margin, now=now): + assert current is not None # is_stale() is True for None + return current + + try: + fresh = await provider.get(requirements) + except InterCEdeError: + raise # already typed, e.g. an AuthenticationError from the provider + except Exception as exc: + # Only the type: the provider's message may hold a token or a secret URL. + # The exception itself stays available as __cause__. + raise AuthenticationError( + f"the credential provider failed for {requirements.resource} " + f"with {type(exc).__name__}" + ) from exc + + if not isinstance(fresh, CredentialSet): + raise AuthenticationError( + f"the credential provider returned a {type(fresh).__name__} " + f"for {requirements.resource}, not a CredentialSet" + ) + missing = [] + for kind in requirements.kinds: + if fresh.get(kind) is None: + missing.append(kind.__name__) + if missing: + raise AuthenticationError( + # Sorted: kinds is a set, whose order changes from run to run. + f"the credential provider returned no {', '.join(sorted(missing))} " + f"for {requirements.resource}" + ) + # Expiring within the margin is accepted (it is asked again next time); + # already expired is not, since it cannot authenticate anything. + if is_stale(fresh, requirements, margin=timedelta(0), now=now): + raise AuthenticationError( + f"the credential provider returned expired credentials " + f"for {requirements.resource}" + ) + return fresh diff --git a/src/intercede/_backends/fake.py b/src/intercede/_backends/fake.py index 53c4d59..609fb37 100644 --- a/src/intercede/_backends/fake.py +++ b/src/intercede/_backends/fake.py @@ -13,6 +13,7 @@ from typing import Self from intercede._backends.config import FakeConfig +from intercede.auth.protocols import CredentialProvider from intercede.exceptions import ( InterCEdeError, ResourceUnavailableError, @@ -94,11 +95,15 @@ def __init__( self._closed = False @classmethod - def from_settings(cls, settings: FakeConfig) -> Self: + def from_settings( + cls, settings: FakeConfig, credentials: CredentialProvider + ) -> Self: """Build the fake from its settings; how the registry constructs it. Args: settings: The fake's settings: only the resource it reports. + credentials: Never asked: nothing leaves the process, so the fake + needs no credentials. Returns: A new fake, not yet opened. @@ -379,4 +384,6 @@ def _write_job_files( _kill: type[Killable] = FakeBackend _purge: type[Purgeable] = FakeBackend _diag: type[Diagnosable] = FakeBackend -_built_by_registry: Callable[[FakeConfig], JobBackend] = FakeBackend.from_settings +_built_by_registry: Callable[[FakeConfig, CredentialProvider], JobBackend] = ( + FakeBackend.from_settings +) diff --git a/src/intercede/auth/__init__.py b/src/intercede/auth/__init__.py new file mode 100644 index 0000000..80a9476 --- /dev/null +++ b/src/intercede/auth/__init__.py @@ -0,0 +1,32 @@ +"""Backend credentials: typed, declared, supplied by a provider. + +A consumer implements a ``CredentialProvider`` and returns ``CredentialSet`` +values from it. Configuration never holds a secret: it only says which kinds +a backend needs, and the provider supplies them at run time. +""" + +from __future__ import annotations + +from intercede.auth.models import ( + BearerToken, + Credential, + CredentialRequirements, + CredentialSet, + SSHPassword, + SSHPrivateKey, + X509Proxy, +) +from intercede.auth.protocols import CredentialProvider +from intercede.auth.providers import StaticCredentials + +__all__ = [ + "BearerToken", + "Credential", + "CredentialProvider", + "CredentialRequirements", + "CredentialSet", + "SSHPassword", + "SSHPrivateKey", + "StaticCredentials", + "X509Proxy", +] diff --git a/src/intercede/auth/models.py b/src/intercede/auth/models.py new file mode 100644 index 0000000..561fe57 --- /dev/null +++ b/src/intercede/auth/models.py @@ -0,0 +1,186 @@ +"""The public credential models a consumer of interCEde can use. + +Credentials are immutable values. Secret material is held in pydantic's secret +types, so a credential cannot be printed or logged by accident: ``repr``, +``str`` and JSON all show ``**********``, and the content is only readable on +purpose, with ``get_secret_value()``. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Self, TypeVar + +from pydantic import ( + AwareDatetime, + ConfigDict, + RootModel, + SecretBytes, + SecretStr, + model_validator, +) + +from intercede.models import _Model + +__all__ = [ + "BearerToken", + "Credential", + "CredentialRequirements", + "CredentialSet", + "SSHPassword", + "SSHPrivateKey", + "X509Proxy", +] + + +# -------------------------------------------------------------------------- +# Credential values +# -------------------------------------------------------------------------- + + +class Credential(_Model): + """Base of every credential: the one field the freshness check needs. + + Not used on its own: a provider returns one of the concrete kinds below. + """ + + # Added to _Model's settings: a value given by mistake may be the secret + # itself, so no error message repeats one. + model_config = ConfigDict(hide_input_in_errors=True, use_attribute_docstrings=True) + + expires_at: AwareDatetime | None = None + """When the credential stops working, with a timezone, so it can be compared + with the current time. None means it never expires, as for SSH secrets.""" + + +class BearerToken(Credential): + """A token for the channel to a CE. Opaque to interCEde.""" + + value: SecretStr + """The token itself.""" + + +class X509Proxy(Credential): + """An X.509 proxy: private key and certificate chain together, as PEM.""" + + pem: SecretBytes + """The private key and certificate chain, PEM-encoded.""" + + +class SSHPrivateKey(Credential): + """An SSH private key: the key itself, never a path on some host.""" + + pem: SecretBytes + """The private key, PEM-encoded.""" + + passphrase: SecretStr | None = None + """The passphrase the key is encrypted with, or None if it is not.""" + + +class SSHPassword(Credential): + """A password for an SSH login.""" + + value: SecretStr + """The password itself.""" + + +AnyCredential = BearerToken | X509Proxy | SSHPrivateKey | SSHPassword +C = TypeVar("C", bound=Credential) + + +# -------------------------------------------------------------------------- +# What a provider returns +# -------------------------------------------------------------------------- + + +class CredentialSet(RootModel[tuple[AnyCredential, ...]]): + """The credentials a provider returns: at most one of each kind. + + A set rather than a single value, because a backend can need several kinds + together:: + + CredentialSet([BearerToken(value=token), X509Proxy(pem=proxy)]) + """ + + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + + root: tuple[AnyCredential, ...] = () + + @model_validator(mode="before") + @classmethod + def _objects_only(cls, data: Any) -> Any: + # A plain dict is ambiguous: {"value": ...} fits both a BearerToken and + # an SSHPassword. Guessing the kind of a secret is never right. + is_sequence = isinstance(data, list | tuple | set | frozenset) + if is_sequence and any(not isinstance(item, Credential) for item in data): + raise ValueError( + "a CredentialSet holds credential objects, " + "e.g. BearerToken(value=...), not plain data" + ) + return data + + @model_validator(mode="after") + def _one_per_kind(self) -> Self: + counts = Counter(type(credential).__name__ for credential in self.root) + duplicates = [] + + for kind, count in counts.items(): + if count > 1: + duplicates.append(kind) + + if duplicates: + raise ValueError(f"more than one credential of kind {duplicates}") + + return self + + def get(self, kind: type[C]) -> C | None: + """Return the credential of one kind. + + Args: + kind: The credential class to look for, e.g. ``BearerToken``. + + Returns: + The set's credential of ``kind``, or None if it has none. + """ + for credential in self.root: + if isinstance(credential, kind): + return credential + return None + + +# -------------------------------------------------------------------------- +# What a backend asks for +# -------------------------------------------------------------------------- + + +class CredentialRequirements(_Model): + """What a backend asks its provider for. + + The backend computes it from its own configuration and keeps it to itself: + a consumer only meets it as the argument to its provider's ``get``. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + resource: str + """Which backend instance is asking: the endpoint it talks to, the same + string as ``JobHandle.resource``. For a token this is also the audience, + in the form that CE expects.""" + + kinds: frozenset[type[Credential]] + """The kinds of credential needed, all of them together, not a choice + between alternatives.""" + + scopes: frozenset[str] = frozenset() + """For token kinds, the scopes of the operation about to run; empty for + the others.""" + + @model_validator(mode="after") + def _concrete_kinds(self) -> Self: + if not self.kinds: + # A backend that needs nothing (e.g. SSH through an agent) never + # calls its provider, so empty requirements are always a mistake. + raise ValueError("kinds must name at least one credential kind") + if Credential in self.kinds: + raise ValueError("kinds must name concrete kinds, not the Credential base") + return self diff --git a/src/intercede/auth/protocols.py b/src/intercede/auth/protocols.py new file mode 100644 index 0000000..c62c2b7 --- /dev/null +++ b/src/intercede/auth/protocols.py @@ -0,0 +1,48 @@ +"""The public protocol for a ``CredentialProvider``.""" + +from __future__ import annotations + +from types import TracebackType +from typing import Protocol, Self, runtime_checkable + +from intercede.auth.models import CredentialRequirements, CredentialSet + +__all__ = ["CredentialProvider"] + + +@runtime_checkable +class CredentialProvider(Protocol): + """The protocol any consumer's own credential provider must satisfy. + + The consumer creates the provider, shares it between backends and closes + it: interCEde never closes a provider, because it did not create it. A + backend calls ``get`` only when its current credentials are missing or + close to expiry, so the consumer decides where credentials come from and + interCEde decides when to ask. + """ + + async def get(self, requirements: CredentialRequirements) -> CredentialSet: + """Return the credentials a backend needs, all of them together. + + Args: + requirements: The resource, the kinds of credential it takes, and + the scopes of the operation about to run. + + Returns: + One credential of every kind in ``requirements.kinds``. + + Raises: + Exception: Anything raised here fails the whole operation: the + backend reports it as an ``AuthenticationError``, never as a + per-job outcome. + """ + ... + + async def __aenter__(self) -> Self: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: ... diff --git a/src/intercede/auth/providers.py b/src/intercede/auth/providers.py new file mode 100644 index 0000000..e0edc9d --- /dev/null +++ b/src/intercede/auth/providers.py @@ -0,0 +1,65 @@ +"""Ready-made credential providers.""" + +from __future__ import annotations + +from collections.abc import Iterable +from types import TracebackType +from typing import Self + +from intercede.auth.models import Credential, CredentialRequirements, CredentialSet +from intercede.auth.protocols import CredentialProvider + +__all__ = ["StaticCredentials"] + + +class StaticCredentials: + """A provider holding fixed credentials: files read once, or fixed values. + + For standalone use, tests and the integration stacks, where nothing issues + or renews credentials. Each backend receives only the kinds it asks for, + never the whole set. It holds nothing to release, so closing does nothing, + but it is still a context manager, like every provider:: + + async with StaticCredentials([SSHPrivateKey(pem=key)]) as credentials: + backend = registry.get_backend(config, credentials) + """ + + def __init__(self, credentials: CredentialSet | Iterable[Credential] = ()) -> None: + # Validated now, so a duplicate kind or plain data fails at construction. + self._credentials = ( + credentials + if isinstance(credentials, CredentialSet) + else CredentialSet.model_validate(tuple(credentials)) + ) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + return None + + async def get(self, requirements: CredentialRequirements) -> CredentialSet: + """Return the held credentials of the requested kinds. + + Args: + requirements: What the backend asks for. + + Returns: + The held credentials whose kind is in ``requirements.kinds``. A + kind it does not hold is left out, for the backend to report. + """ + return CredentialSet( + tuple( + credential + for credential in self._credentials.root + if type(credential) in requirements.kinds + ) + ) + + +_provider: type[CredentialProvider] = StaticCredentials diff --git a/src/intercede/registry.py b/src/intercede/registry.py index 063afe1..6acf729 100644 --- a/src/intercede/registry.py +++ b/src/intercede/registry.py @@ -7,6 +7,8 @@ Every registered class provides a ``from_settings`` classmethod, which is how the registry builds it from a validated configuration model. +The parts that authenticate (single-class backends and transports) also +receive the consumer's credential provider; a scheduler only builds commands. What it builds is checked against the relevant protocol before it is returned, so a broken entry fails here, with a clear error, and not at the first call. """ @@ -20,6 +22,7 @@ from intercede._backends.batch.protocols import Scheduler, Transport from intercede._backends.config import FakeConfig +from intercede.auth.protocols import CredentialProvider from intercede.config import ( _MODELS as _CONFIG_MODELS, ) @@ -29,7 +32,7 @@ BatchSSHConfig, validate_config, ) -from intercede.exceptions import RegistryError +from intercede.exceptions import ConfigurationError, RegistryError from intercede.protocols import JobBackend __all__ = ["available", "get_backend"] @@ -53,25 +56,39 @@ } -def get_backend(config: BackendConfig | Mapping[str, Any]) -> JobBackend: +def get_backend( + config: BackendConfig | Mapping[str, Any], credentials: CredentialProvider +) -> JobBackend: """Build the backend a configuration describes. - The backend is returned unopened: use it as ``async with registry.get_backend(config) as ce``. - Construction does no I/O, so building a backend for an unreachable resource succeeds. + The backend is returned unopened: use it as + ``async with registry.get_backend(config, credentials) as ce``. Construction + does no I/O and asks the provider for nothing, so building a backend for an + unreachable resource succeeds. The backend asks for credentials when it + first needs them, and again when they are about to expire. Args: config: A ``BackendConfig``, or the same data as a mapping, for example a parsed TOML table. + credentials: The consumer's provider. The consumer opens and closes it; + interCEde never does. One provider is normally shared by every + backend a task drives. Returns: The backend, not yet opened. Raises: - ConfigurationError: The configuration is invalid. + ConfigurationError: The configuration is invalid, or ``credentials`` + is not a ``CredentialProvider``. RegistryError: The configuration is valid, but this version of interCEde does not provide what it asks for. """ - return _build(validate_config(config)) + validated = validate_config(config) + if not isinstance(credentials, CredentialProvider): + raise ConfigurationError( + f"credentials must be a CredentialProvider, not a {type(credentials).__name__}" + ) + return _build(validated, credentials) def available() -> dict[str, tuple[str, ...]]: @@ -123,11 +140,14 @@ def _choices(annotation: Any) -> set[str]: return values -def _build(config: BackendConfig | FakeConfig) -> JobBackend: +def _build( + config: BackendConfig | FakeConfig, credentials: CredentialProvider +) -> JobBackend: """Build a backend from a validated configuration. Internal: the test suites call it directly. Args: config: The validated configuration of one backend, or the fake's settings. + credentials: The consumer's provider, for the parts that authenticate. Returns: The backend, not yet opened. A ``batch`` backend is composed of the @@ -139,14 +159,14 @@ def _build(config: BackendConfig | FakeConfig) -> JobBackend: what was built does not satisfy its protocol. """ if not isinstance(config, BatchLocalConfig | BatchSSHConfig): - built = _load("backend", config.kind).from_settings(config) + built = _load("backend", config.kind).from_settings(config, credentials) result: JobBackend = _checked("backend", config.kind, built, JobBackend) return result transport: Transport = _checked( "transport", config.transport, - _load("transport", config.transport).from_settings(config), + _load("transport", config.transport).from_settings(config, credentials), Transport, ) scheduler: Scheduler = _checked( diff --git a/tests/unit/_registry_dummies.py b/tests/unit/_registry_dummies.py index 5dc02fe..345b4eb 100644 --- a/tests/unit/_registry_dummies.py +++ b/tests/unit/_registry_dummies.py @@ -19,10 +19,13 @@ async def __aexit__(self, *exc_info: object) -> None: ... class DummyTransport(_Lifecycle): def __init__(self, settings: Any) -> None: self.settings = settings + self.credentials: Any = None @classmethod - def from_settings(cls, settings: Any) -> DummyTransport: - return cls(settings) + def from_settings(cls, settings: Any, credentials: Any) -> DummyTransport: + built = cls(settings) + built.credentials = credentials + return built async def run(self, argv: Any) -> Any: ... @@ -74,10 +77,13 @@ class DummyBackend(_Lifecycle): def __init__(self, settings: Any) -> None: self.settings = settings + self.credentials: Any = None @classmethod - def from_settings(cls, settings: Any) -> DummyBackend: - return cls(settings) + def from_settings(cls, settings: Any, credentials: Any) -> DummyBackend: + built = cls(settings) + built.credentials = credentials + return built async def submit(self, spec: Any, copies: Any = 1) -> Any: ... diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b243067..e3b6575 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -13,11 +13,12 @@ from intercede import JobBackend from intercede._backends.config import FakeConfig +from intercede.auth import StaticCredentials from intercede.registry import _build # Built through the registry, so the suite also covers the registry path. BACKENDS: dict[str, Callable[[], JobBackend]] = { - "fake": lambda: _build(FakeConfig()), + "fake": lambda: _build(FakeConfig(), StaticCredentials()), } diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..3395dc1 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,117 @@ +"""Credential values, sets, requirements and the static provider.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any + +import pytest +from pydantic import ValidationError + +from intercede.auth import ( + BearerToken, + Credential, + CredentialProvider, + CredentialRequirements, + CredentialSet, + SSHPassword, + SSHPrivateKey, + StaticCredentials, + X509Proxy, +) + +SECRET = "TOPSECRET" # noqa: S105 (a fake secret the tests look for) + +# One instance of every kind, each holding SECRET in all of its secret fields. +EVERY_KIND: dict[type[Credential], Credential] = { + BearerToken: BearerToken(value=SECRET), + X509Proxy: X509Proxy(pem=SECRET.encode()), + SSHPrivateKey: SSHPrivateKey(pem=SECRET.encode(), passphrase=SECRET), + SSHPassword: SSHPassword(value=SECRET), +} + + +@pytest.mark.parametrize("kind", list(EVERY_KIND), ids=lambda kind: kind.__name__) +def test_a_secret_never_shows(kind: type[Credential]): + credential = EVERY_KIND[kind] + credentials = CredentialSet([credential]) + for shown in ( + repr(credential), + str(credential), + credential.model_dump_json(), + repr(credentials), + credentials.model_dump_json(), + repr(StaticCredentials([credential])), + ): + assert SECRET not in shown + + +@pytest.mark.parametrize( + "build", + [ + lambda: BearerToken(value="t", token=SECRET), + lambda: SSHPrivateKey(pem=b"k", passphrase=[SECRET]), + lambda: CredentialSet([{"value": SECRET}]), + lambda: StaticCredentials([{"value": SECRET}]), + ], + ids=["unknown-field", "wrong-type", "plain-data-in-set", "plain-data-in-provider"], +) +def test_a_mistake_never_repeats_the_secret(build: Callable[[], Any]): + with pytest.raises(ValidationError) as exc_info: + build() + assert SECRET not in str(exc_info.value) + + +def test_expiry_must_have_a_timezone(): + # A naive time could not be compared with the current time in UTC. + with pytest.raises(ValidationError, match="timezone"): + BearerToken(value="t", expires_at=datetime(2030, 1, 1)) # noqa: DTZ001 + + +class TestCredentialSet: + def test_get_goes_by_kind_not_by_shape(self): + # A token and a password both have a single "value" field. + password = SSHPassword(value="pw") + credentials = CredentialSet([password]) + assert credentials.get(SSHPassword) is password + assert credentials.get(BearerToken) is None + + def test_one_credential_per_kind(self): + with pytest.raises(ValidationError, match="BearerToken"): + CredentialSet([BearerToken(value="a"), BearerToken(value="b")]) + + @pytest.mark.parametrize("item", [{"value": "x"}, "token", Credential()]) + def test_only_concrete_credentials(self, item: Any): + with pytest.raises(ValidationError): + CredentialSet([item]) + + +class TestCredentialRequirements: + def test_needs_at_least_one_kind(self): + with pytest.raises(ValidationError, match="at least one"): + CredentialRequirements(resource="r", kinds=frozenset()) + + @pytest.mark.parametrize("kind", [Credential, str, "BearerToken"]) + def test_kinds_are_concrete_credential_classes(self, kind: Any): + with pytest.raises(ValidationError): + CredentialRequirements(resource="r", kinds=frozenset({kind})) + + +class TestStaticCredentials: + def test_is_a_provider(self): + assert isinstance(StaticCredentials(), CredentialProvider) + + async def test_hands_out_only_the_requested_kinds(self): + token, proxy = BearerToken(value="t"), X509Proxy(pem=b"p") + provider = StaticCredentials([token, proxy, SSHPassword(value="pw")]) + requirements = CredentialRequirements( + resource="ce:9619", kinds=frozenset({BearerToken, X509Proxy, SSHPrivateKey}) + ) + async with provider: + supplied = await provider.get(requirements) + assert supplied.root == (token, proxy) + + def test_refuses_bad_input_at_construction(self): + with pytest.raises(ValidationError): + StaticCredentials([BearerToken(value="a"), BearerToken(value="b")]) diff --git a/tests/unit/test_credentials.py b/tests/unit/test_credentials.py new file mode 100644 index 0000000..dbec136 --- /dev/null +++ b/tests/unit/test_credentials.py @@ -0,0 +1,207 @@ +"""When a backend asks its provider again, and what it accepts back.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import TracebackType +from typing import Any, Self + +import pytest + +from intercede import AuthenticationError, InterCEdeError, validate_config +from intercede._backends.credentials import ( + DEFAULT_MARGIN, + credential_requirements, + is_stale, + refresh_if_stale, +) +from intercede.auth import ( + BearerToken, + CredentialRequirements, + CredentialSet, + SSHPassword, + SSHPrivateKey, + X509Proxy, +) + +NOW = datetime(2030, 1, 1, 12, 0, tzinfo=UTC) +TOKEN_ONLY = CredentialRequirements(resource="ce:9619", kinds=frozenset({BearerToken})) +TOKEN_AND_PROXY = CredentialRequirements( + resource="ce:9619", kinds=frozenset({BearerToken, X509Proxy}) +) + + +def token(expires_in: timedelta | None = None) -> BearerToken: + return BearerToken( + value="t", expires_at=None if expires_in is None else NOW + expires_in + ) + + +def proxy(expires_in: timedelta | None = None) -> X509Proxy: + return X509Proxy( + pem=b"p", expires_at=None if expires_in is None else NOW + expires_in + ) + + +class Recording: + """A provider that returns whatever the test gives it, and counts calls.""" + + def __init__(self, result: Any = None, error: BaseException | None = None) -> None: + self.result = result + self.error = error + self.asked: list[CredentialRequirements] = [] + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: ... + + async def get(self, requirements: CredentialRequirements) -> Any: + self.asked.append(requirements) + if self.error is not None: + raise self.error + return self.result + + +class TestIsStale: + @pytest.mark.parametrize( + ("credentials", "stale"), + [ + (None, True), # nothing asked for yet + (CredentialSet([token()]), False), # never expires + (CredentialSet([token(DEFAULT_MARGIN + timedelta(seconds=1))]), False), + (CredentialSet([token(DEFAULT_MARGIN)]), True), # inside the margin + (CredentialSet([token(-timedelta(hours=1))]), True), # expired + ], + ids=["none", "never-expires", "outside-margin", "at-margin", "expired"], + ) + def test_one_kind(self, credentials: CredentialSet | None, stale: bool): + assert is_stale(credentials, TOKEN_ONLY, now=NOW) is stale + + def test_one_missing_or_expiring_kind_makes_the_set_stale(self): + assert is_stale(CredentialSet([token()]), TOKEN_AND_PROXY, now=NOW) + expiring = CredentialSet([token(), proxy(timedelta(minutes=1))]) + assert is_stale(expiring, TOKEN_AND_PROXY, now=NOW) + + def test_kinds_not_required_are_ignored(self): + # An expired proxy does not matter to a backend that only needs a token. + credentials = CredentialSet([token(), proxy(-timedelta(hours=1))]) + assert not is_stale(credentials, TOKEN_ONLY, now=NOW) + + +class TestRefreshIfStale: + async def test_fresh_set_is_kept_without_asking(self): + current = CredentialSet([token(timedelta(hours=1))]) + provider = Recording() + assert await refresh_if_stale(current, TOKEN_ONLY, provider, now=NOW) is current + assert provider.asked == [] + + async def test_stale_set_is_replaced(self): + fresh = CredentialSet([token(timedelta(hours=1))]) + provider = Recording(fresh) + current = CredentialSet([token(timedelta(minutes=1))]) + assert await refresh_if_stale(current, TOKEN_ONLY, provider, now=NOW) is fresh + assert provider.asked == [TOKEN_ONLY] + + async def test_short_lived_credentials_are_accepted(self): + # Inside the margin but not expired: usable now, asked again next time. + fresh = CredentialSet([token(timedelta(minutes=1))]) + assert ( + await refresh_if_stale(None, TOKEN_ONLY, Recording(fresh), now=NOW) is fresh + ) + + +class TestProviderFailures: + """Every failure is a whole-operation AuthenticationError.""" + + async def test_provider_raising_is_an_authentication_error(self): + provider = Recording(error=RuntimeError("no token for s3cret-url")) + with pytest.raises(AuthenticationError) as exc_info: + await refresh_if_stale(None, TOKEN_ONLY, provider, now=NOW) + assert isinstance(exc_info.value.__cause__, RuntimeError) + message = str(exc_info.value) + assert "ce:9619" in message and "RuntimeError" in message + assert "s3cret" not in message # the provider's text may hold a secret + + async def test_our_own_errors_pass_through_unchanged(self): + error = InterCEdeError("x") + with pytest.raises(InterCEdeError) as exc_info: + await refresh_if_stale(None, TOKEN_ONLY, Recording(error=error), now=NOW) + assert exc_info.value is error + + @pytest.mark.parametrize("result", [None, [token()]]) + async def test_wrong_return_type(self, result: Any): + with pytest.raises(AuthenticationError, match="not a CredentialSet"): + await refresh_if_stale(None, TOKEN_ONLY, Recording(result), now=NOW) + + async def test_every_missing_kind_is_named(self): + provider = Recording(CredentialSet()) + with pytest.raises(AuthenticationError, match="BearerToken, X509Proxy"): + await refresh_if_stale(None, TOKEN_AND_PROXY, provider, now=NOW) + + async def test_expired_credentials(self): + provider = Recording(CredentialSet([token(-timedelta(minutes=1))])) + with pytest.raises(AuthenticationError, match="expired"): + await refresh_if_stale(None, TOKEN_ONLY, provider, now=NOW) + + +# -------------------------------------------------------------------------- +# From a configuration to what to ask the provider for +# -------------------------------------------------------------------------- + +ARC: dict[str, Any] = {"kind": "arc", "endpoint": "https://arc.example.org:443"} +CE: dict[str, Any] = {"kind": "htcondor-ce", "endpoint": "ce.example.org:9619"} +SSH: dict[str, Any] = { + "kind": "batch", + "transport": "ssh", + "scheduler": "slurm", + "host": "batch.example.org", + "user": "dirac", + "known_hosts": "batch.example.org ssh-ed25519 AAAAC3Nz...", +} + + +class TestCredentialRequirements: + @pytest.mark.parametrize("base", [ARC, CE], ids=["arc", "htcondor-ce"]) + @pytest.mark.parametrize( + ("auth", "kinds"), + [({}, {BearerToken}), ({"auth": ["token", "proxy"]}, {BearerToken, X509Proxy})], + ids=["default", "token+proxy"], + ) + def test_a_ce_asks_for_its_endpoint( + self, base: dict[str, Any], auth: dict[str, Any], kinds: set[type] + ): + scopes = frozenset({"compute.create"}) + requirements = credential_requirements( + validate_config({**base, **auth}), scopes + ) + assert requirements is not None + assert requirements.kinds == kinds + assert requirements.resource == base["endpoint"] + assert requirements.scopes == scopes + + @pytest.mark.parametrize( + ("auth", "kind"), [("key", SSHPrivateKey), ("password", SSHPassword)] + ) + def test_ssh_asks_for_the_login(self, auth: str, kind: type): + config = validate_config({**SSH, "auth": auth, "port": 2222}) + requirements = credential_requirements(config) + assert requirements is not None + assert requirements.kinds == {kind} + assert requirements.resource == "dirac@batch.example.org:2222" + + @pytest.mark.parametrize( + "data", + [ + {**SSH, "auth": "agent"}, + {"kind": "batch", "transport": "local", "scheduler": "slurm"}, + ], + ids=["ssh-agent", "local"], + ) + def test_nothing_to_ask_for(self, data: dict[str, Any]): + assert credential_requirements(validate_config(data)) is None diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index eebab72..576f22e 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -18,9 +18,11 @@ registry, ) from intercede._backends.config import FakeConfig +from intercede.auth import CredentialSet, StaticCredentials from intercede.registry import _build, _load DUMMIES = "tests.unit._registry_dummies" +CREDENTIALS = StaticCredentials() CLOUD: dict[str, Any] = { "kind": "cloud", @@ -63,7 +65,7 @@ def composed(monkeypatch: pytest.MonkeyPatch) -> None: class TestBuilds: async def test_the_fake_for_the_test_suites(self): - async with _build(FakeConfig(resource="fake://test")) as backend: + async with _build(FakeConfig(resource="fake://test"), CREDENTIALS) as backend: assert isinstance(backend, JobBackend) assert backend.resource == "fake://test" @@ -72,29 +74,57 @@ async def test_the_fake_for_the_test_suites(self): [CLOUD, CloudConfig(**CLOUD)], ids=["mapping", "object"], ) - def test_a_single_class_backend_gets_its_settings( + def test_a_single_class_backend_gets_its_settings_and_provider( self, monkeypatch: pytest.MonkeyPatch, config: Any ): monkeypatch.setitem(registry._BACKENDS, "cloud", f"{DUMMIES}:DummyBackend") - built: Any = registry.get_backend(config) + provider = StaticCredentials() + built: Any = registry.get_backend(config, provider) assert type(built).__name__ == "DummyBackend" assert isinstance(built.settings, CloudConfig) + assert built.credentials is provider def test_a_batch_backend_is_composed_of_its_parts(self, composed: None): - built: Any = registry.get_backend({**SSH_SLURM, "queue": "short"}) + provider = StaticCredentials() + built: Any = registry.get_backend({**SSH_SLURM, "queue": "short"}, provider) assert type(built).__name__ == "DummyBatchBackend" - # Transport and scheduler each get the whole batch configuration. + # Transport and scheduler each get the whole batch configuration; the + # transport, which authenticates, also gets the provider. assert isinstance(built.transport.settings, BatchSSHConfig) assert built.transport.settings.host == "batch.example.org" + assert built.transport.credentials is provider assert built.scheduler.settings.queue == "short" + async def test_building_asks_the_provider_nothing( + self, composed: None, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setitem(registry._BACKENDS, "cloud", f"{DUMMIES}:DummyBackend") + + class Watching(StaticCredentials): + asked = 0 + + async def get(self, requirements: Any) -> CredentialSet: + Watching.asked += 1 + return await super().get(requirements) + + registry.get_backend(SSH_SLURM, Watching()) + registry.get_backend(CLOUD, Watching()) + assert Watching.asked == 0 # construction does no I/O + class TestRefused: def test_invalid_configuration_keeps_the_details(self): with pytest.raises(ConfigurationError) as exc_info: - registry.get_backend({"kind": "arc"}) + registry.get_backend({"kind": "arc"}, CREDENTIALS) assert "endpoint: required for kind='arc'" in str(exc_info.value) + @pytest.mark.parametrize( + "credentials", [None, CredentialSet()], ids=["none", "set-not-provider"] + ) + def test_credentials_must_be_a_provider(self, composed: None, credentials: Any): + with pytest.raises(ConfigurationError, match="must be a CredentialProvider"): + registry.get_backend(LOCAL_SLURM, credentials) + @pytest.mark.parametrize( "config", [{"kind": "arc", "endpoint": "https://arc.example.org:443"}, CLOUD], @@ -104,18 +134,18 @@ def test_valid_but_not_shipped_yet(self, config: dict[str, Any]): with pytest.raises( RegistryError, match=f"backend '{config['kind']}' is not available" ): - registry.get_backend(config) + registry.get_backend(config, CREDENTIALS) def test_composed_part_not_shipped_yet(self): with pytest.raises(RegistryError, match="transport 'ssh' is not available"): - registry.get_backend(SSH_SLURM) + registry.get_backend(SSH_SLURM, CREDENTIALS) def test_an_entry_that_cannot_be_imported( self, composed: None, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setitem(registry._TRANSPORTS, "ssh", "tests.unit.does_not_exist:X") with pytest.raises(RegistryError, match="cannot be imported") as exc_info: - registry.get_backend(SSH_SLURM) + registry.get_backend(SSH_SLURM, CREDENTIALS) assert isinstance(exc_info.value.__cause__, ImportError) @pytest.mark.parametrize( @@ -138,7 +168,7 @@ def test_what_breaks_its_protocol_is_refused( getattr(registry, table), name, f"{DUMMIES}:BuildsTheWrongThing" ) with pytest.raises(RegistryError, match=f"which is not a {protocol}"): - registry.get_backend(LOCAL_SLURM) + registry.get_backend(LOCAL_SLURM, CREDENTIALS) class TestAvailable: @@ -170,5 +200,5 @@ def test_only_the_requested_entry_is_imported( monkeypatch.delitem(sys.modules, DUMMIES, raising=False) registry.available() assert DUMMIES not in sys.modules # listing imports nothing - registry.get_backend(LOCAL_SLURM) + registry.get_backend(LOCAL_SLURM, CREDENTIALS) assert DUMMIES in sys.modules