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.
Files changed (51) hide show
  1. osp_uio_integrations/__init__.py +17 -0
  2. osp_uio_integrations/mreg/README.md +131 -0
  3. osp_uio_integrations/mreg/__init__.py +60 -0
  4. osp_uio_integrations/mreg/auth/README.md +199 -0
  5. osp_uio_integrations/mreg/auth/__init__.py +14 -0
  6. osp_uio_integrations/mreg/auth/interactive.py +66 -0
  7. osp_uio_integrations/mreg/auth/login.py +118 -0
  8. osp_uio_integrations/mreg/auth/tokens.py +75 -0
  9. osp_uio_integrations/mreg/client.py +1088 -0
  10. osp_uio_integrations/mreg/enums.py +51 -0
  11. osp_uio_integrations/mreg/exceptions.py +148 -0
  12. osp_uio_integrations/mreg/hosts/README.md +311 -0
  13. osp_uio_integrations/mreg/hosts/__init__.py +24 -0
  14. osp_uio_integrations/mreg/hosts/_network.py +12 -0
  15. osp_uio_integrations/mreg/hosts/_shared.py +44 -0
  16. osp_uio_integrations/mreg/hosts/addressing.py +1061 -0
  17. osp_uio_integrations/mreg/hosts/api.py +53 -0
  18. osp_uio_integrations/mreg/hosts/cnames.py +366 -0
  19. osp_uio_integrations/mreg/hosts/contacts.py +258 -0
  20. osp_uio_integrations/mreg/hosts/history.py +280 -0
  21. osp_uio_integrations/mreg/hosts/lifecycle.py +386 -0
  22. osp_uio_integrations/mreg/hosts/models.py +181 -0
  23. osp_uio_integrations/mreg/hosts/queries.py +285 -0
  24. osp_uio_integrations/mreg/hosts/records/README.md +299 -0
  25. osp_uio_integrations/mreg/hosts/records/__init__.py +39 -0
  26. osp_uio_integrations/mreg/hosts/records/_shared.py +46 -0
  27. osp_uio_integrations/mreg/hosts/records/api.py +47 -0
  28. osp_uio_integrations/mreg/hosts/records/hinfo.py +238 -0
  29. osp_uio_integrations/mreg/hosts/records/loc.py +230 -0
  30. osp_uio_integrations/mreg/hosts/records/models.py +263 -0
  31. osp_uio_integrations/mreg/hosts/records/mx.py +268 -0
  32. osp_uio_integrations/mreg/hosts/records/naptr.py +429 -0
  33. osp_uio_integrations/mreg/hosts/records/ptr.py +564 -0
  34. osp_uio_integrations/mreg/hosts/records/srv.py +389 -0
  35. osp_uio_integrations/mreg/hosts/records/sshfp.py +314 -0
  36. osp_uio_integrations/mreg/hosts/records/ttl.py +365 -0
  37. osp_uio_integrations/mreg/hosts/records/txt.py +240 -0
  38. osp_uio_integrations/mreg/networks/__init__.py +22 -0
  39. osp_uio_integrations/mreg/networks/api.py +536 -0
  40. osp_uio_integrations/mreg/networks/models.py +78 -0
  41. osp_uio_integrations/nivlheim/README.md +12 -0
  42. osp_uio_integrations/nivlheim/__init__.py +37 -0
  43. osp_uio_integrations/nivlheim/client.py +722 -0
  44. osp_uio_integrations/nivlheim/exceptions.py +58 -0
  45. osp_uio_integrations/nivlheim/models.py +57 -0
  46. osp_uio_integrations/py.typed +1 -0
  47. osp_uio_integrations/version.py +3 -0
  48. osp_uio_integrations-0.1.0.dist-info/METADATA +88 -0
  49. osp_uio_integrations-0.1.0.dist-info/RECORD +51 -0
  50. osp_uio_integrations-0.1.0.dist-info/WHEEL +4 -0
  51. 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)