osp-uio-integrations 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osp_uio_integrations/__init__.py +17 -0
- osp_uio_integrations/mreg/README.md +131 -0
- osp_uio_integrations/mreg/__init__.py +60 -0
- osp_uio_integrations/mreg/auth/README.md +199 -0
- osp_uio_integrations/mreg/auth/__init__.py +14 -0
- osp_uio_integrations/mreg/auth/interactive.py +66 -0
- osp_uio_integrations/mreg/auth/login.py +118 -0
- osp_uio_integrations/mreg/auth/tokens.py +75 -0
- osp_uio_integrations/mreg/client.py +1088 -0
- osp_uio_integrations/mreg/enums.py +51 -0
- osp_uio_integrations/mreg/exceptions.py +148 -0
- osp_uio_integrations/mreg/hosts/README.md +311 -0
- osp_uio_integrations/mreg/hosts/__init__.py +24 -0
- osp_uio_integrations/mreg/hosts/_network.py +12 -0
- osp_uio_integrations/mreg/hosts/_shared.py +44 -0
- osp_uio_integrations/mreg/hosts/addressing.py +1061 -0
- osp_uio_integrations/mreg/hosts/api.py +53 -0
- osp_uio_integrations/mreg/hosts/cnames.py +366 -0
- osp_uio_integrations/mreg/hosts/contacts.py +258 -0
- osp_uio_integrations/mreg/hosts/history.py +280 -0
- osp_uio_integrations/mreg/hosts/lifecycle.py +386 -0
- osp_uio_integrations/mreg/hosts/models.py +181 -0
- osp_uio_integrations/mreg/hosts/queries.py +285 -0
- osp_uio_integrations/mreg/hosts/records/README.md +299 -0
- osp_uio_integrations/mreg/hosts/records/__init__.py +39 -0
- osp_uio_integrations/mreg/hosts/records/_shared.py +46 -0
- osp_uio_integrations/mreg/hosts/records/api.py +47 -0
- osp_uio_integrations/mreg/hosts/records/hinfo.py +238 -0
- osp_uio_integrations/mreg/hosts/records/loc.py +230 -0
- osp_uio_integrations/mreg/hosts/records/models.py +263 -0
- osp_uio_integrations/mreg/hosts/records/mx.py +268 -0
- osp_uio_integrations/mreg/hosts/records/naptr.py +429 -0
- osp_uio_integrations/mreg/hosts/records/ptr.py +564 -0
- osp_uio_integrations/mreg/hosts/records/srv.py +389 -0
- osp_uio_integrations/mreg/hosts/records/sshfp.py +314 -0
- osp_uio_integrations/mreg/hosts/records/ttl.py +365 -0
- osp_uio_integrations/mreg/hosts/records/txt.py +240 -0
- osp_uio_integrations/mreg/networks/__init__.py +22 -0
- osp_uio_integrations/mreg/networks/api.py +536 -0
- osp_uio_integrations/mreg/networks/models.py +78 -0
- osp_uio_integrations/nivlheim/README.md +12 -0
- osp_uio_integrations/nivlheim/__init__.py +37 -0
- osp_uio_integrations/nivlheim/client.py +722 -0
- osp_uio_integrations/nivlheim/exceptions.py +58 -0
- osp_uio_integrations/nivlheim/models.py +57 -0
- osp_uio_integrations/py.typed +1 -0
- osp_uio_integrations/version.py +3 -0
- osp_uio_integrations-0.1.0.dist-info/METADATA +88 -0
- osp_uio_integrations-0.1.0.dist-info/RECORD +51 -0
- osp_uio_integrations-0.1.0.dist-info/WHEEL +4 -0
- osp_uio_integrations-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared integration clients for UiO-specific infrastructure services.
|
|
2
|
+
|
|
3
|
+
This package provides reusable Python clients and small normalization helpers
|
|
4
|
+
for external UiO systems such as MREG and Nivlheim. It exists to keep raw API
|
|
5
|
+
glue in one place so providers and other callers do not duplicate auth,
|
|
6
|
+
request/response handling, pagination, error mapping, or stable host and
|
|
7
|
+
record normalization.
|
|
8
|
+
|
|
9
|
+
It does not own provider workflow, approval policy, ownership decisions, or
|
|
10
|
+
orchestrator/provider wire contracts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .mreg import MregClient
|
|
14
|
+
from .nivlheim import NivlheimClient
|
|
15
|
+
from .version import __version__
|
|
16
|
+
|
|
17
|
+
__all__ = ["MregClient", "NivlheimClient", "__version__"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# mreg — Package Guide
|
|
2
|
+
|
|
3
|
+
The `osp_uio_integrations.mreg` package is a small synchronous Python client
|
|
4
|
+
for UiO's MREG API. It owns transport, token-backed authentication, request
|
|
5
|
+
metadata, light response normalization, and the explicit host/record APIs
|
|
6
|
+
exposed through `MregClient`.
|
|
7
|
+
|
|
8
|
+
It does not try to be a generic MREG framework, and it does not own provider
|
|
9
|
+
workflow, orchestration policy, or CLI rendering concerns. The package exists
|
|
10
|
+
to give other Python code a boring importable library surface for common MREG
|
|
11
|
+
operations.
|
|
12
|
+
|
|
13
|
+
## What Lives Here
|
|
14
|
+
|
|
15
|
+
- `client.py` — HTTP transport boundary, token handling, one-shot `401`
|
|
16
|
+
retry, request metadata, compatibility fallback for legacy writes
|
|
17
|
+
- `auth/` — login and token helpers
|
|
18
|
+
- `exceptions.py` — stable exception taxonomy used across transport and domain
|
|
19
|
+
operations
|
|
20
|
+
- `hosts/` — explicit host-facing API surface exposed as `client.hosts`
|
|
21
|
+
|
|
22
|
+
## Public Surface
|
|
23
|
+
|
|
24
|
+
Most callers should import from `osp_uio_integrations.mreg`, not from
|
|
25
|
+
individual implementation modules.
|
|
26
|
+
|
|
27
|
+
Primary entrypoints:
|
|
28
|
+
|
|
29
|
+
- `MregClient`
|
|
30
|
+
- `client.hosts`
|
|
31
|
+
- `client.hosts.records`
|
|
32
|
+
|
|
33
|
+
Stable supporting types:
|
|
34
|
+
|
|
35
|
+
- enums such as `AddressFamily`, `SshfpAlgorithm`, `SshfpHashType`
|
|
36
|
+
- exception types such as `RequestFailedError`, `ValidationError`,
|
|
37
|
+
`MutationStateUncertainError`
|
|
38
|
+
- stable return-shape models re-exported from
|
|
39
|
+
`osp_uio_integrations.mreg.hosts` and
|
|
40
|
+
`osp_uio_integrations.mreg.hosts.records`
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from osp_uio_integrations.mreg import MregClient, MutationStateUncertainError
|
|
46
|
+
|
|
47
|
+
client = MregClient(base_url="https://mreg.example.test", token="token")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
host = client.hosts.queries.get("example.uio.no")
|
|
51
|
+
record = client.hosts.records.ptr.add("example.uio.no", "192.0.2.10")
|
|
52
|
+
finally:
|
|
53
|
+
client.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Package Shape
|
|
57
|
+
|
|
58
|
+
The package is deliberately explicit rather than generic.
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
mreg/
|
|
62
|
+
client.py transport + auth + request/response contract
|
|
63
|
+
exceptions.py stable error taxonomy
|
|
64
|
+
auth/ login and token helpers
|
|
65
|
+
hosts/ host composition root and host sub-APIs
|
|
66
|
+
queries.py host lookup + payload interpretation
|
|
67
|
+
lifecycle.py create/delete
|
|
68
|
+
addressing.py A/AAAA add/change/move/remove
|
|
69
|
+
contacts.py host contact mutations
|
|
70
|
+
cnames.py alias workflows
|
|
71
|
+
history.py host history lookups
|
|
72
|
+
records/ host-owned RR modules under client.hosts.records
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The important ownership rule is that `client.py` owns transport behavior,
|
|
76
|
+
while `hosts/` owns endpoint-aware MREG business rules for the host domain.
|
|
77
|
+
Models stay small data shapes; they do not perform I/O themselves.
|
|
78
|
+
|
|
79
|
+
## Error Model
|
|
80
|
+
|
|
81
|
+
The package uses a narrow exception taxonomy rather than exposing
|
|
82
|
+
raw `httpx` or upstream-specific failures directly.
|
|
83
|
+
|
|
84
|
+
- `RequestFailedError` means the request failed in the ordinary sense:
|
|
85
|
+
transport failure, non-success HTTP status, or another request-backed
|
|
86
|
+
problem where the library is not claiming partial mutation state
|
|
87
|
+
- `ValidationError` means the library or MREG rejected the payload as invalid
|
|
88
|
+
- `ResponseDecodeError` means MREG returned a success response the client
|
|
89
|
+
could not interpret into the promised shape
|
|
90
|
+
- `MutationStateUncertainError` means a write likely reached MREG, but the
|
|
91
|
+
client could not safely confirm the resulting state
|
|
92
|
+
|
|
93
|
+
That last exception is the important one for callers doing write workflows.
|
|
94
|
+
MREG is not transactional. When a write succeeds upstream and follow-up
|
|
95
|
+
verification fails, the library reports uncertainty explicitly instead of
|
|
96
|
+
pretending it knows that nothing changed.
|
|
97
|
+
|
|
98
|
+
For multi-step workflows such as address move or bulk record deletion,
|
|
99
|
+
`MutationStateUncertainError` may include `completed_steps` and
|
|
100
|
+
`remaining_steps` so callers can log what probably happened.
|
|
101
|
+
|
|
102
|
+
## Design Boundaries
|
|
103
|
+
|
|
104
|
+
The package does:
|
|
105
|
+
|
|
106
|
+
- normalize request metadata such as `User-Agent` and `X-Correlation-ID`
|
|
107
|
+
- keep a stable exception contract
|
|
108
|
+
- port concrete host and RR workflows from `mreg-cli` into library-shaped
|
|
109
|
+
modules
|
|
110
|
+
- add cheap local validations that clearly pay off
|
|
111
|
+
|
|
112
|
+
The package does not:
|
|
113
|
+
|
|
114
|
+
- implement transactional writes
|
|
115
|
+
- implement rollback or retry frameworks
|
|
116
|
+
- build a generic resource abstraction over all of MREG
|
|
117
|
+
- duplicate all upstream semantic validation locally
|
|
118
|
+
- own CLI prompts outside the optional auth prompt hook
|
|
119
|
+
|
|
120
|
+
When in doubt, prefer explicit module-local logic over shared helper
|
|
121
|
+
machinery.
|
|
122
|
+
|
|
123
|
+
## Read Next
|
|
124
|
+
|
|
125
|
+
- [__init__.py](__init__.py) — public import surface
|
|
126
|
+
- [client.py](client.py) — transport contract
|
|
127
|
+
- [hosts/__init__.py](hosts/__init__.py) — host-facing import surface
|
|
128
|
+
- [hosts/records/__init__.py](hosts/records/__init__.py) — RR-facing import
|
|
129
|
+
surface
|
|
130
|
+
- [docs/mreg-host-library-plan.md](../../../docs/mreg-host-library-plan.md) —
|
|
131
|
+
architectural background
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""MREG client package.
|
|
2
|
+
|
|
3
|
+
This package provides a small synchronous client for the UiO MREG API. It owns
|
|
4
|
+
transport, token handling, and light response normalization for MREG-facing
|
|
5
|
+
callers. The public surface exported here is the intended import root for most
|
|
6
|
+
library users:
|
|
7
|
+
|
|
8
|
+
- `MregClient` for transport and composed domain APIs
|
|
9
|
+
- stable enums used at call boundaries
|
|
10
|
+
- the small exception taxonomy callers are expected to catch
|
|
11
|
+
|
|
12
|
+
It does not own orchestration policy, provider workflow, or command-line
|
|
13
|
+
interaction beyond an optional credentials prompt hook. Concrete submodules
|
|
14
|
+
under `osp_uio_integrations.mreg` remain implementation details unless they are
|
|
15
|
+
re-exported here or documented as public elsewhere.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .client import MregClient
|
|
19
|
+
from .enums import (
|
|
20
|
+
AddressFamily,
|
|
21
|
+
CompatibilityFallback,
|
|
22
|
+
RequestMethod,
|
|
23
|
+
SshfpAlgorithm,
|
|
24
|
+
SshfpHashType,
|
|
25
|
+
ValidationSource,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
AuthenticationFailedError,
|
|
29
|
+
AuthenticationRequiredError,
|
|
30
|
+
ConflictError,
|
|
31
|
+
ForbiddenError,
|
|
32
|
+
MregError,
|
|
33
|
+
MutationStateUncertainError,
|
|
34
|
+
MutationStep,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
RequestFailedError,
|
|
37
|
+
ResponseDecodeError,
|
|
38
|
+
ValidationError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AuthenticationFailedError",
|
|
43
|
+
"AuthenticationRequiredError",
|
|
44
|
+
"AddressFamily",
|
|
45
|
+
"ConflictError",
|
|
46
|
+
"ForbiddenError",
|
|
47
|
+
"CompatibilityFallback",
|
|
48
|
+
"MregClient",
|
|
49
|
+
"MregError",
|
|
50
|
+
"MutationStateUncertainError",
|
|
51
|
+
"MutationStep",
|
|
52
|
+
"NotFoundError",
|
|
53
|
+
"RequestMethod",
|
|
54
|
+
"RequestFailedError",
|
|
55
|
+
"ResponseDecodeError",
|
|
56
|
+
"SshfpAlgorithm",
|
|
57
|
+
"SshfpHashType",
|
|
58
|
+
"ValidationSource",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# auth (MREG Authentication Helpers)
|
|
2
|
+
|
|
3
|
+
The `auth` package owns the token-auth flow: a credentials value object,
|
|
4
|
+
the token-auth POST exchange, an optional interactive prompt, and a token
|
|
5
|
+
storage protocol with an in-memory default.
|
|
6
|
+
|
|
7
|
+
The package exists so the transport client (`mreg/client.py`) can stay
|
|
8
|
+
focused on HTTP behavior while the actual auth contract — request shape,
|
|
9
|
+
status-to-exception mapping, prompt hook, persistence boundary — stays in
|
|
10
|
+
one auditable place.
|
|
11
|
+
|
|
12
|
+
The package owns:
|
|
13
|
+
|
|
14
|
+
- the token-auth wire contract (`POST /api/token-auth/`, form body, JSON
|
|
15
|
+
token response)
|
|
16
|
+
- mapping HTTP status to `AuthenticationFailedError`,
|
|
17
|
+
`RequestFailedError`, or `ResponseDecodeError`
|
|
18
|
+
- the optional interactive prompt used when no token is cached
|
|
19
|
+
- a tiny `TokenStore` protocol so callers can plug in their own
|
|
20
|
+
persistence (keyring, file, secret manager) without changing the client
|
|
21
|
+
|
|
22
|
+
The package does not own:
|
|
23
|
+
|
|
24
|
+
- session, cookie, or SSO flows — token-auth only
|
|
25
|
+
- token refresh on the wire — `MregClient` performs the one-shot 401
|
|
26
|
+
retry by re-issuing login and replaying the request once
|
|
27
|
+
- credential discovery — env-var, config-file, or vault lookups belong in
|
|
28
|
+
the calling application
|
|
29
|
+
|
|
30
|
+
## What lives here
|
|
31
|
+
|
|
32
|
+
- **`__init__.py`** — re-exports the small public surface.
|
|
33
|
+
- **`login.py`** — `Credentials` value object and `login_for_token()`,
|
|
34
|
+
the only place that constructs the token-auth request and classifies
|
|
35
|
+
its responses.
|
|
36
|
+
- **`tokens.py`** — `TokenStore` `Protocol` and `MemoryTokenStore`
|
|
37
|
+
default implementation.
|
|
38
|
+
- **`interactive.py`** — `CredentialPrompt` `Protocol` and
|
|
39
|
+
`prompt_for_credentials`, the default stdin/getpass prompt used when
|
|
40
|
+
the client needs credentials and the caller has not supplied a hook.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Layer guides
|
|
45
|
+
|
|
46
|
+
### `login.py` — login_for_token
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
def login_for_token(
|
|
50
|
+
client: httpx.Client,
|
|
51
|
+
*,
|
|
52
|
+
credentials: Credentials,
|
|
53
|
+
correlation_id: str | None = None,
|
|
54
|
+
) -> str
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`login_for_token` accepts a plain `httpx.Client` rather than a
|
|
58
|
+
`MregClient` because the MREG transport boundary itself uses it during
|
|
59
|
+
construction. It:
|
|
60
|
+
|
|
61
|
+
- POSTs `username` / `password` as a form body to `/api/token-auth/`
|
|
62
|
+
- forwards `X-Correlation-ID` when provided
|
|
63
|
+
- maps `401`/`403` to `AuthenticationFailedError`
|
|
64
|
+
- maps any other `>=400` to `RequestFailedError`
|
|
65
|
+
- maps non-JSON or token-less success bodies to `ResponseDecodeError`
|
|
66
|
+
- returns the issued token string on success
|
|
67
|
+
|
|
68
|
+
`Credentials` is a frozen, slotted dataclass holding `username` and
|
|
69
|
+
`password`. Treat it as a short-lived value object; do not log it.
|
|
70
|
+
|
|
71
|
+
### `tokens.py` — TokenStore protocol
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
class TokenStore(Protocol):
|
|
75
|
+
def load_token(self, *, base_url, username) -> str | None: ...
|
|
76
|
+
def save_token(self, *, base_url, username, token) -> None: ...
|
|
77
|
+
def clear_token(self, *, base_url, username) -> None: ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The protocol is keyed on `(base_url, username)` so a single store can
|
|
81
|
+
hold tokens for multiple MREG instances or multiple users. `username`
|
|
82
|
+
may be `None` when the caller does not pin one.
|
|
83
|
+
|
|
84
|
+
`MemoryTokenStore` is the default. It is suitable for tests and
|
|
85
|
+
short-lived processes; long-lived tools should pass a custom
|
|
86
|
+
implementation backed by their preferred secret store.
|
|
87
|
+
|
|
88
|
+
### `interactive.py` — CredentialPrompt protocol
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
class CredentialPrompt(Protocol):
|
|
92
|
+
def __call__(self, *, base_url, username: str | None) -> Credentials: ...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`prompt_for_credentials` is the default implementation. It uses `input()`
|
|
96
|
+
for the username (when not supplied) and `getpass()` for the password.
|
|
97
|
+
The protocol is what the client expects, so callers can plug in
|
|
98
|
+
non-interactive credential providers (config-file lookups, secret
|
|
99
|
+
manager fetches, test fakes) without touching `MregClient`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## How `MregClient` uses these helpers
|
|
104
|
+
|
|
105
|
+
`MregClient.__init__` accepts both a `credential_prompt` and a
|
|
106
|
+
`token_store`. Each defaults to the helpers above:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
self._credential_prompt = credential_prompt or prompt_for_credentials
|
|
110
|
+
self._token_store = token_store or MemoryTokenStore()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The auth flow at request time is:
|
|
114
|
+
|
|
115
|
+
1. If a token is already cached for `(base_url, username)`, reuse it.
|
|
116
|
+
2. On `401` from a real request, drop the cached token and prompt the
|
|
117
|
+
`CredentialPrompt` for credentials.
|
|
118
|
+
3. Call `login_for_token(...)`; persist the returned token through
|
|
119
|
+
`TokenStore.save_token(...)`.
|
|
120
|
+
4. Replay the original request once with the new token. Any further
|
|
121
|
+
`401` is surfaced to the caller.
|
|
122
|
+
|
|
123
|
+
The package never retries beyond that one-shot refresh. Repeated 401s
|
|
124
|
+
are treated as configuration or credential problems and propagate as
|
|
125
|
+
`AuthenticationFailedError`.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Design recipes
|
|
130
|
+
|
|
131
|
+
### Why a `Protocol` for `TokenStore` and `CredentialPrompt`
|
|
132
|
+
|
|
133
|
+
Using `Protocol` (instead of an abstract base class) lets callers plug in
|
|
134
|
+
keyring-backed storage, environment-driven credential providers, or test
|
|
135
|
+
fakes without inheriting from anything in this package. `MregClient`
|
|
136
|
+
depends on the shape, not the type.
|
|
137
|
+
|
|
138
|
+
### Why credentials are a frozen dataclass, not raw strings
|
|
139
|
+
|
|
140
|
+
`Credentials` keeps the username and password together at the call site,
|
|
141
|
+
makes accidental mutation noisy, and gives type-checkers a stable type
|
|
142
|
+
to reason about. It does not implement `__repr__` redaction; do not log
|
|
143
|
+
instances.
|
|
144
|
+
|
|
145
|
+
### Why login error mapping lives here
|
|
146
|
+
|
|
147
|
+
Status-to-exception mapping for `/api/token-auth/` belongs next to the
|
|
148
|
+
endpoint that defines it, not in transport. Keeping it here means
|
|
149
|
+
`mreg/client.py` does not need to know the difference between a 401
|
|
150
|
+
during login and a 401 during a normal data request.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Design rules
|
|
155
|
+
|
|
156
|
+
- **No retry policy in this package.** `login_for_token` issues exactly
|
|
157
|
+
one POST. Retries and the one-shot refresh dance live in the client.
|
|
158
|
+
- **`TokenStore` keys are `(base_url, username)`.** Callers pin
|
|
159
|
+
`username` when they want per-user isolation; otherwise stores hold
|
|
160
|
+
one token per base URL.
|
|
161
|
+
- **`prompt_for_credentials` is best-effort interactive.** It assumes a
|
|
162
|
+
TTY. Headless callers must inject their own `CredentialPrompt`
|
|
163
|
+
implementation.
|
|
164
|
+
- **No HTTP session is shared between login and request.** The client
|
|
165
|
+
drives both, so this package does not assume anything about cookies
|
|
166
|
+
or persistent auth headers.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Common issues
|
|
171
|
+
|
|
172
|
+
- **`AuthenticationFailedError` on every request.** Either the cached
|
|
173
|
+
token is wrong (clear it via `TokenStore.clear_token(...)`) or the
|
|
174
|
+
account/password is invalid for the supplied `base_url`.
|
|
175
|
+
- **Repeated prompts in a loop.** The one-shot refresh ran but the new
|
|
176
|
+
token was also rejected. Verify `Credentials.username` is the correct
|
|
177
|
+
identity for that `base_url`.
|
|
178
|
+
- **`ResponseDecodeError: MREG login response did not include a token`.**
|
|
179
|
+
MREG returned 2xx but the body was empty or did not carry a `token`
|
|
180
|
+
field. Treat as a transient infrastructure problem; do not auto-retry.
|
|
181
|
+
- **`RequestFailedError` during login.** Network or non-401/403 HTTP
|
|
182
|
+
failure. Inspect `status_code` and any `X-Request-Id` on the exception.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Mini cheat sheet
|
|
187
|
+
|
|
188
|
+
| I want to … | Use |
|
|
189
|
+
|---|---|
|
|
190
|
+
| Plug in custom credential lookup | Pass `credential_prompt=` to `MregClient` |
|
|
191
|
+
| Plug in custom token persistence | Pass `token_store=` to `MregClient` |
|
|
192
|
+
| Authenticate manually | `login_for_token(httpx_client, credentials=Credentials(...))` |
|
|
193
|
+
| Drop a cached token | `token_store.clear_token(base_url=..., username=...)` |
|
|
194
|
+
| Use a non-interactive flow | Implement `CredentialPrompt`; never call `prompt_for_credentials` |
|
|
195
|
+
|
|
196
|
+
See [../README.md](../README.md) for the package-level surface,
|
|
197
|
+
[../client.py](../client.py) for how the prompt and token store are wired
|
|
198
|
+
into the transport, and [../exceptions.py](../exceptions.py) for the full
|
|
199
|
+
exception taxonomy.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Authentication helpers for the MREG client."""
|
|
2
|
+
|
|
3
|
+
from .interactive import CredentialPrompt, prompt_for_credentials
|
|
4
|
+
from .login import Credentials, login_for_token
|
|
5
|
+
from .tokens import MemoryTokenStore, TokenStore
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CredentialPrompt",
|
|
9
|
+
"Credentials",
|
|
10
|
+
"MemoryTokenStore",
|
|
11
|
+
"TokenStore",
|
|
12
|
+
"login_for_token",
|
|
13
|
+
"prompt_for_credentials",
|
|
14
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Interactive helpers for obtaining MREG credentials.
|
|
2
|
+
|
|
3
|
+
The MREG client takes a :class:`CredentialPrompt` hook that it invokes
|
|
4
|
+
when no usable token is cached and a fresh login is required. The default
|
|
5
|
+
hook is the simple stdin/getpass prompt defined here, but the protocol is
|
|
6
|
+
public so callers can plug in headless credential providers (config-file
|
|
7
|
+
lookup, secret-manager fetch, test fakes) without touching the transport
|
|
8
|
+
layer.
|
|
9
|
+
|
|
10
|
+
This module owns only the interactive default and the protocol shape. It
|
|
11
|
+
does not own token storage (see ``tokens.py``) or the token-auth wire
|
|
12
|
+
exchange (see ``login.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from getpass import getpass
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from .login import Credentials
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CredentialPrompt(Protocol):
|
|
24
|
+
"""Callable hook used by the MREG client to obtain login credentials.
|
|
25
|
+
|
|
26
|
+
Implementations may prompt the user, read from a config file, fetch
|
|
27
|
+
from a secret manager, or return canned credentials in tests. The
|
|
28
|
+
protocol intentionally mirrors a single call rather than a stateful
|
|
29
|
+
object so non-interactive providers stay trivial to implement.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __call__(self, *, base_url: str, username: str | None) -> Credentials:
|
|
33
|
+
"""Return :class:`Credentials` for the supplied MREG instance.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
base_url: MREG base URL the credentials should authenticate
|
|
37
|
+
against. Useful for prompts that surface the target host
|
|
38
|
+
or for providers that pick a per-environment secret.
|
|
39
|
+
username: Username already known to the client, or ``None``
|
|
40
|
+
when the caller has not pinned one. Implementations may
|
|
41
|
+
reuse the value or prompt for a fresh username.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prompt_for_credentials(*, base_url: str, username: str | None) -> Credentials:
|
|
46
|
+
"""Prompt the operator for an MREG username and password on stdin.
|
|
47
|
+
|
|
48
|
+
This is the default :class:`CredentialPrompt` used by ``MregClient``
|
|
49
|
+
when none is supplied. It assumes a real TTY: the username is read
|
|
50
|
+
from ``input()`` (when not already known) and the password is read
|
|
51
|
+
from ``getpass()`` so it does not echo. Headless callers should
|
|
52
|
+
replace this hook entirely rather than depending on terminal
|
|
53
|
+
behavior.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
base_url: MREG base URL surfaced in the username prompt so the
|
|
57
|
+
operator can tell which instance is asking.
|
|
58
|
+
username: Username already known to the client, or ``None`` to
|
|
59
|
+
prompt for one.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A :class:`Credentials` value object to feed into ``login_for_token``.
|
|
63
|
+
"""
|
|
64
|
+
prompt_username = username or input(f"Username for {base_url}: ").strip()
|
|
65
|
+
password = getpass(f"Password for {prompt_username}: ")
|
|
66
|
+
return Credentials(username=prompt_username, password=password)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Token login helpers for the MREG client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..enums import RequestMethod
|
|
11
|
+
from ..exceptions import AuthenticationFailedError, RequestFailedError, ResponseDecodeError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Credentials:
|
|
16
|
+
"""Credentials used to authenticate against MREG."""
|
|
17
|
+
|
|
18
|
+
username: str
|
|
19
|
+
password: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def login_for_token(
|
|
23
|
+
client: httpx.Client,
|
|
24
|
+
*,
|
|
25
|
+
credentials: Credentials,
|
|
26
|
+
correlation_id: str | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Exchange username/password credentials for one MREG API token.
|
|
29
|
+
|
|
30
|
+
The transport client owns token lifecycle, but the actual token-auth
|
|
31
|
+
protocol is isolated here so the request shape and its error mapping stay
|
|
32
|
+
easy to audit. The function accepts a plain ``httpx.Client`` because it is
|
|
33
|
+
used by the MREG transport boundary itself, not by domain modules.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
client: Configured ``httpx.Client`` used to reach the token-auth
|
|
37
|
+
endpoint.
|
|
38
|
+
credentials: Username/password pair to exchange for a token.
|
|
39
|
+
correlation_id: Optional correlation id propagated to the auth request
|
|
40
|
+
for traceability.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Newly issued MREG API token.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
AuthenticationFailedError: MREG rejected the supplied credentials.
|
|
47
|
+
RequestFailedError: Network failure or unexpected upstream HTTP status
|
|
48
|
+
prevented successful login.
|
|
49
|
+
ResponseDecodeError: Login succeeded but the response body did not
|
|
50
|
+
contain a usable token.
|
|
51
|
+
"""
|
|
52
|
+
endpoint = "/api/token-auth/"
|
|
53
|
+
try:
|
|
54
|
+
response = client.post(
|
|
55
|
+
endpoint,
|
|
56
|
+
data={
|
|
57
|
+
"username": credentials.username,
|
|
58
|
+
"password": credentials.password,
|
|
59
|
+
},
|
|
60
|
+
headers=_auth_headers(correlation_id),
|
|
61
|
+
)
|
|
62
|
+
except httpx.HTTPError as exc:
|
|
63
|
+
raise RequestFailedError(
|
|
64
|
+
"MREG login request failed",
|
|
65
|
+
method=RequestMethod.POST,
|
|
66
|
+
endpoint=endpoint,
|
|
67
|
+
correlation_id=correlation_id,
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
if response.status_code in (401, 403):
|
|
71
|
+
raise AuthenticationFailedError(
|
|
72
|
+
"MREG rejected the supplied credentials",
|
|
73
|
+
method=RequestMethod.POST,
|
|
74
|
+
status_code=response.status_code,
|
|
75
|
+
endpoint=endpoint,
|
|
76
|
+
request_id=response.headers.get("X-Request-Id"),
|
|
77
|
+
correlation_id=response.headers.get("X-Correlation-ID") or correlation_id,
|
|
78
|
+
)
|
|
79
|
+
if response.status_code >= 400:
|
|
80
|
+
raise RequestFailedError(
|
|
81
|
+
f"MREG login failed with status {response.status_code}",
|
|
82
|
+
method=RequestMethod.POST,
|
|
83
|
+
status_code=response.status_code,
|
|
84
|
+
endpoint=endpoint,
|
|
85
|
+
request_id=response.headers.get("X-Request-Id"),
|
|
86
|
+
correlation_id=response.headers.get("X-Correlation-ID") or correlation_id,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
payload: Any = response.json()
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise ResponseDecodeError(
|
|
93
|
+
"MREG login response was not valid JSON",
|
|
94
|
+
method=RequestMethod.POST,
|
|
95
|
+
status_code=response.status_code,
|
|
96
|
+
endpoint=endpoint,
|
|
97
|
+
request_id=response.headers.get("X-Request-Id"),
|
|
98
|
+
correlation_id=response.headers.get("X-Correlation-ID") or correlation_id,
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
token = payload.get("token") if isinstance(payload, dict) else None
|
|
102
|
+
if not isinstance(token, str) or not token:
|
|
103
|
+
raise ResponseDecodeError(
|
|
104
|
+
"MREG login response did not include a token",
|
|
105
|
+
method=RequestMethod.POST,
|
|
106
|
+
status_code=response.status_code,
|
|
107
|
+
endpoint=endpoint,
|
|
108
|
+
request_id=response.headers.get("X-Request-Id"),
|
|
109
|
+
correlation_id=response.headers.get("X-Correlation-ID") or correlation_id,
|
|
110
|
+
)
|
|
111
|
+
return token
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _auth_headers(correlation_id: str | None) -> dict[str, str]:
|
|
115
|
+
"""Build token-auth headers, forwarding a correlation id when available."""
|
|
116
|
+
if not correlation_id:
|
|
117
|
+
return {}
|
|
118
|
+
return {"X-Correlation-ID": correlation_id}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Token storage abstractions for the MREG client.
|
|
2
|
+
|
|
3
|
+
The transport client never hardcodes how API tokens are persisted. Instead
|
|
4
|
+
it depends on the small :class:`TokenStore` ``Protocol`` defined here, so
|
|
5
|
+
callers can plug in keyring-backed storage, file-backed storage, secret
|
|
6
|
+
managers, or test fakes without changing the client. The
|
|
7
|
+
:class:`MemoryTokenStore` shipped here is the safe default for tests and
|
|
8
|
+
short-lived processes.
|
|
9
|
+
|
|
10
|
+
This module owns the persistence boundary contract and one in-memory
|
|
11
|
+
implementation. It does not own credential collection (see
|
|
12
|
+
``interactive.py``) or the token-auth wire flow (see ``login.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import MutableMapping
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenStore(Protocol):
|
|
22
|
+
"""Persistence interface used by the MREG client to cache API tokens.
|
|
23
|
+
|
|
24
|
+
All operations are keyed on ``(base_url, username)`` so a single store
|
|
25
|
+
can hold tokens for multiple MREG instances and multiple identities at
|
|
26
|
+
the same time. ``username`` may be ``None`` when the calling code does
|
|
27
|
+
not pin a specific user identity to the cached token.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def load_token(self, *, base_url: str, username: str | None) -> str | None:
|
|
31
|
+
"""Return a previously persisted token, or ``None`` if none is cached."""
|
|
32
|
+
|
|
33
|
+
def save_token(self, *, base_url: str, username: str | None, token: str) -> None:
|
|
34
|
+
"""Persist ``token`` for the supplied ``base_url`` / ``username`` key."""
|
|
35
|
+
|
|
36
|
+
def clear_token(self, *, base_url: str, username: str | None) -> None:
|
|
37
|
+
"""Drop any persisted token for the supplied key.
|
|
38
|
+
|
|
39
|
+
Called by the client when a cached token is rejected by MREG (for
|
|
40
|
+
example after a 401) so that the next request triggers a fresh
|
|
41
|
+
login instead of reusing the known-bad token.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MemoryTokenStore:
|
|
46
|
+
"""Process-local in-memory implementation of :class:`TokenStore`.
|
|
47
|
+
|
|
48
|
+
Suitable for tests and short-lived CLI invocations. Long-running tools
|
|
49
|
+
should pass a custom store backed by their preferred secret manager so
|
|
50
|
+
tokens survive across processes and are not held in plain Python
|
|
51
|
+
memory longer than needed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, initial: MutableMapping[tuple[str, str | None], str] | None = None) -> None:
|
|
55
|
+
"""Create a store, optionally seeded with already-known tokens.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
initial: Optional mapping of ``(base_url, username)`` to token
|
|
59
|
+
used to preload the store. Useful in tests that need to
|
|
60
|
+
exercise the cached-token path without going through a
|
|
61
|
+
real login.
|
|
62
|
+
"""
|
|
63
|
+
self._tokens: dict[tuple[str, str | None], str] = dict(initial or {})
|
|
64
|
+
|
|
65
|
+
def load_token(self, *, base_url: str, username: str | None) -> str | None:
|
|
66
|
+
"""Return the cached token for the key, or ``None`` if absent."""
|
|
67
|
+
return self._tokens.get((base_url, username))
|
|
68
|
+
|
|
69
|
+
def save_token(self, *, base_url: str, username: str | None, token: str) -> None:
|
|
70
|
+
"""Replace any existing cached token for the supplied key."""
|
|
71
|
+
self._tokens[(base_url, username)] = token
|
|
72
|
+
|
|
73
|
+
def clear_token(self, *, base_url: str, username: str | None) -> None:
|
|
74
|
+
"""Drop the cached token for the supplied key if one exists."""
|
|
75
|
+
self._tokens.pop((base_url, username), None)
|