auth 2.5.2__tar.gz → 3.0.0__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 (59) hide show
  1. {auth-2.5.2 → auth-3.0.0}/PKG-INFO +15 -16
  2. {auth-2.5.2 → auth-3.0.0}/README.md +14 -15
  3. {auth-2.5.2 → auth-3.0.0}/auth/client.py +93 -104
  4. {auth-2.5.2 → auth-3.0.0}/auth/config.py +8 -0
  5. {auth-2.5.2 → auth-3.0.0}/auth/database.py +60 -0
  6. {auth-2.5.2 → auth-3.0.0}/auth/docs_page.py +29 -27
  7. {auth-2.5.2 → auth-3.0.0}/auth/services/service.py +9 -1
  8. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/PKG-INFO +15 -16
  9. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/SOURCES.txt +1 -0
  10. {auth-2.5.2 → auth-3.0.0}/pyproject.toml +1 -1
  11. {auth-2.5.2 → auth-3.0.0}/tests/test_client.py +51 -50
  12. auth-3.0.0/tests/test_strict_default.py +139 -0
  13. {auth-2.5.2 → auth-3.0.0}/LICENSE +0 -0
  14. {auth-2.5.2 → auth-3.0.0}/auth/__init__.py +0 -0
  15. {auth-2.5.2 → auth-3.0.0}/auth/api_keys.py +0 -0
  16. {auth-2.5.2 → auth-3.0.0}/auth/audit.py +0 -0
  17. {auth-2.5.2 → auth-3.0.0}/auth/circuit_breaker.py +0 -0
  18. {auth-2.5.2 → auth-3.0.0}/auth/cmd/__init__.py +0 -0
  19. {auth-2.5.2 → auth-3.0.0}/auth/cmd/server.py +0 -0
  20. {auth-2.5.2 → auth-3.0.0}/auth/core/REST/__init__.py +0 -0
  21. {auth-2.5.2 → auth-3.0.0}/auth/core/REST/client.py +0 -0
  22. {auth-2.5.2 → auth-3.0.0}/auth/core/__init__.py +0 -0
  23. {auth-2.5.2 → auth-3.0.0}/auth/core/models/__init__.py +0 -0
  24. {auth-2.5.2 → auth-3.0.0}/auth/decorators.py +0 -0
  25. {auth-2.5.2 → auth-3.0.0}/auth/encryption.py +0 -0
  26. {auth-2.5.2 → auth-3.0.0}/auth/logging_config.py +0 -0
  27. {auth-2.5.2 → auth-3.0.0}/auth/main.py +0 -0
  28. {auth-2.5.2 → auth-3.0.0}/auth/models/sql.py +0 -0
  29. {auth-2.5.2 → auth-3.0.0}/auth/response_format.py +0 -0
  30. {auth-2.5.2 → auth-3.0.0}/auth/routes.py +0 -0
  31. {auth-2.5.2 → auth-3.0.0}/auth/sanitizer.py +0 -0
  32. {auth-2.5.2 → auth-3.0.0}/auth/server.py +0 -0
  33. {auth-2.5.2 → auth-3.0.0}/auth/validation.py +0 -0
  34. {auth-2.5.2 → auth-3.0.0}/auth/workflow_checker.py +0 -0
  35. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/dependency_links.txt +0 -0
  36. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/entry_points.txt +0 -0
  37. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/requires.txt +0 -0
  38. {auth-2.5.2 → auth-3.0.0}/auth.egg-info/top_level.txt +0 -0
  39. {auth-2.5.2 → auth-3.0.0}/setup.cfg +0 -0
  40. {auth-2.5.2 → auth-3.0.0}/tests/test_api_keys.py +0 -0
  41. {auth-2.5.2 → auth-3.0.0}/tests/test_api_keys_encryption.py +0 -0
  42. {auth-2.5.2 → auth-3.0.0}/tests/test_audit_integrity.py +0 -0
  43. {auth-2.5.2 → auth-3.0.0}/tests/test_client_rest.py +0 -0
  44. {auth-2.5.2 → auth-3.0.0}/tests/test_cmd_server.py +0 -0
  45. {auth-2.5.2 → auth-3.0.0}/tests/test_config.py +0 -0
  46. {auth-2.5.2 → auth-3.0.0}/tests/test_correctness_hardening.py +0 -0
  47. {auth-2.5.2 → auth-3.0.0}/tests/test_database_sslmode.py +0 -0
  48. {auth-2.5.2 → auth-3.0.0}/tests/test_docs_page.py +0 -0
  49. {auth-2.5.2 → auth-3.0.0}/tests/test_encryption.py +0 -0
  50. {auth-2.5.2 → auth-3.0.0}/tests/test_encryption_integration.py +0 -0
  51. {auth-2.5.2 → auth-3.0.0}/tests/test_flask.py +0 -0
  52. {auth-2.5.2 → auth-3.0.0}/tests/test_key_rotation.py +0 -0
  53. {auth-2.5.2 → auth-3.0.0}/tests/test_log_redaction.py +0 -0
  54. {auth-2.5.2 → auth-3.0.0}/tests/test_phase_a_hardening.py +0 -0
  55. {auth-2.5.2 → auth-3.0.0}/tests/test_reencryption.py +0 -0
  56. {auth-2.5.2 → auth-3.0.0}/tests/test_routes_errors.py +0 -0
  57. {auth-2.5.2 → auth-3.0.0}/tests/test_server.py +0 -0
  58. {auth-2.5.2 → auth-3.0.0}/tests/test_service_lifecycle.py +0 -0
  59. {auth-2.5.2 → auth-3.0.0}/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.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -104,13 +104,11 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
