auth 2.5.2__tar.gz → 3.0.1__tar.gz

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 (60) hide show
  1. {auth-2.5.2 → auth-3.0.1}/PKG-INFO +37 -16
  2. {auth-2.5.2 → auth-3.0.1}/README.md +36 -15
  3. {auth-2.5.2 → auth-3.0.1}/auth/__init__.py +9 -0
  4. {auth-2.5.2 → auth-3.0.1}/auth/client.py +93 -104
  5. {auth-2.5.2 → auth-3.0.1}/auth/config.py +58 -42
  6. {auth-2.5.2 → auth-3.0.1}/auth/database.py +152 -4
  7. {auth-2.5.2 → auth-3.0.1}/auth/docs_page.py +117 -31
  8. {auth-2.5.2 → auth-3.0.1}/auth/main.py +17 -5
  9. {auth-2.5.2 → auth-3.0.1}/auth/services/service.py +9 -1
  10. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/PKG-INFO +37 -16
  11. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/SOURCES.txt +1 -0
  12. {auth-2.5.2 → auth-3.0.1}/pyproject.toml +1 -1
  13. {auth-2.5.2 → auth-3.0.1}/tests/test_client.py +51 -50
  14. auth-3.0.1/tests/test_config.py +211 -0
  15. auth-3.0.1/tests/test_strict_default.py +139 -0
  16. auth-2.5.2/tests/test_config.py +0 -79
  17. {auth-2.5.2 → auth-3.0.1}/LICENSE +0 -0
  18. {auth-2.5.2 → auth-3.0.1}/auth/api_keys.py +0 -0
  19. {auth-2.5.2 → auth-3.0.1}/auth/audit.py +0 -0
  20. {auth-2.5.2 → auth-3.0.1}/auth/circuit_breaker.py +0 -0
  21. {auth-2.5.2 → auth-3.0.1}/auth/cmd/__init__.py +0 -0
  22. {auth-2.5.2 → auth-3.0.1}/auth/cmd/server.py +0 -0
  23. {auth-2.5.2 → auth-3.0.1}/auth/core/REST/__init__.py +0 -0
  24. {auth-2.5.2 → auth-3.0.1}/auth/core/REST/client.py +0 -0
  25. {auth-2.5.2 → auth-3.0.1}/auth/core/__init__.py +0 -0
  26. {auth-2.5.2 → auth-3.0.1}/auth/core/models/__init__.py +0 -0
  27. {auth-2.5.2 → auth-3.0.1}/auth/decorators.py +0 -0
  28. {auth-2.5.2 → auth-3.0.1}/auth/encryption.py +0 -0
  29. {auth-2.5.2 → auth-3.0.1}/auth/logging_config.py +0 -0
  30. {auth-2.5.2 → auth-3.0.1}/auth/models/sql.py +0 -0
  31. {auth-2.5.2 → auth-3.0.1}/auth/response_format.py +0 -0
  32. {auth-2.5.2 → auth-3.0.1}/auth/routes.py +0 -0
  33. {auth-2.5.2 → auth-3.0.1}/auth/sanitizer.py +0 -0
  34. {auth-2.5.2 → auth-3.0.1}/auth/server.py +0 -0
  35. {auth-2.5.2 → auth-3.0.1}/auth/validation.py +0 -0
  36. {auth-2.5.2 → auth-3.0.1}/auth/workflow_checker.py +0 -0
  37. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/dependency_links.txt +0 -0
  38. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/entry_points.txt +0 -0
  39. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/requires.txt +0 -0
  40. {auth-2.5.2 → auth-3.0.1}/auth.egg-info/top_level.txt +0 -0
  41. {auth-2.5.2 → auth-3.0.1}/setup.cfg +0 -0
  42. {auth-2.5.2 → auth-3.0.1}/tests/test_api_keys.py +0 -0
  43. {auth-2.5.2 → auth-3.0.1}/tests/test_api_keys_encryption.py +0 -0
  44. {auth-2.5.2 → auth-3.0.1}/tests/test_audit_integrity.py +0 -0
  45. {auth-2.5.2 → auth-3.0.1}/tests/test_client_rest.py +0 -0
  46. {auth-2.5.2 → auth-3.0.1}/tests/test_cmd_server.py +0 -0
  47. {auth-2.5.2 → auth-3.0.1}/tests/test_correctness_hardening.py +0 -0
  48. {auth-2.5.2 → auth-3.0.1}/tests/test_database_sslmode.py +0 -0
  49. {auth-2.5.2 → auth-3.0.1}/tests/test_docs_page.py +0 -0
  50. {auth-2.5.2 → auth-3.0.1}/tests/test_encryption.py +0 -0
  51. {auth-2.5.2 → auth-3.0.1}/tests/test_encryption_integration.py +0 -0
  52. {auth-2.5.2 → auth-3.0.1}/tests/test_flask.py +0 -0
  53. {auth-2.5.2 → auth-3.0.1}/tests/test_key_rotation.py +0 -0
  54. {auth-2.5.2 → auth-3.0.1}/tests/test_log_redaction.py +0 -0
  55. {auth-2.5.2 → auth-3.0.1}/tests/test_phase_a_hardening.py +0 -0
  56. {auth-2.5.2 → auth-3.0.1}/tests/test_reencryption.py +0 -0
  57. {auth-2.5.2 → auth-3.0.1}/tests/test_routes_errors.py +0 -0
  58. {auth-2.5.2 → auth-3.0.1}/tests/test_server.py +0 -0
  59. {auth-2.5.2 → auth-3.0.1}/tests/test_service_lifecycle.py +0 -0
  60. {auth-2.5.2 → auth-3.0.1}/tests/test_strict_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 2.5.2
