Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
9ef20fc
feat: Models the core contract needs
Oct 2, 2026
771d48a
Feat: Protocols and Exceptions added for the core contract
Oct 2, 2026
5fe30c2
Feat: Private protocols for the BatchBackend
Oct 2, 2026
89d0bc9
feat: a fake backend to run unit tests against
Oct 6, 2026
af4c436
fix: some code cleanup in the helper functions
Oct 6, 2026
99f82ae
feat: setup for the unit test suite
Oct 6, 2026
e041acf
chore: style fix
Oct 6, 2026
d287307
chore: styling, unit test fixture
Oct 6, 2026
e830545
chore: single letter var `h` refering to hanlde changed to `handle`
Oct 6, 2026
be1e998
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
Oct 6, 2026
2aeb589
chore: Making room in local fork for my documentation so i can easily…
Oct 7, 2026
5024ce9
fix: placed in the wrong folder :/
Oct 7, 2026
2c32012
chore: added testplan
Oct 7, 2026
3bf4210
feat: Models the core contract needs
Oct 2, 2026
0a6a9e1
Feat: Protocols and Exceptions added for the core contract
Oct 2, 2026
36cf7df
Feat: Private protocols for the BatchBackend
Oct 2, 2026
357050e
feat: a fake backend to run unit tests against
Oct 6, 2026
56bce86
fix: some code cleanup in the helper functions
Oct 6, 2026
5923545
feat: setup for the unit test suite
Oct 6, 2026
1f92acf
chore: style fix
Oct 6, 2026
f7cf50c
chore: styling, unit test fixture
Oct 6, 2026
bab3989
chore: single letter var `h` refering to hanlde changed to `handle`
Oct 6, 2026
eb3c644
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
Oct 6, 2026
f727cbb
Merge branch 'feat/core-contract' of github.com:Alexander-Auer/interc…
Oct 7, 2026
feaf5e7
fix: made the OutputSpec optional as it is not suppoerted by all and …
Oct 7, 2026
46fd1a3
Feat: Core contract issue #16 (#1)
Alexander-Auer Oct 7, 2026
42dce34
fix: These documents should not exist in this way in the main repository
Oct 7, 2026
d67d380
fix: None check for the outputs in submissionspec
Oct 7, 2026
910b0b0
chore: trailing white spaces
Oct 7, 2026
a9fcca0
chore: fixed pre commit locally
Oct 7, 2026
74c40a7
chore: removed pdf, as they just take up space and no one will use th…
Oct 9, 2026
5cb1ee3
fix: JobHandle key is now a combination of the handle's fields instea…
Oct 19, 2026
b6af530
Merge branch 'main' into feat/core-contract
Alexander-Auer Oct 19, 2026
f90cdd4
feat: backend configuration and a closed registry
Alexander-Auer Oct 19, 2026
9dac6f5
test: expand unit test coverage to every logical case
Alexander-Auer Oct 20, 2026
0338aa2
feat: credential models and provider-based supply
Alexander-Auer Oct 20, 2026
ceed1df
Merge branch 'main' into feat/core-contract
Alexander-Auer Oct 23, 2026
d310fe8
test: trim the unit tests to logical cases that can happen
Oct 23, 2026
529fa75
Merge branch 'feat/core-contract' into stack/registry
Oct 23, 2026
5d94d0f
test: trim the registry and configuration tests to logical cases
Oct 23, 2026
60906ef
Merge branch 'stack/registry' into stack/test-expansion
Oct 23, 2026
ae6ca0a
test: trim the expanded tests to logical cases that can happen
Oct 23, 2026
359ece8
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 23, 2026
f7e5eea
test: trim the credential tests to logical cases that can happen
Oct 23, 2026
a644ce6
docs: follow the Diátaxis structure for the user documentation
Oct 23, 2026
b6e172f
fix: address the review of the core contract
Oct 23, 2026
bc5619e
Merge branch 'feat/core-contract' into stack/registry
Oct 23, 2026
7391fbe
fix: no references to the ADRs in the registry and configuration
Oct 23, 2026
9c89415
Merge branch 'stack/registry' into stack/test-expansion
Oct 23, 2026
673cfa4
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 23, 2026
ceb871e
fix: no references to the ADRs in the credential code
Oct 23, 2026
a0211b4
Merge DIRACGrid main into stack/registry (the core contract, squash-m…
Oct 26, 2026
5d10b5a
fix: apply the core contract review to the registry
Oct 26, 2026
7d86596
refactor: rename registry.backend() to get_backend()
Oct 26, 2026
b4fb7bd
refactor!: one public configuration model per backend kind
Oct 26, 2026
03efb2a
docs: generate the configuration reference from the models
Oct 26, 2026
2191feb
Merge branch 'stack/registry' into stack/test-expansion
Oct 26, 2026
929ea61
test: remove leftovers of the flat configuration model
Oct 26, 2026
ce1c811
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 26, 2026
af2109f
fix: Rename FakeSettings to FakeConfig to stay in line with the revie…
Oct 26, 2026
23641f6
Merge remote-tracking branch 'origin/stack/registry' into stack/test-…
Oct 27, 2026
6dca5ef
test: keep the existing model tests where they are
Oct 27, 2026
d7e045f
Merge DIRACGrid main into stack/test-expansion (the registry, squash-…
Oct 27, 2026
b94e917
test: a configuration that is not a mapping is refused
Oct 27, 2026
66af4f2
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 27, 2026
e55693f
Merge DIRACGrid main into stack/credential-models (the test expansion…
Oct 27, 2026
ca8d39f
fix: credential errors never repeat the value they were given
Oct 27, 2026
eccfbc6
fix: a provider's error text stays out of the AuthenticationError
Oct 27, 2026
8e2f76c
docs: document the credential fields in the API reference
Oct 27, 2026
2670cfb
docs: Google-style docstrings for the credential functions
Oct 27, 2026
89c3b94
fix: Removed the x509 proxy as it will not be used anymore, and also …
Nov 4, 2026
e3fc956
Merge branch 'main' into stack/credential-models
Alexander-Auer Nov 4, 2026
40fcf80
fix: Added a small credentialcache so each scope can get its own toke…
Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/user/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
192 changes: 192 additions & 0 deletions src/intercede/_backends/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""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 holds a ``CredentialCache``,
and before each operation asks it for that operation's credential: the provider
is asked only when there is none yet or it 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,
SSHPassword,
SSHPrivateKey,
)
from intercede.auth.protocols import CredentialProvider
from intercede.config import (
ARCConfig,
BackendConfig,
BatchSSHConfig,
HTCondorCEConfig,
)
from intercede.exceptions import AuthenticationError, InterCEdeError

# What each configured SSH 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]] = {
"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,
kind=BearerToken,
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}",
kind=AUTH_KINDS[config.auth],
)
return None