104
104
  - **Two response shapes** — bare `{"result": ...}` and wrapped
105
105
  `{"success", "data", ...}`. The API reference says which per endpoint.
106
106
  - **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.
107
+ - **Python client: transport failures raise `AuthTransportError`** (3.0.0).
108
+ The 2.x answer-shaped error dict is gone — an outage can never be misread
109
+ as a denial. Catch the exception and map it to your unavailable/503 path,
110
+ never to a permission denial. The old `raise_on_error` constructor argument
111
+ is a deprecated no-op, so 2.x code keeps constructing.
114
112
  - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
115
113
  key out of source control, logs, and URLs — it is the only thing protecting
116
114
  your data. Rotate it with `POST /api/keys/rotate` if it leaks.
@@ -123,14 +121,15 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
123
121
  `check_api_key_permission` (validate + permission in one round trip), plus
124
122
  `get_settings`/`set_strict_users`. All of these also exist on the in-process
125
123
  `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).
124
+ - **Strict user identity is the default for NEW tenants (3.0.0).** A tenant
125
+ namespace created after 3.0.0 requires key-backed users for authorization
126
+ decisions (`user_not_key_backed` answers; key-less membership grants answer
127
+ 409) — create the user's API key first, then grant roles. Every tenant that
128
+ existed before 3.0.0 was **grandfathered** with an explicit
129
+ `strict_users: false` row (nothing changed for them at upgrade), and the
130
+ audited per-tenant opt-out (`PUT /api/settings {"strict_users": false}`)
131
+ survives indefinitely for validated machine-subject architectures.
132
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
134
133
 
135
134
  ## Documentation
136
135
 
@@ -48,13 +48,11 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
48
48
  - **Two response shapes** — bare `{"result": ...}` and wrapped
49
49
  `{"success", "data", ...}`. The API reference says which per endpoint.
50
50
  - **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.
51
+ - **Python client: transport failures raise `AuthTransportError`** (3.0.0).
52
+ The 2.x answer-shaped error dict is gone — an outage can never be misread
53
+ as a denial. Catch the exception and map it to your unavailable/503 path,
54
+ never to a permission denial. The old `raise_on_error` constructor argument
55
+ is a deprecated no-op, so 2.x code keeps constructing.
58
56
  - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
59
57
  key out of source control, logs, and URLs — it is the only thing protecting
60
58
  your data. Rotate it with `POST /api/keys/rotate` if it leaks.
@@ -67,14 +65,15 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
67
65
  `check_api_key_permission` (validate + permission in one round trip), plus
68
66
  `get_settings`/`set_strict_users`. All of these also exist on the in-process
69
67
  `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).
68
+ - **Strict user identity is the default for NEW tenants (3.0.0).** A tenant
69
+ namespace created after 3.0.0 requires key-backed users for authorization
70
+ decisions (`user_not_key_backed` answers; key-less membership grants answer
71
+ 409) — create the user's API key first, then grant roles. Every tenant that
72
+ existed before 3.0.0 was **grandfathered** with an explicit
73
+ `strict_users: false` row (nothing changed for them at upgrade), and the
74
+ audited per-tenant opt-out (`PUT /api/settings {"strict_users": false}`)
75
+ survives indefinitely for validated machine-subject architectures.
76
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
78
77
 