3
+ Version: 3.0.1
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -80,11 +80,32 @@ BASE=https://auth.rodmena.app
80
80
 
81
81
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/role/engineers
82
82
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/permission/engineers/deploy
83
+ curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/apikeys/user/alice
83
84
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/membership/alice/engineers
84
85
  curl -H "Authorization: Bearer $KEY" $BASE/api/has_permission/alice/deploy
85
86
  # -> {"success": true, "data": {"has_permission": true}, ...}
86
87
  ```
87
88
 
89
+ **Why the `apikeys` call is in there.** Since 3.0.0 a namespace created from a
90
+ brand-new key is **strict**: a user must hold an API key in your namespace
91
+ before it can be given a role. Omit that line and the membership call answers
92
+ `409 {"reason": "user_not_key_backed", "result": false}` — a permanent refusal,
93
+ so retrying never helps. You need not keep the returned secret if you only want
94
+ the identity to exist.
95
+
96
+ If your users can never hold auth API keys (you authenticate them yourself and
97
+ your "users" are opaque ids), opt the namespace out once and skip that step
98
+ forever:
99
+
100
+ ```bash
101
+ curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
102
+ -d '{"strict_users": false}' $BASE/api/settings
103
+ ```
104
+
105
+ The opt-out is audited, per-tenant, and supported indefinitely. Namespaces
106
+ created before 3.0.0 were grandfathered onto it, which is why existing
107
+ integrations saw no change at upgrade.
108
+
88
109
  With the Python client (`pip install auth`):
89
110
 
90
111
  ```python
@@ -93,6 +114,7 @@ from auth import Client
93
114
  with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
94
115
  c.create_role("engineers")
95
116
  c.add_permission("engineers", "deploy")
117
+ c.create_api_key("alice") # strict default since 3.0.0
96
118
  c.add_membership("alice", "engineers")
97
119
  c.user_has_permission("alice", "deploy") # -> {... "has_permission": true}