def is_stale(
credential: Credential | None,
requirements: CredentialRequirements,
*,
margin: timedelta = DEFAULT_MARGIN,
now: datetime | None = None,
) -> bool:
"""Tell whether ``credential`` cannot serve ``requirements`` for ``margin`` more.

A credential that never expires is never stale. Only the kind and the expiry
are checked, not the scopes, since a token is opaque to interCEde: ``credential``
must have been given for these same requirements, as ``CredentialCache`` ensures.

Args:
credential: The one the backend holds, or None before its first request.
requirements: What the backend needs for the operation about to run.
margin: How long the credential must stay valid from now.
now: The current time; tests pass a fixed one.

Returns:
True when there is no credential yet, when it is of another kind than
required, or when it expires within ``margin``.
"""
if not isinstance(credential, requirements.kind):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can have different tokens for a same backend.
For instance, a token to submit a job with scope compute.create, and another one to get the status with scope compute.read.
I guess that needs to be taken into account here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with a small CredentialCache in _backends/credentials.py.

Why it exists: is_stale only looks at a credential's kind and expiry, not its scopes, because tokens are opaque to interCEde. A backend that kept a single token would treat one fetched for compute.read as fresh for compute.create, and submit with it without asking the provider.

What it does: it keeps one credential per CredentialRequirements, so each set of scopes gets its own token. Before an operation, a backend calls await cache.fresh(requirements), and the provider is only asked if that credential is missing or about to expire. A provider may mint a token on every call and backends poll often, so this keeps those calls down.

