Skip to content

Feat: credential and auth model (tokens, delagation) - #30

Open
Alexander-Auer wants to merge 73 commits into
DIRACGrid:mainfrom
Alexander-Auer:stack/credential-models
Open

Alexander-Auer wants to merge 73 commits into
DIRACGrid:mainfrom
Alexander-Auer:stack/credential-models

Conversation

@Alexander-Auer

@Alexander-Auer Alexander-Auer commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

feat: credentials (models, provider, freshness)

Part of #17. Follows #29 (merged).

Backends get their credentials from a provider that the consumer owns. Configuration never holds a secret.

A backend authenticates with exactly one credential. X.509 proxies are not supported, so nothing needs a token and a proxy together.

How a consumer uses it

The consumer supplies the credentials: it creates a CredentialProvider, passes it to get_backend, and closes it when done. interCEde only decides when to ask, never where credentials come from, and never closes the provider.

A provider is any object with an async get method and async with support. get returns one credential, of the kind the backend asks for:

from intercede import registry
from intercede.auth import BearerToken, CredentialRequirements

class MyTokenProvider:
    async def get(self, requirements: CredentialRequirements) -> BearerToken:
        # requirements.resource is the endpoint asking; requirements.scopes the operation's scopes
        token = await my_token_service.mint(audience=requirements.resource)
        return BearerToken(value=token.value, expires_at=token.expires_at)

    async def __aenter__(self): return self
    async def __aexit__(self, *exc): ...

async with MyTokenProvider() as credentials:
    async with registry.get_backend(config, credentials) as ce:
        await ce.submit(spec, copies)

For fixed credentials, for example a key read from a file, the consumer can use StaticCredentials instead of writing a provider:

from intercede.auth import SSHPrivateKey, StaticCredentials

async with StaticCredentials([SSHPrivateKey(pem=key_bytes)]) as credentials:
    ...

One provider can be shared by every backend the consumer drives.

What's added

  • intercede.auth (public):
    • Credential types: BearerToken, SSHPrivateKey, SSHPassword. Secrets never appear in repr, JSON or error messages.
    • CredentialRequirements, which names the one kind a backend needs, and the CredentialProvider protocol.
    • StaticCredentials. It holds at most one credential of each kind, so it can serve several backends, and each backend gets only the one it asks for. Asking for a kind it does not hold raises an AuthenticationError.
  • Requirements (internal): each backend works out the one credential it needs from its config:
    • ARC and HTCondor-CE ask for a token for their endpoint. Their auth field only accepts "token".
    • SSH asks for a key or a password for user@host:port, as its auth field says.
    • Local, cloud and SSH through an agent ask for nothing.
  • Freshness (internal): before each operation, a backend asks the provider again only when it has no credential yet, or when its credential expires within 5 minutes. A provider that fails, returns anything other than the requested kind, or returns an expired credential causes an AuthenticationError for the whole operation, never a per-job failure.
  • Registry: get_backend(config, credentials) passes the provider to backends and transports. Anything that isn't a provider raises a ConfigurationError. Building a backend never calls the provider.

Breaking change

registry.get_backend(config) now takes a second argument, credentials.

Open points

  • There is no credential type for cloud API keys yet.
  • Should a provider be able to change the 5-minute margin?

Tests

155 tests pass on Python 3.11 and 3.13, with 99% coverage. test_credentials.py imports intercede._backends.credentials directly, because no real backend uses it yet.

alexander and others added 30 commits October 2, 2026 16:54
* feat: Models the core contract needs

* Feat: Protocols and Exceptions added for the core contract

* Feat: Private protocols for the BatchBackend

* feat: a fake backend to run unit tests against

* fix: some code cleanup in the helper functions

* feat: setup for the unit test suite

* chore: style fix

* chore: styling, unit test fixture

* chore: single letter var `h` refering to hanlde changed to `handle`

* fix: unused parameter in `test_injected_failure_is_reported_per_job()` removed

* feat: Models the core contract needs

* Feat: Protocols and Exceptions added for the core contract

* Feat: Private protocols for the BatchBackend

* feat: a fake backend to run unit tests against

* fix: some code cleanup in the helper functions

* feat: setup for the unit test suite

* chore: style fix

* chore: styling, unit test fixture

* chore: single letter var `h` refering to hanlde changed to `handle`

* fix: unused parameter in `test_injected_failure_is_reported_per_job()` removed