98
120
  ```
@@ -104,13 +126,11 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
104
126
  - **Two response shapes** — bare `{"result": ...}` and wrapped
105
127
  `{"success", "data", ...}`. The API reference says which per endpoint.
106
128
  - **Errors below 2xx are HTML**, not JSON. Branch on the status code first.
107
- - **Python client: check `success` before reading `data`.** On transport
108
- failure the client does not raise by default — it returns
109
- `{"error", "success": False, "transport_error": True, "data": {...}}` where
110
- `data` only echoes your inputs and does NOT contain the answer field
111
- (`has_permission`, `count`, ...). Reading `data` blindly turns an outage into
112
- a false "no". Pass `Client(..., raise_on_error=True)` to get an
113
- `AuthTransportError` exception instead of the error dict.
129
+ - **Python client: transport failures raise `AuthTransportError`** (3.0.0).
130
+ The 2.x answer-shaped error dict is gone — an outage can never be misread
131
+ as a denial. Catch the exception and map it to your unavailable/503 path,
132
+ never to a permission denial. The old `raise_on_error` constructor argument
133
+ is a deprecated no-op, so 2.x code keeps constructing.
114
134
  - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
115
135
  key out of source control, logs, and URLs — it is the only thing protecting
116
136
  your data. Rotate it with `POST /api/keys/rotate` if it leaks.
@@ -123,14 +143,15 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
123
143
  `check_api_key_permission` (validate + permission in one round trip), plus
124
144
  `get_settings`/`set_strict_users`. All of these also exist on the in-process
125
145
  `Authorization` wrapper for embedded consumers.
126
- - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
127
- key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
128
- `PUT /api/settings {"strict_users": true}` makes keyless users answer
129
- negatively (`user_not_key_backed`), and `POST /api/apikeys/check_permission`
130
- does validate + permission in one round trip. **3.0.0 makes strict identity
131
- the default** (the audited per-tenant opt-out survives for validated
132
- machine-subject architectures) — and ships only after every consuming
133
- platform confirms. Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
146
+ - **Strict user identity is the default for NEW tenants (3.0.0).** A tenant
147
+ namespace created after 3.0.0 requires key-backed users for authorization
148
+ decisions (`user_not_key_backed` answers; key-less membership grants answer
149
+ 409) — create the user's API key first, then grant roles. Every tenant that
150
+ existed before 3.0.0 was **grandfathered** with an explicit
151
+ `strict_users: false` row (nothing changed for them at upgrade), and the
152
+ audited per-tenant opt-out (`PUT /api/settings {"strict_users": false}`)
153
+ survives indefinitely for validated machine-subject architectures.
154
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
134
155
 
135
156
  ## Documentation
136
157
 
@@ -24,11 +24,32 @@ BASE=https://auth.rodmena.app
24
24
 
25
25
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/role/engineers
26
26
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/permission/engineers/deploy
27
+ curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/apikeys/user/alice
27
28
  curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/membership/alice/engineers
28
29
  curl -H "Authorization: Bearer $KEY" $BASE/api/has_permission/alice/deploy
29
30
  # -> {"success": true, "data": {"has_permission": true}, ...}
30
31
  ```
31
32
 
33
+ **Why the `apikeys` call is in there.** Since 3.0.0 a namespace created from a
34
+ brand-new key is **strict**: a user must hold an API key in your namespace
35
+ before it can be given a role. Omit that line and the membership call answers
36
+ `409 {"reason": "user_not_key_backed", "result": false}` — a permanent refusal,
37
+ so retrying never helps. You need not keep the returned secret if you only want
38
+ the identity to exist.
39
+
40
+ If your users can never hold auth API keys (you authenticate them yourself and
41
+ your "users" are opaque ids), opt the namespace out once and skip that step
42
+ forever:
43
+
44
+ ```bash
45
+ curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
46
+ -d '{"strict_users": false}' $BASE/api/settings
47
+ ```
48
+
49
+ The opt-out is audited, per-tenant, and supported indefinitely. Namespaces
50
+ created before 3.0.0 were grandfathered onto it, which is why existing
51
+ integrations saw no change at upgrade.
52
+
32
53
  With the Python client (`pip install auth`):
33
54
 
34
55
  ```python
@@ -37,6 +58,7 @@ from auth import Client
37
58
  with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
38
59
  c.create_role("engineers")
39
60
  c.add_permission("engineers", "deploy")
61
+ c.create_api_key("alice") # strict default since 3.0.0
40
62
  c.add_membership("alice", "engineers")
41
63
  c.user_has_permission("alice", "deploy") # -> {... "has_permission": true}
42
64
  ```
@@ -48,13 +70,11 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
48
70
  - **Two response shapes** — bare `{"result": ...}` and wrapped
49
71
  `{"success", "data", ...}`. The API reference says which per endpoint.
50
72
  - **Errors below 2xx are HTML**, not JSON. Branch on the status code first.
51
- - **Python client: check `success` before reading `data`.** On transport
52
- failure the client does not raise by default — it returns
53
- `{"error", "success": False, "transport_error": True, "data": {...}}` where
54
- `data` only echoes your inputs and does NOT contain the answer field
55
- (`has_permission`, `count`, ...). Reading `data` blindly turns an outage into
56
- a false "no". Pass `Client(..., raise_on_error=True)` to get an
57
- `AuthTransportError` exception instead of the error dict.
73
+ - **Python client: transport failures raise `AuthTransportError`** (3.0.0).
74
+ The 2.x answer-shaped error dict is gone — an outage can never be misread
75
+ as a denial. Catch the exception and map it to your unavailable/503 path,
76
+ never to a permission denial. The old `raise_on_error` constructor argument
77
+ is a deprecated no-op, so 2.x code keeps constructing.
58
78
  - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
