Repository navigation
Feat: credential and auth model (tokens, delagation) #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Alexander-Auer
wants to merge
73
commits into
DIRACGrid:main
Choose a base branch
from
Alexander-Auer:stack/credential-models
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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
771d48a
Feat: Protocols and Exceptions added for the core contract
5fe30c2
Feat: Private protocols for the BatchBackend
89d0bc9
feat: a fake backend to run unit tests against
af4c436
fix: some code cleanup in the helper functions
99f82ae
feat: setup for the unit test suite
e041acf
chore: style fix
d287307
chore: styling, unit test fixture
e830545
chore: single letter var `h` refering to hanlde changed to `handle`
be1e998
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
2aeb589
chore: Making room in local fork for my documentation so i can easily…
5024ce9
fix: placed in the wrong folder :/
2c32012
chore: added testplan
3bf4210
feat: Models the core contract needs
0a6a9e1
Feat: Protocols and Exceptions added for the core contract
36cf7df
Feat: Private protocols for the BatchBackend
357050e
feat: a fake backend to run unit tests against
56bce86
fix: some code cleanup in the helper functions
5923545
feat: setup for the unit test suite
1f92acf
chore: style fix
f7cf50c
chore: styling, unit test fixture
bab3989
chore: single letter var `h` refering to hanlde changed to `handle`
eb3c644
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
f727cbb
Merge branch 'feat/core-contract' of github.com:Alexander-Auer/interc…
feaf5e7
fix: made the OutputSpec optional as it is not suppoerted by all and …
46fd1a3
Feat: Core contract issue #16 (#1)
Alexander-Auer 42dce34
fix: These documents should not exist in this way in the main repository
d67d380
fix: None check for the outputs in submissionspec
910b0b0
chore: trailing white spaces
a9fcca0
chore: fixed pre commit locally
74c40a7
chore: removed pdf, as they just take up space and no one will use th…
5cb1ee3
fix: JobHandle key is now a combination of the handle's fields instea…
b6af530
Merge branch 'main' into feat/core-contract
Alexander-Auer f90cdd4
feat: backend configuration and a closed registry
Alexander-Auer 9dac6f5
test: expand unit test coverage to every logical case
Alexander-Auer 0338aa2
feat: credential models and provider-based supply
Alexander-Auer ceed1df
Merge branch 'main' into feat/core-contract
Alexander-Auer d310fe8
test: trim the unit tests to logical cases that can happen
529fa75
Merge branch 'feat/core-contract' into stack/registry
5d94d0f
test: trim the registry and configuration tests to logical cases
60906ef
Merge branch 'stack/registry' into stack/test-expansion
ae6ca0a
test: trim the expanded tests to logical cases that can happen
359ece8
Merge branch 'stack/test-expansion' into stack/credential-models
f7e5eea
test: trim the credential tests to logical cases that can happen
a644ce6
docs: follow the Diátaxis structure for the user documentation
b6e172f
fix: address the review of the core contract
bc5619e
Merge branch 'feat/core-contract' into stack/registry
7391fbe
fix: no references to the ADRs in the registry and configuration
9c89415
Merge branch 'stack/registry' into stack/test-expansion
673cfa4
Merge branch 'stack/test-expansion' into stack/credential-models
ceb871e
fix: no references to the ADRs in the credential code
a0211b4
Merge DIRACGrid main into stack/registry (the core contract, squash-m…
5d10b5a
fix: apply the core contract review to the registry
7d86596
refactor: rename registry.backend() to get_backend()
b4fb7bd
refactor!: one public configuration model per backend kind
03efb2a
docs: generate the configuration reference from the models
2191feb
Merge branch 'stack/registry' into stack/test-expansion
929ea61
test: remove leftovers of the flat configuration model
ce1c811
Merge branch 'stack/test-expansion' into stack/credential-models
af2109f
fix: Rename FakeSettings to FakeConfig to stay in line with the revie…
23641f6
Merge remote-tracking branch 'origin/stack/registry' into stack/test-…
6dca5ef
test: keep the existing model tests where they are
d7e045f
Merge DIRACGrid main into stack/test-expansion (the registry, squash-…
b94e917
test: a configuration that is not a mapping is refused
66af4f2
Merge branch 'stack/test-expansion' into stack/credential-models
e55693f
Merge DIRACGrid main into stack/credential-models (the test expansion…
ca8d39f
fix: credential errors never repeat the value they were given
eccfbc6
fix: a provider's error text stays out of the AuthenticationError
8e2f76c
docs: document the credential fields in the API reference
2670cfb
docs: Google-style docstrings for the credential functions
89c3b94
fix: Removed the x509 proxy as it will not be used anymore, and also …
e3fc956
Merge branch 'main' into stack/credential-models
Alexander-Auer 40fcf80
fix: Added a small credentialcache so each scope can get its own toke…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
| 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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 scopecompute.read.I guess that needs to be taken into account here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed with a small
CredentialCachein_backends/credentials.py.Why it exists:
is_staleonly 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 forcompute.readas fresh forcompute.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 callsawait 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.