What it doesn't do: it isn't a store of secrets. It holds credentials in memory only, for one backend, until that backend closes. Where they come from and how they are renewed stays with the provider.

No backend in this PR uses it yet. The HTCondor-CE backend (#35) keeps its own dict per scope today, and I plan to switch that and the SSH transport to this.

return True
if credential.expires_at is None:
return False
return credential.expires_at - margin <= (now or datetime.now(UTC))


async def refresh_if_stale(
current: Credential | None,
requirements: CredentialRequirements,
provider: CredentialProvider,
*,
margin: timedelta = DEFAULT_MARGIN,
now: datetime | None = None,
) -> Credential:
"""Return ``current`` while it is fresh, otherwise a new one from ``provider``.

Args:
current: The credential 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 credential must stay valid from now.
now: The current time; tests pass a fixed one.

Returns:
The credential to authenticate with: ``current``, or the provider's new one.

Raises:
AuthenticationError: The provider failed, returned something other than
the 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, requirements.kind):
raise AuthenticationError(
f"the credential provider returned a {type(fresh).__name__} "
f"for {requirements.resource}, not a {requirements.kind.__name__}"
)
# 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 an expired credential "
f"for {requirements.resource}"
)
return fresh


class CredentialCache:
"""Keeps one credential per requirements for one backend, so each scope gets its own token.

The provider is asked again only when that credential is missing or about to expire.
"""

def __init__(self, provider: CredentialProvider) -> None:
self._provider = provider
self._held: dict[CredentialRequirements, Credential] = {}

async def fresh(self, requirements: CredentialRequirements) -> Credential:
"""Return the credential for these requirements, asking the provider if needed.

Args:
requirements: What the backend needs for the operation about to run.

Returns:
The credential to authenticate with.

Raises:
AuthenticationError: The provider failed, returned something other than
the required kind, or returned a credential that has already expired.
"""
credential = await refresh_if_stale(
self._held.get(requirements), requirements, self._provider
)
self._held[requirements] = credential
return credential

def clear(self) -> None:
"""Forget every credential, as a backend does when it closes."""
self._held.clear()
11 changes: 9 additions & 2 deletions src/intercede/_backends/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
)
28 changes: 28 additions & 0 deletions src/intercede/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Backend credentials: typed, declared, supplied by a provider.

A consumer implements a ``CredentialProvider``, which returns the one credential
a backend asks for. Configuration never holds a secret: it only says how a backend
logs in, and the provider supplies the credential at run time.
"""

from __future__ import annotations

from intercede.auth.models import (
BearerToken,
Credential,
CredentialRequirements,
SSHPassword,
SSHPrivateKey,
)
from intercede.auth.protocols import CredentialProvider
from intercede.auth.providers import StaticCredentials

__all__ = [
"BearerToken",
"Credential",
"CredentialProvider",
"CredentialRequirements",
"SSHPassword",
"SSHPrivateKey",
"StaticCredentials",
]
106 changes: 106 additions & 0 deletions src/intercede/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""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 typing import Self

from pydantic import (
AwareDatetime,
ConfigDict,
SecretBytes,
SecretStr,
model_validator,
)

from intercede.models import _Model

__all__ = [
"BearerToken",
"Credential",
"CredentialRequirements",
"SSHPassword",
"SSHPrivateKey",
]


# --------------------------------------------------------------------------
# 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 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."""


# --------------------------------------------------------------------------
# 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."""

kind: type[Credential]
"""The kind of credential needed: a backend authenticates with exactly one."""

scopes: frozenset[str] = frozenset()
"""For a token, the scopes of the operation about to run; empty for the
other kinds."""

@model_validator(mode="after")
def _concrete_kind(self) -> Self:
if self.kind is Credential:
raise ValueError("kind must be a concrete kind, not the Credential base")
return self
Loading
Loading