59
79
  key out of source control, logs, and URLs — it is the only thing protecting
60
80
  your data. Rotate it with `POST /api/keys/rotate` if it leaks.
@@ -67,14 +87,15 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
67
87
  `check_api_key_permission` (validate + permission in one round trip), plus
68
88
  `get_settings`/`set_strict_users`. All of these also exist on the in-process
69
89
  `Authorization` wrapper for embedded consumers.
70
- - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
71
- key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
72
- `PUT /api/settings {"strict_users": true}` makes keyless users answer
73
- negatively (`user_not_key_backed`), and `POST /api/apikeys/check_permission`
74
- does validate + permission in one round trip. **3.0.0 makes strict identity
75
- the default** (the audited per-tenant opt-out survives for validated
76
- machine-subject architectures) — and ships only after every consuming
77
- platform confirms. Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
90
+ - **Strict user identity is the default for NEW tenants (3.0.0).** A tenant
91
+ namespace created after 3.0.0 requires key-backed users for authorization
92
+ decisions (`user_not_key_backed` answers; key-less membership grants answer
93
+ 409) — create the user's API key first, then grant roles. Every tenant that
94
+ existed before 3.0.0 was **grandfathered** with an explicit
95
+ `strict_users: false` row (nothing changed for them at upgrade), and the
96
+ audited per-tenant opt-out (`PUT /api/settings {"strict_users": false}`)
97
+ survives indefinitely for validated machine-subject architectures.
98
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
78
99
 
79
100
  ## Documentation
80
101
 
@@ -44,6 +44,15 @@ class Authorization:
44
44
  db_session=None,
45
45
  strict_users: Optional[bool] = None,
46
46
  ):
47
+ # Constructing this wrapper means running auth embedded, against a real
48
+ # database — the one embedded path that never goes through create_app.
49
+ # Weak server secrets are actionable here; on a bare `import auth` by a
50
+ # client-only consumer they are not, which is why they are not emitted
51
+ # at import time (issuedb #20).
52
+ from auth.config import get_settings, warn_on_weak_secrets
53
+
54
+ warn_on_weak_secrets(get_settings())
55
+
47
56
  self.client = client
48
57
  # Use provided session or create a new one
49
58
  self.db = db_session if db_session else SessionLocal()
@@ -3,7 +3,8 @@ Enhanced client library with connection pooling, retry logic, and circuit breake
3
3
  """
4
4
 
5
5
  import json
6
- from typing import Any, Dict, Optional
6
+ import warnings
7
+ from typing import Any, Dict, NoReturn, Optional
7
8
  from urllib.parse import urljoin
8
9
 
9
10
  import requests
@@ -21,10 +22,11 @@ _RETRY_METHODS = ["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
21
22
  class AuthTransportError(Exception):
22
23
  """The client could not get an answer from the auth service.
23
24
 
24
- Raised instead of the legacy error-dict return when the client is
25
- constructed with ``raise_on_error=True``. Distinguishes "we could not
26
- ask" (connection failure, exhausted retries, open circuit breaker) from
27
- a genuine negative answer such as ``has_permission: false``.
25
+ Raised by every client method on transport failure (connection failure,
26
+ exhausted retries, open circuit breaker, non-2xx status) since 3.0.0.
27
+ Distinguishes "we could not ask" from a genuine negative answer such as
28
+ ``has_permission: false`` — map it to your unavailable/503 path, never
29
+ to a denial.
28
30
  """
29
31
 
30
32
 
@@ -70,17 +72,12 @@ class RetryableHTTPAdapter(HTTPAdapter):
70
72
  class EnhancedAuthClient:
71
73
  """Enhanced client with connection pooling, retry logic, and circuit breaker.
72
74
 