79
78
  ## Documentation
80
79
 
@@ -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
@@ -82,6 +82,14 @@ class Settings(BaseSettings):
82
82
  enable_encryption: bool = False
83
83
  encryption_key: str = ""
84
84
 
85
+ # Strict user identity (SPEC 0008/0012). Applies ONLY to tenants with no
86
+ # auth_tenant_settings row: 3.0.0 defaults them to strict (key-backed
87
+ # users required for authorization decisions). Tenants existing before
88
+ # 3.0.0 are grandfathered with explicit false rows (migration +
89
+ # create_tables pass), so this reaches new tenants only. Embedded
90
+ # consumers not yet key-backed can set AUTH_STRICT_USERS_DEFAULT=false.
91
+ strict_users_default: bool = True
92
+
85
93
  # Schema settings (for PostgreSQL multi-tenancy)
86
94
  database_schema: str = "" # Optional schema name (e.g., "auth_rbac" for Highway)
87
95
 
@@ -321,6 +321,7 @@ def create_tables(raise_on_error: bool = False):
321
321
  text(f'CREATE SCHEMA IF NOT EXISTS "{settings.database_schema}"')
322
322
  )
323
323
  Base.metadata.create_all(bind=engine, checkfirst=True)
324
+ _grandfather_strict_users(engine)
324
325
  logger.info("Tables created successfully.")
325
326
  except Exception:
326
327
  logger.exception("create_tables failed")
@@ -328,6 +329,65 @@ def create_tables(raise_on_error: bool = False):
328
329
  raise
329
330
 
330
331
 
332
+ # Marker creator recording that the one-shot 3.0.0 grandfathering pass ran on
333
+ # this database. Reserved — never use it as a real tenant identifier.
334
+ GRANDFATHER_MARKER = "__meta:grandfathered-3.0__"
335
+
336
+
337
+ def _grandfather_strict_users(target_engine: Engine) -> None:
338
+ """One-shot 3.0.0 flip protection (SPEC 0012): write explicit
339
+ ``strict_users = false`` rows for every creator that exists on this
340
+ database, then record a marker so the pass never runs again.
341
+
342
+ 3.0.0 makes no-settings-row tenants strict by default; this pass is what
343
+ guarantees that flip reaches ONLY tenants created after it ran — every
344
+ pre-existing tenant keeps its behavior as an explicit, auditable opt-out
345
+ it can change later. Runs inside create_tables so embedded consumers get
346
+ the same protection our deployment gets from the migretti migration
347
+ (both are marker-guarded, so they compose idempotently).
348
+ """
349
+ from typing import cast
350
+
351
+ from sqlalchemy import Table, literal, select, union
352
+
353
+ from auth.models.sql import (
354
+ AuthApiKey,
355
+ AuthGroup,
356
+ AuthMembership,
357
+ AuthPermission,
358
+ AuthTenantSettings,
359
+ )
360
+
361
+ settings_t = cast(Table, AuthTenantSettings.__table__)
362
+ with target_engine.begin() as conn:
363
+ marker_exists = conn.execute(
364
+ select(settings_t.c.id).where(settings_t.c.creator == GRANDFATHER_MARKER)
365
+ ).first()
366
+ if marker_exists:
367
+ return
368
+ creators = union(
369
+ *(
370
+ select(t.__table__.c.creator)
371
+ for t in (AuthGroup, AuthMembership, AuthPermission, AuthApiKey)
372
+ )
373
+ ).subquery()
374
+ already = select(settings_t.c.creator)
375
+ conn.execute(
376
+ settings_t.insert().from_select(
377
+ ["creator", "strict_users"],
378
+ select(creators.c.creator, literal(False)).where(
379
+ creators.c.creator.notin_(already)
380
+ ),
381
+ )
382
+ )
383
+ conn.execute(
384
+ settings_t.insert().values(
385
+ creator=GRANDFATHER_MARKER, strict_users=False
386
+ )
387
+ )
388
+ logger.info("strict_users grandfathering pass completed (one-shot).")
389
+
390
+
331
391
  def log_pool_stats():
332
392
  """Log current connection pool statistics"""
333
393
  stats = get_pool_status()