* fix: made the OutputSpec optional as it is not suppoerted by all and a consumer should choose to use it or not

---------

Co-authored-by: alexander <alexander.auer@cern.ch>
alexander added 18 commits October 26, 2026 09:53
- Google-style docstrings with Args, Returns and Raises for resolve() and every registry and configuration helper, and docstrings for the settings classes; ruff now checks docstrings against the Google convention.
- Remove vm_max_lifetime_s: removing VMs after a lifetime is the consumer's policy, not interCEde's.
- Import Callable from collections.abc, like the rest of the code.
A verb says that the call builds something, and get_backend() no longer reads like the backend itself.
ARCConfig, HTCondorCEConfig, CloudConfig, BatchLocalConfig and BatchSSHConfig
replace the flat BackendConfig, which is now their union, chosen by kind (and
by transport for batch). A consumer can still pass a plain mapping, such as a
TOML table.

- Every field is declared once, on the model of its kind: the internal
  settings classes, the hand-kept field lists and resolve() are gone, so a
  configuration is validated once instead of twice.
- validate_config() checks a configuration and returns its model;
  registry.get_backend() uses it. A model is checked again, so one changed
  with model_copy() cannot carry fields of another transport to a backend.
- Errors never repeat a value they were given, since it may be a secret, and
  name the expected fields when a field does not belong to the kind.
- The configuration tests use the public API only.

The SSH auth mode is now one value (auth = "key") instead of a list.
griffe-pydantic renders every field of each configuration model, inherited
ones included, with its type, default and description, so the reference cannot
drift from the code like the hand-written table could.
The merge of stack/registry kept a helper and a section header from the old test_config.py; the helper referred to resolve() and BackendConfig.model_validate(), which no longer exist.
Brings in the per-kind configuration models of stack/registry, and moves the
credential code onto them:

- credential_requirements(config, scopes) in _backends/credentials.py replaces
  the methods on the removed internal settings classes, with the same rules:
  a CE asks for its endpoint with the configured token and/or proxy, SSH for
  user@host:port unless an SSH agent holds the key, and a local batch system
  for nothing.
- registry.get_backend(config, credentials) checks the provider and passes it
  to the parts that authenticate: single-class backends and transports.
- The credential requirement tests move from test_config.py to
  test_credentials.py and build their configurations through validate_config().
Two tests had moved without changing, which only made the diff harder to read.
# Conflicts:
#	src/intercede/_backends/fake.py
#	src/intercede/registry.py
#	tests/unit/conftest.py
#	tests/unit/test_registry.py
A value given by mistake, such as a misspelled field or plain data in a
CredentialSet, may be the secret itself. Validation errors now hide their
input, as the configuration models already do.
The message comes from the consumer's code and may hold a token or a
secret URL. The AuthenticationError names only its type; the exception
itself is still available as __cause__.
The fields were described in comments, which the generated reference does
not show. They now use attribute docstrings, as the configuration models do.
Name every argument and the return value, and drop the blank lines
before closing quotes.
@Alexander-Auer
Alexander-Auer requested a review from aldbr October 27, 2026 16:17
@Alexander-Auer
Alexander-Auer marked this pull request as ready for review October 27, 2026 16:18
Alexander-Auer pushed a commit to Alexander-Auer/intercede that referenced this pull request Oct 27, 2026
… into feat/HTCondorCEBackend

Replaces the earlier versions this branch was built on with what was merged
upstream (DIRACGrid#27, DIRACGrid#28, DIRACGrid#29) and the credentials under review (DIRACGrid#30). The HTCondor-CE
schedd setting moves onto HTCondorCEConfig.
@Alexander-Auer Alexander-Auer linked an issue Oct 29, 2026 that may be closed by this pull request
4 tasks
@Alexander-Auer

Copy link
Copy Markdown
Contributor Author

@aldbr I removed the x509 proxy, so I think it's ready for a review

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.

Comment thread src/intercede/config.py Outdated
if not self.auth:
raise ValueError("auth must name at least one of 'token', 'proxy'")
return self
auth: Literal["token"] = "token"

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.

Just wondering whether it's necessary (same for HTCondorCEConfig.auth)

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.

I removed them, they were indeed not needed

…n, and removed auth value from config as it was not used
@Alexander-Auer Alexander-Auer changed the title Feat: credential and auth model (tokens, proxy, delagation) Feat: credential and auth model (tokens, delagation) Nov 5, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Credential/auth model (tokens, proxies, delegation)

2 participants