73
- Error contract: methods do not raise by default. On transport failure
74
- (connection error, exhausted retries, open circuit breaker) they return::
75
-
76
- {"error": "<message>", "success": False, "transport_error": True,
77
- "data": {...the call's input arguments...}}
78
-
79
- ``data`` echoes the inputs and does NOT contain the answer field
80
- (``has_permission``, ``count``, ...), so reading it without checking
81
- ``success`` turns an outage into a false negative. Either check
82
- ``success`` first, or construct with ``raise_on_error=True`` to make
83
- every method raise :class:`AuthTransportError` on transport failure.
75
+ Error contract (3.0.0): every method raises :class:`AuthTransportError`
76
+ on transport failure — connection error, exhausted retries, open circuit
77
+ breaker, or a non-2xx status. A transport failure can therefore never
78
+ reach your authorization logic as a value: catch the exception and map it
79
+ to your unavailable/503 path, never to a denial. Success payloads are
80
+ unchanged from 2.x.
84
81
  """
85
82
 
86
83
  def __init__(
@@ -92,7 +89,7 @@ class EnhancedAuthClient:
92
89
  pool_maxsize: int = 64,
93
90
  timeout: int = 30,
94
91
  circuit_breaker_enabled: bool = True,
95
- raise_on_error: bool = False,
92
+ raise_on_error: Optional[bool] = None,
96
93
  ):
97
94
  """
98
95
  Initialize the enhanced client
@@ -107,14 +104,22 @@ class EnhancedAuthClient:
107
104
  as transport failures under load)
108
105
  timeout: Request timeout in seconds
109
106
  circuit_breaker_enabled: Whether circuit breaker is enabled
110
- raise_on_error: When True, methods raise AuthTransportError on
111
- transport failure instead of returning the legacy error dict
112
- """
107
+ raise_on_error: DEPRECATED no-op kept so 2.x constructor calls
108
+ keep working — since 3.0.0 the client ALWAYS raises
109
+ AuthTransportError on transport failure
110
+ """
111
+ if raise_on_error is not None:
112
+ warnings.warn(
113
+ "raise_on_error is deprecated and ignored: since auth 3.0.0 "
114
+ "the client always raises AuthTransportError on transport "
115
+ "failure.",
116
+ DeprecationWarning,
117
+ stacklevel=2,
118
+ )
113
119
  self.api_key = api_key
114
120
  self.service_url = service_url
115
121
  self.timeout = timeout
116
122
  self.circuit_breaker_enabled = circuit_breaker_enabled
117
- self.raise_on_error = raise_on_error
118
123
 
119
124
  # Create session with connection pooling
120
125
  self.session = requests.Session()
@@ -233,30 +238,22 @@ class EnhancedAuthClient:
233
238
 
234
239
  def _transport_failure(
235
240
  self, exc: Exception, data: Optional[Dict[str, Any]] = None
236
- ) -> Dict[str, Any]:
237
- """Report a transport-level failure per the configured policy.
238
-
239
- Either raises :class:`AuthTransportError` (``raise_on_error=True``) or
240
- returns the legacy error payload, marked ``transport_error: True``.
241
- The payload's ``data`` only echoes call inputs — it never contains the
242
- queried answer field, which is why callers must check ``success``.
243
- """
244
- if self.raise_on_error:
245
- raise AuthTransportError(str(exc)) from exc
246
- payload: Dict[str, Any] = {
247
- "error": str(exc),
248
- "success": False,
249
- "transport_error": True,
250
- }
251
- if data is not None:
252
- payload["data"] = data
253
- return payload
241
+ ) -> NoReturn:
242
+ """Raise :class:`AuthTransportError` for a transport-level failure.
243
+
244
+ Since 3.0.0 this ALWAYS raises — the 2.x error-dict return (an
245
+ answer-shaped payload without the answer field) is gone, so an outage
246
+ can never be misread as a denial. ``data`` is accepted for call-site
247
+ compatibility and intentionally unused: inputs like key material must
248
+ never ride on an exception.
249
+ """
250
+ raise AuthTransportError(str(exc)) from exc
254
251
 
255
252
  def ping(self) -> Dict[str, Any]:
256
253
  """Health check.
257
254
 
258
- Transport failure: error dict without the answer field (or
259
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
255
+ Transport failure raises :class:`AuthTransportError` — map it to
256
+ your unavailable/503 path, never to a denial.
260
257
  """
261
258
  try:
262
259
  return self._make_request("GET", self.endpoints["ping"])
@@ -266,8 +263,8 @@ class EnhancedAuthClient:
266
263
  def add_membership(self, user: str, group: str) -> Dict[str, Any]:
267
264
  """Add user to a group.
268
265
 
269
- Transport failure: error dict without the answer field (or
270
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
266
+ Transport failure raises :class:`AuthTransportError` — map it to
267
+ your unavailable/503 path, never to a denial.
271
268
  """
272
269
  endpoint = self.endpoints["membership"].format(user=user, group=group)
273
270
  try:
@@ -278,8 +275,8 @@ class EnhancedAuthClient:
278
275
  def remove_membership(self, user: str, group: str) -> Dict[str, Any]:
279
276
  """Remove user from a group.
280
277
 
281
- Transport failure: error dict without the answer field (or
282
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
278
+ Transport failure raises :class:`AuthTransportError` — map it to
279
+ your unavailable/503 path, never to a denial.
283
280
  """
284
281
  endpoint = self.endpoints["membership"].format(user=user, group=group)
285
282
  try:
@@ -290,8 +287,8 @@ class EnhancedAuthClient:
290
287
  def has_membership(self, user: str, group: str) -> Dict[str, Any]:
291
288
  """Check if user is member of a group.
292
289
 
293
- Transport failure: error dict without the answer field (or
294
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
290
+ Transport failure raises :class:`AuthTransportError` — map it to
291
+ your unavailable/503 path, never to a denial.
295
292
  """
296
293
  endpoint = self.endpoints["membership"].format(user=user, group=group)
297
294
  try:
@@ -302,8 +299,8 @@ class EnhancedAuthClient:
302
299
  def add_permission(self, group: str, name: str) -> Dict[str, Any]:
303
300
  """Add permission to a group.
304
301
 
305
- Transport failure: error dict without the answer field (or
306
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
302
+ Transport failure raises :class:`AuthTransportError` — map it to
303
+ your unavailable/503 path, never to a denial.
307
304
  """
308
305
  endpoint = self.endpoints["permission"].format(group=group, name=name)
309
306
  try:
@@ -314,8 +311,8 @@ class EnhancedAuthClient:
314
311
  def remove_permission(self, group: str, name: str) -> Dict[str, Any]:
315
312
  """Remove permission from a group.
316
313
 
317
- Transport failure: error dict without the answer field (or
318
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
314
+ Transport failure raises :class:`AuthTransportError` — map it to
315
+ your unavailable/503 path, never to a denial.
319
316
  """
320
317
  endpoint = self.endpoints["permission"].format(group=group, name=name)
321
318
  try:
@@ -326,8 +323,8 @@ class EnhancedAuthClient:
326
323
  def has_permission(self, group: str, name: str) -> Dict[str, Any]:
327
324
  """Check if group has permission.
328
325
 
329
- Transport failure: error dict without the answer field (or
330
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
326
+ Transport failure raises :class:`AuthTransportError` — map it to
327
+ your unavailable/503 path, never to a denial.
331
328
  """
332
329
  endpoint = self.endpoints["permission"].format(group=group, name=name)
333
330
  try:
@@ -338,10 +335,8 @@ class EnhancedAuthClient:
338
335
  def user_has_permission(self, user: str, name: str) -> Dict[str, Any]:
339
336
  """Check if user has permission.
340
337
 
341
- Transport failure: error dict without the answer field (or
342
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``
343
- before reading ``data`` — a missing ``has_permission`` is an outage,
344
- not a denial.
338
+ Transport failure raises :class:`AuthTransportError` — map it to
339
+ your unavailable/503 path, never to a denial.
345
340
  """
346
341
  endpoint = self.endpoints["has_permission"].format(user=user, name=name)
347
342
  try:
@@ -352,10 +347,8 @@ class EnhancedAuthClient:
352
347
  def get_user_permissions(self, user: str) -> Dict[str, Any]:
353
348
  """Get all permissions for a user.
354
349
 
355
- Transport failure: error dict without the answer field (or
356
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``
357
- before reading ``data`` — a missing ``count`` is an outage, not an
358
- unknown user.
350
+ Transport failure raises :class:`AuthTransportError` — map it to
351
+ your unavailable/503 path, never to a denial.
359
352
  """
360
353
  endpoint = self.endpoints["user_permissions"].format(user=user)
361
354
  try:
@@ -366,8 +359,8 @@ class EnhancedAuthClient:
366
359
  def get_role_permissions(self, role: str) -> Dict[str, Any]:
367
360
  """Get all permissions for a role.
368
361
 
369
- Transport failure: error dict without the answer field (or
370
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
362
+ Transport failure raises :class:`AuthTransportError` — map it to
363
+ your unavailable/503 path, never to a denial.
371
364
  """
372
365
  endpoint = self.endpoints["role_permissions"].format(role=role)
373
366
  try:
@@ -378,8 +371,8 @@ class EnhancedAuthClient:
378
371
  def get_user_roles(self, user: str) -> Dict[str, Any]:
379
372
  """Get all roles for a user.
380
373
 
381
- Transport failure: error dict without the answer field (or
382
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
374
+ Transport failure raises :class:`AuthTransportError` — map it to
375
+ your unavailable/503 path, never to a denial.
383
376
  """
384
377
  endpoint = self.endpoints["user_roles"].format(user=user)
385
378
  try:
@@ -390,8 +383,8 @@ class EnhancedAuthClient:
390
383
  def get_role_members(self, role: str) -> Dict[str, Any]:
391
384
  """Get all members of a role.
392
385
 
393
- Transport failure: error dict without the answer field (or
394
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
386
+ Transport failure raises :class:`AuthTransportError` — map it to
387
+ your unavailable/503 path, never to a denial.
395
388
  """
396
389
  endpoint = self.endpoints["role_members"].format(role=role)
397
390
  try:
@@ -402,8 +395,8 @@ class EnhancedAuthClient:
402
395
  def list_roles(self) -> Dict[str, Any]:
403
396
  """List all roles.
404
397
 
405
- Transport failure: error dict without the answer field (or
406
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
398
+ Transport failure raises :class:`AuthTransportError` — map it to
399
+ your unavailable/503 path, never to a denial.
407
400
  """
408
401
  try:
409
402
  return self._make_request("GET", self.endpoints["roles"])
@@ -413,8 +406,8 @@ class EnhancedAuthClient:
413
406
  def which_roles_can(self, name: str) -> Dict[str, Any]:
414
407
  """Get roles that can perform an action.
415
408
 
416
- Transport failure: error dict without the answer field (or
417
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
409
+ Transport failure raises :class:`AuthTransportError` — map it to
410
+ your unavailable/503 path, never to a denial.
418
411
  """
419
412
  endpoint = self.endpoints["which_roles_can"].format(name=name)
420
413
  try:
@@ -425,8 +418,8 @@ class EnhancedAuthClient:
425
418
  def which_users_can(self, name: str) -> Dict[str, Any]:
426
419
  """Get users that can perform an action.
427
420
 
428
- Transport failure: error dict without the answer field (or
429
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
421
+ Transport failure raises :class:`AuthTransportError` — map it to
422
+ your unavailable/503 path, never to a denial.
430
423
  """
431
424
  endpoint = self.endpoints["which_users_can"].format(name=name)
432
425
  try:
@@ -437,8 +430,8 @@ class EnhancedAuthClient:
437
430
  def create_role(self, role: str) -> Dict[str, Any]:
438
431
  """Create a new role.
439
432
 
440
- Transport failure: error dict without the answer field (or
441
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
433
+ Transport failure raises :class:`AuthTransportError` — map it to
434
+ your unavailable/503 path, never to a denial.
442
435
  """
443
436
  endpoint = self.endpoints["role"].format(role=role)
444
437
  try:
@@ -449,8 +442,8 @@ class EnhancedAuthClient:
449
442
  def delete_role(self, role: str) -> Dict[str, Any]:
450
443
  """Delete a role.
451
444
 
452
- Transport failure: error dict without the answer field (or
453
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
445
+ Transport failure raises :class:`AuthTransportError` — map it to
446
+ your unavailable/503 path, never to a denial.
454
447
  """
455
448
  endpoint = self.endpoints["role"].format(role=role)
456
449
  try:
@@ -468,8 +461,8 @@ class EnhancedAuthClient:
468
461
  returned key is the ONLY copy: persist ``data.new_key`` (e.g. to your
469
462
  secret store) or you lose access to the namespace.
470
463
 
471
- Transport failure: error dict without the answer field (or
472
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
464
+ Transport failure raises :class:`AuthTransportError` — map it to
465
+ your unavailable/503 path, never to a denial.
473
466
  """
474
467
  endpoint = self.endpoints["rotate_key"]
475
468
  try:
@@ -493,8 +486,8 @@ class EnhancedAuthClient:
493
486
  retry after an ambiguous failure could mint a second key whose secret
494
487
  nobody ever saw. On an ambiguous failure, list and revoke instead.
495
488
 
496
- Transport failure: error dict without the answer field (or
497
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
489
+ Transport failure raises :class:`AuthTransportError` — map it to
490
+ your unavailable/503 path, never to a denial.
498
491
  """
499
492
  endpoint = self.endpoints["apikeys_user"].format(user=user)
500
493
  payload = {"label": label} if label is not None else None
@@ -506,8 +499,8 @@ class EnhancedAuthClient:
506
499
  def list_api_keys(self, user: str) -> Dict[str, Any]:
507
500
  """List a user's API keys (metadata only; never the secrets).
508
501
 
509
- Transport failure: error dict without the answer field (or
510
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
502
+ Transport failure raises :class:`AuthTransportError` — map it to
503
+ your unavailable/503 path, never to a denial.
511
504
  """
512
505
  endpoint = self.endpoints["apikeys_user"].format(user=user)
513
506
  try:
@@ -518,8 +511,8 @@ class EnhancedAuthClient:
518
511
  def revoke_api_key(self, user: str, key_id: str) -> Dict[str, Any]:
519
512
  """Revoke one of a user's API keys by its public key_id (idempotent).
520
513
 
521
- Transport failure: error dict without the answer field (or
522
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
514
+ Transport failure raises :class:`AuthTransportError` — map it to
515
+ your unavailable/503 path, never to a denial.
523
516
  """
524
517
  endpoint = self.endpoints["apikey_revoke"].format(user=user, key_id=key_id)
525
518
  try:
@@ -530,13 +523,11 @@ class EnhancedAuthClient:
530
523
  def validate_api_key(self, api_key: str) -> Dict[str, Any]:
531
524
  """Validate an API-key secret; answers ``data.valid`` true/false.
532
525
 
533
- The secret travels in the JSON body, never a URL. The failure payload
534
- echoes only the display prefix — never the secret itself.
526
+ The secret travels in the JSON body, never a URL, and never rides
527
+ on an exception.
535
528
 
536
- Transport failure: error dict without the answer field (or
537
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``
538
- before reading ``data`` — a missing ``valid`` is an outage, not an
539
- invalid key.
529
+ Transport failure raises :class:`AuthTransportError` — map it to
530
+ your unavailable/503 path, never to a denial.
540
531
  """
541
532
  try:
542
533
  return self._make_request(
@@ -552,12 +543,10 @@ class EnhancedAuthClient:
552
543
 
553
544
  ``data.valid`` false → the key failed (reason as in validate_api_key);
554
545
  true → ``data.has_permission`` answers for the key's user. The secret
555
- travels in the JSON body; failure payloads echo only its prefix.
546
+ travels in the JSON body and never rides on an exception.
556
547
 
557
- Transport failure: error dict without the answer field (or
558
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``
559
- before reading ``data`` — a missing ``valid`` is an outage, not a
560
- denial.
548
+ Transport failure raises :class:`AuthTransportError` — map it to
549
+ your unavailable/503 path, never to a denial.
561
550
  """
562
551
  try:
563
552
  return self._make_request(
@@ -574,8 +563,8 @@ class EnhancedAuthClient:
574
563
  def get_settings(self) -> Dict[str, Any]:
575
564
  """This tenant's settings (``data.strict_users``).
576
565
 
577
- Transport failure: error dict without the answer field (or
578
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
566
+ Transport failure raises :class:`AuthTransportError` — map it to
567
+ your unavailable/503 path, never to a denial.
579
568
  """
580
569
  try:
581
570
  return self._make_request("GET", self.endpoints["settings"])
@@ -589,8 +578,8 @@ class EnhancedAuthClient:
589
578
  key answer negatively (``reason: user_not_key_backed``) — issue keys
590
579
  before flipping this on.
591
580
 
592
- Transport failure: error dict without the answer field (or
593
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
581
+ Transport failure raises :class:`AuthTransportError` — map it to
582
+ your unavailable/503 path, never to a denial.
594
583
  """
595
584
  try:
596
585
  return self._make_request(
@@ -603,8 +592,8 @@ class EnhancedAuthClient:
603
592
  def get_users_for_workflow(self, workflow_name: str) -> Dict[str, Any]:
604
593
  """Get all users who can run a specific workflow.
605
594
 
606
- Transport failure: error dict without the answer field (or
607
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
595
+ Transport failure raises :class:`AuthTransportError` — map it to
596
+ your unavailable/503 path, never to a denial.
608
597
  """
609
598
  endpoint = self.endpoints["workflow_users"].format(workflow_name=workflow_name)
610
599
  try:
@@ -617,8 +606,8 @@ class EnhancedAuthClient:
617
606
  ) -> Dict[str, Any]:
618
607
  """Check if a user can run a specific workflow.
619
608
 
620
- Transport failure: error dict without the answer field (or
621
- ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
609
+ Transport failure raises :class:`AuthTransportError` — map it to
610
+ your unavailable/503 path, never to a denial.
622
611
  """
623
612
  endpoint = self.endpoints["workflow_permission"].format(
624
613
  user=user, workflow_name=workflow_name