auth 3.1.0__tar.gz → 3.2.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 (93) hide show
  1. {auth-3.1.0 → auth-3.2.0}/PKG-INFO +5 -3
  2. {auth-3.1.0 → auth-3.2.0}/auth/__init__.py +2 -0
  3. {auth-3.1.0 → auth-3.2.0}/auth/api_keys.py +22 -0
  4. {auth-3.1.0 → auth-3.2.0}/auth/audit.py +21 -0
  5. auth-3.2.0/auth/client/__init__.py +59 -0
  6. auth-3.2.0/auth/client/_transport.py +81 -0
  7. auth-3.2.0/auth/client/base.py +241 -0
  8. auth-3.2.0/auth/client/keys.py +142 -0
  9. auth-3.2.0/auth/client/rbac.py +200 -0
  10. auth-3.2.0/auth/client/workflow.py +40 -0
  11. {auth-3.1.0 → auth-3.2.0}/auth/database.py +29 -146
  12. auth-3.2.0/auth/docs_page.py +132 -0
  13. auth-3.2.0/auth/keycheck.py +233 -0
  14. {auth-3.1.0 → auth-3.2.0}/auth/main.py +21 -0
  15. {auth-3.1.0 → auth-3.2.0}/auth/models/sql.py +8 -0
  16. auth-3.2.0/auth/rls.py +122 -0
  17. auth-3.2.0/auth/routes/__init__.py +51 -0
  18. auth-3.2.0/auth/routes/_common.py +66 -0
  19. auth-3.2.0/auth/routes/apikeys.py +198 -0
  20. auth-3.2.0/auth/routes/membership.py +118 -0
  21. auth-3.2.0/auth/routes/permissions.py +170 -0
  22. auth-3.2.0/auth/routes/public.py +166 -0
  23. auth-3.2.0/auth/routes/roles.py +143 -0
  24. auth-3.2.0/auth/routes/tenant.py +269 -0
  25. auth-3.2.0/auth/routes/workflow.py +80 -0
  26. auth-3.2.0/auth/schema_reconcile.py +241 -0
  27. auth-3.2.0/auth/services/base.py +106 -0
  28. auth-3.2.0/auth/services/keys.py +184 -0
  29. auth-3.2.0/auth/services/queries.py +138 -0
  30. auth-3.2.0/auth/services/rbac.py +359 -0
  31. auth-3.2.0/auth/services/recovery.py +127 -0
  32. auth-3.2.0/auth/services/rotation.py +162 -0
  33. auth-3.2.0/auth/services/service.py +76 -0
  34. auth-3.2.0/auth/services/tenant.py +150 -0
  35. auth-3.2.0/auth/static_docs/__init__.py +8 -0
  36. auth-3.1.0/auth/docs_page.py → auth-3.2.0/auth/static_docs/api_reference.md +23 -321
  37. auth-3.2.0/auth/static_docs/claude_guide.md +89 -0
  38. auth-3.2.0/auth/static_docs/landing.md +81 -0
  39. auth-3.2.0/auth/static_docs/page.html +21 -0
  40. {auth-3.1.0 → auth-3.2.0}/auth/validation.py +24 -4
  41. {auth-3.1.0 → auth-3.2.0}/auth/workflow_checker.py +12 -1
  42. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/PKG-INFO +5 -3
  43. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/SOURCES.txt +32 -2
  44. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/requires.txt +5 -2
  45. {auth-3.1.0 → auth-3.2.0}/pyproject.toml +13 -3
  46. auth-3.2.0/tests/test_gunicorn_config_invariants.py +92 -0
  47. auth-3.2.0/tests/test_no_bundled_libpq.py +69 -0
  48. {auth-3.1.0 → auth-3.2.0}/tests/test_routes_errors.py +3 -1
  49. auth-3.1.0/auth/client.py +0 -640
  50. auth-3.1.0/auth/routes.py +0 -806
  51. auth-3.1.0/auth/services/service.py +0 -977
  52. {auth-3.1.0 → auth-3.2.0}/LICENSE +0 -0
  53. {auth-3.1.0 → auth-3.2.0}/README.md +0 -0
  54. {auth-3.1.0 → auth-3.2.0}/auth/circuit_breaker.py +0 -0
  55. {auth-3.1.0 → auth-3.2.0}/auth/cmd/__init__.py +0 -0
  56. {auth-3.1.0 → auth-3.2.0}/auth/cmd/server.py +0 -0
  57. {auth-3.1.0 → auth-3.2.0}/auth/config.py +0 -0
  58. {auth-3.1.0 → auth-3.2.0}/auth/core/REST/__init__.py +0 -0
  59. {auth-3.1.0 → auth-3.2.0}/auth/core/REST/client.py +0 -0
  60. {auth-3.1.0 → auth-3.2.0}/auth/core/__init__.py +0 -0
  61. {auth-3.1.0 → auth-3.2.0}/auth/core/models/__init__.py +0 -0
  62. {auth-3.1.0 → auth-3.2.0}/auth/decorators.py +0 -0
  63. {auth-3.1.0 → auth-3.2.0}/auth/encryption.py +0 -0
  64. {auth-3.1.0 → auth-3.2.0}/auth/logging_config.py +0 -0
  65. {auth-3.1.0 → auth-3.2.0}/auth/response_format.py +0 -0
  66. {auth-3.1.0 → auth-3.2.0}/auth/sanitizer.py +0 -0
  67. {auth-3.1.0 → auth-3.2.0}/auth/server.py +0 -0
  68. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/dependency_links.txt +0 -0
  69. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/entry_points.txt +0 -0
  70. {auth-3.1.0 → auth-3.2.0}/auth.egg-info/top_level.txt +0 -0
  71. {auth-3.1.0 → auth-3.2.0}/setup.cfg +0 -0
  72. {auth-3.1.0 → auth-3.2.0}/tests/test_api_keys.py +0 -0
  73. {auth-3.1.0 → auth-3.2.0}/tests/test_api_keys_encryption.py +0 -0
  74. {auth-3.1.0 → auth-3.2.0}/tests/test_audit_integrity.py +0 -0
  75. {auth-3.1.0 → auth-3.2.0}/tests/test_client.py +0 -0
  76. {auth-3.1.0 → auth-3.2.0}/tests/test_client_rest.py +0 -0
  77. {auth-3.1.0 → auth-3.2.0}/tests/test_cmd_server.py +0 -0
  78. {auth-3.1.0 → auth-3.2.0}/tests/test_config.py +0 -0
  79. {auth-3.1.0 → auth-3.2.0}/tests/test_correctness_hardening.py +0 -0
  80. {auth-3.1.0 → auth-3.2.0}/tests/test_database_sslmode.py +0 -0
  81. {auth-3.1.0 → auth-3.2.0}/tests/test_docs_page.py +0 -0
  82. {auth-3.1.0 → auth-3.2.0}/tests/test_encryption.py +0 -0
  83. {auth-3.1.0 → auth-3.2.0}/tests/test_encryption_integration.py +0 -0
  84. {auth-3.1.0 → auth-3.2.0}/tests/test_flask.py +0 -0
  85. {auth-3.1.0 → auth-3.2.0}/tests/test_key_rotation.py +0 -0
  86. {auth-3.1.0 → auth-3.2.0}/tests/test_log_redaction.py +0 -0
  87. {auth-3.1.0 → auth-3.2.0}/tests/test_phase_a_hardening.py +0 -0
  88. {auth-3.1.0 → auth-3.2.0}/tests/test_reencryption.py +0 -0
  89. {auth-3.1.0 → auth-3.2.0}/tests/test_self_service_audit.py +0 -0
  90. {auth-3.1.0 → auth-3.2.0}/tests/test_server.py +0 -0
  91. {auth-3.1.0 → auth-3.2.0}/tests/test_service_lifecycle.py +0 -0
  92. {auth-3.1.0 → auth-3.2.0}/tests/test_strict_default.py +0 -0
  93. {auth-3.1.0 → auth-3.2.0}/tests/test_strict_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -29,14 +29,16 @@ Requires-Dist: flask<4,>=2.0.0
29
29
  Requires-Dist: flask-cors<7,>=3.0.0
30
30
  Requires-Dist: sqlalchemy<3,>=2.0.0
31
31
  Requires-Dist: waitress<4,>=2.0.0
32
- Requires-Dist: cryptography<48,>=3.0.0
32
+ Requires-Dist: cryptography>=3.0.0
33
33
  Requires-Dist: APScheduler<4,>=3.0.0
34
- Requires-Dist: psycopg[binary]<4,>=3.0.0
34
+ Requires-Dist: psycopg<4,>=3.0.0
35
35
  Requires-Dist: pydantic<3,>=2.0.0
36
36
  Requires-Dist: pydantic-settings<3,>=2.0.0
37
37
  Requires-Dist: requests<3,>=2.25.0
38
38
  Requires-Dist: bleach<7,>=5.0.0
39
39
  Requires-Dist: python-json-logger<4,>=3.1.0
40
+ Provides-Extra: c
41
+ Requires-Dist: psycopg[c]<4,>=3.0.0; extra == "c"
40
42
  Provides-Extra: ratelimit
41
43
  Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
42
44
  Provides-Extra: migrations
@@ -4,6 +4,7 @@ from importlib.metadata import PackageNotFoundError, version
4
4
  from typing import Optional
5
5
 
6
6
  from auth.client import ( # Import the new client
7
+ AuthRefused,
7
8
  AuthTransportError,
8
9
  Client,
9
10
  EnhancedAuthClient,
@@ -140,6 +141,7 @@ class Authorization:
140
141
  # Export the new client for users who want enhanced features
141
142
  __all__ = [
142
143
  "Authorization",
144
+ "AuthRefused",
143
145
  "AuthTransportError",
144
146
  "Client",
145
147
  "EnhancedAuthClient",
@@ -15,7 +15,11 @@ import uuid
15
15
  from typing import Tuple
16
16
 
17
17
  API_KEY_PREFIX = "rak_"
18
+ #: Recovery ("rotate") keys. A different prefix so a credential is never
19
+ #: mistaken for the other kind, and so a leaked one is identifiable on sight.
20
+ ROTATE_KEY_PREFIX = "rrk_"
18
21
  API_KEY_PATTERN = re.compile(r"^rak_[0-9A-Za-z]{43}$")
22
+ ROTATE_KEY_PATTERN = re.compile(r"^rrk_[0-9A-Za-z]{43}$")
19
23
 
20
24
  # "rak_" + first 8 payload chars — safe to store and display in listings.
21
25
  KEY_PREFIX_LEN = 12
@@ -48,3 +52,21 @@ def generate_api_key() -> Tuple[str, str, str, str]:
48
52
  def hash_api_key(secret: str) -> str:
49
53
  """SHA-256 hex digest of the full secret string (equality-queryable)."""
50
54
  return hashlib.sha256(secret.encode()).hexdigest()
55
+
56
+
57
+ def generate_rotate_key() -> Tuple[str, str]:
58
+ """Mint a recovery key: returns ``(secret, secret_hash)``.
59
+
60
+ Same construction and entropy as a user API key -- 256 bits from
61
+ ``secrets.token_bytes(32)`` -- because it guards more, not less: whoever
62
+ holds it can take over the namespace without the client key. Only the hash
63
+ is stored, and the secret is disclosed exactly once.
64
+ """
65
+ payload = _base62(secrets.token_bytes(32)).rjust(_PAYLOAD_LEN, _ALPHABET[0])
66
+ secret = ROTATE_KEY_PREFIX + payload
67
+ return secret, hash_api_key(secret)
68
+
69
+
70
+ def validate_rotate_key_format(secret: str) -> bool:
71
+ """Shape check before any database work."""
72
+ return bool(ROTATE_KEY_PATTERN.match(secret))
@@ -46,6 +46,8 @@ class AuditAction(Enum):
46
46
  GET_SETTINGS = "GET_SETTINGS"
47
47
  UPDATE_SETTINGS = "UPDATE_SETTINGS"
48
48
  GET_AUDIT = "GET_AUDIT"
49
+ GET_ROTATE_KEY = "GET_ROTATE_KEY"
50
+ RECOVER_KEY = "RECOVER_KEY"
49
51
 
50
52
 
51
53
  class AuditLog(Base):
@@ -176,6 +178,23 @@ def _emit_structured_log(
176
178
  audit_logger.info(json.dumps(log_msg))
177
179
 
178
180
 
181
+ def _bind_audit(session, client_id: str) -> None:
182
+ """Bind the audit tenant so RLS admits the row (no-op off PostgreSQL).
183
+
184
+ audit_log's policy compares ``client_id`` against a session variable, and
185
+ the value is bound VERBATIM: every caller already passes a fingerprint (see
186
+ ``client_ref`` in auth/decorators.py), and ``_build_audit_entry`` stores it
187
+ unchanged. Fingerprinting it again here would bind a fingerprint OF a
188
+ fingerprint, which matches no row and rejects every audit write.
189
+ """
190
+ try:
191
+ from auth.rls import AUDIT_GUC, bind_session
192
+
193
+ bind_session(session, **{AUDIT_GUC: client_id})
194
+ except Exception: # pragma: no cover - binding must never break an audit write
195
+ pass
196
+
197
+
179
198
  def record_audit(
180
199
  session,
181
200
  *,
@@ -196,6 +215,7 @@ def record_audit(
196
215
  surrounding request (fail-closed), never leave a committed mutation
197
216
  unaudited.
198
217
  """
218
+ _bind_audit(session, client_id)
199
219
  session.add(
200
220
  _build_audit_entry(
201
221
  client_id, user, action, resource, details, ip_address, user_agent, success
@@ -224,6 +244,7 @@ def log_audit_event(
224
244
  """
225
245
  session = SessionLocal()
226
246
  try:
247
+ _bind_audit(session, client_id)
227
248
  session.add(
228
249
  _build_audit_entry(
229
250
  client_id, user, action, resource, details, ip_address, user_agent, success
@@ -0,0 +1,59 @@
1
+ """
2
+ Enhanced client library with connection pooling, retry logic, and circuit breaker
3
+
4
+ The implementation is split across :mod:`auth.client._transport`,
5
+ :mod:`auth.client.base` and the per-resource mixins, but this package stays the
6
+ public import path: ``from auth.client import EnhancedAuthClient`` resolves
7
+ exactly as it did before the split, and both classes keep ``auth.client`` as
8
+ their ``__module__``.
9
+ """
10
+
11
+ # Re-exported so it still resolves through this module, as it did when the
12
+ # client was a single file.
13
+ from auth.circuit_breaker import circuit_breaker
14
+ from auth.client._transport import (
15
+ _RETRY_METHODS,
16
+ AuthRefused,
17
+ AuthTransportError,
18
+ RetryableHTTPAdapter,
19
+ _build_retry,
20
+ )
21
+ from auth.client.base import ClientBase
22
+ from auth.client.keys import ApiKeyMixin
23
+ from auth.client.rbac import RbacMixin
24
+ from auth.client.workflow import WorkflowMixin
25
+
26
+
27
+ class EnhancedAuthClient(WorkflowMixin):
28
+ """Enhanced client with connection pooling, retry logic, and circuit breaker.
29
+
30
+ Error contract (3.0.0): every method raises :class:`AuthTransportError`
31
+ on transport failure — connection error, exhausted retries, open circuit
32
+ breaker, or a non-2xx status. A transport failure can therefore never
33
+ reach your authorization logic as a value: catch the exception and map it
34
+ to your unavailable/503 path, never to a denial. Success payloads are
35
+ unchanged from 2.x.
36
+ """
37
+
38
+
39
+ # For backward compatibility with the old client
40
+ class Client(EnhancedAuthClient):
41
+ """Legacy client class for backward compatibility"""
42
+
43
+ pass
44
+
45
+
46
+ __all__ = [
47
+ "ApiKeyMixin",
48
+ "AuthRefused",
49
+ "AuthTransportError",
50
+ "Client",
51
+ "ClientBase",
52
+ "EnhancedAuthClient",
53
+ "RbacMixin",
54
+ "RetryableHTTPAdapter",
55
+ "WorkflowMixin",
56
+ "circuit_breaker",
57
+ "_RETRY_METHODS",
58
+ "_build_retry",
59
+ ]
@@ -0,0 +1,81 @@
1
+ """Transport layer for the REST client: retry policy, adapter, error type."""
2
+
3
+ from requests.adapters import HTTPAdapter
4
+ from urllib3.util.retry import Retry
5
+
6
+ # Methods eligible for retry. The auth server's write endpoints are
7
+ # idempotent upserts, so retrying POST/PUT/DELETE is safe here.
8
+ _RETRY_METHODS = ["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
9
+
10
+
11
+ class AuthTransportError(Exception):
12
+ """The client could not get an answer from the auth service.
13
+
14
+ Raised by every client method when no answer could be obtained:
15
+ connection failure, exhausted retries, open circuit breaker, or a 5xx.
16
+ A 4xx raises :class:`AuthRefused`, a subclass — the service DID answer,
17
+ it refused the request.
18
+ Distinguishes "we could not ask" from a genuine negative answer such as
19
+ ``has_permission: false`` — map it to your unavailable/503 path, never
20
+ to a denial.
21
+ """
22
+
23
+
24
+ class AuthRefused(AuthTransportError):
25
+ """The auth service answered and refused the request (a 4xx).
26
+
27
+ Subclasses :class:`AuthTransportError` so existing handlers keep working,
28
+ but lets a caller tell "auth would not accept this" from "auth could not be
29
+ reached". Reported by a consumer whose platform-admin check logged "auth
30
+ unreachable at login" while auth was up, answering, and simply rejecting a
31
+ subject containing ``|``: that sends whoever reads the log to the health
32
+ endpoint and the network when the cause is the shape of a string.
33
+
34
+ ``status`` is the HTTP status; ``payload`` is the decoded body when there
35
+ was one, so actionable errors (such as the 409 ``user_not_key_backed`` hint)
36
+ survive instead of being flattened into a transport message.
37
+ """
38
+
39
+ def __init__(self, message: str, status: int, payload=None):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.payload = payload
43
+
44
+
45
+ def _build_retry(total, backoff_factor, status_forcelist) -> Retry:
46
+ """Build a Retry that works on both urllib3 1.x and 2.x.
47
+
48
+ urllib3 renamed ``method_whitelist`` to ``allowed_methods`` in 1.26 and
49
+ removed the old name in 2.0.
50
+ """
51
+ kwargs = {
52
+ "total": total,
53
+ "backoff_factor": backoff_factor,
54
+ "status_forcelist": list(status_forcelist),
55
+ }
56
+ try:
57
+ return Retry(allowed_methods=_RETRY_METHODS, **kwargs)
58
+ except TypeError: # urllib3 < 1.26
59
+ return Retry(method_whitelist=_RETRY_METHODS, **kwargs) # type: ignore[call-arg]
60
+
61
+
62
+ class RetryableHTTPAdapter(HTTPAdapter):
63
+ """HTTP adapter with retry logic"""
64
+
65
+ def __init__(
66
+ self, retries=3, backoff_factor=0.3, status_forcelist=(500, 502, 504), **kwargs
67
+ ):
68
+ self.retries = retries
69
+ self.backoff_factor = backoff_factor
70
+ self.status_forcelist = status_forcelist
71
+ super().__init__(**kwargs)
72
+
73
+ def init_poolmanager(self, *args, **kwargs):
74
+ kwargs["retries"] = Retry(
75
+ total=self.retries,
76
+ read=self.retries,
77
+ connect=self.retries,
78
+ backoff_factor=self.backoff_factor,
79
+ status_forcelist=self.status_forcelist,
80
+ )
81
+ return super().init_poolmanager(*args, **kwargs)
@@ -0,0 +1,241 @@
1
+ """The client's request machinery: session setup, request/response, teardown."""
2
+
3
+ import json
4
+ import warnings
5
+ from typing import Any, Dict, NoReturn, Optional
6
+ from urllib.parse import urljoin
7
+
8
+ import requests
9
+ from requests.adapters import HTTPAdapter
10
+ from requests.exceptions import ConnectionError
11
+
12
+ from auth.circuit_breaker import circuit_breaker
13
+ from auth.client._transport import (
14
+ AuthRefused,
15
+ AuthTransportError,
16
+ RetryableHTTPAdapter,
17
+ _build_retry,
18
+ )
19
+
20
+
21
+ class ClientBase:
22
+ """Enhanced client with connection pooling, retry logic, and circuit breaker.
23
+
24
+ Error contract (3.0.0): every method raises :class:`AuthTransportError`
25
+ on transport failure — connection error, exhausted retries, open circuit
26
+ breaker, or a non-2xx status. A transport failure can therefore never
27
+ reach your authorization logic as a value: catch the exception and map it
28
+ to your unavailable/503 path, never to a denial. Success payloads are
29
+ unchanged from 2.x.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: str,
35
+ service_url: str,
36
+ max_retries: int = 3,
37
+ pool_connections: int = 10,
38
+ pool_maxsize: int = 64,
39
+ timeout: int = 30,
40
+ circuit_breaker_enabled: bool = True,
41
+ raise_on_error: Optional[bool] = None,
42
+ ):
43
+ """
44
+ Initialize the enhanced client
45
+
46
+ Args:
47
+ api_key: The API key for authentication
48
+ service_url: The base URL for the auth service
49
+ max_retries: Number of times to retry failed requests
50
+ pool_connections: Number of connection pools (passed to the HTTP adapter)
51
+ pool_maxsize: Max connections per pool (passed to the HTTP adapter;
52
+ size it to your caller concurrency — an exhausted pool surfaces
53
+ as transport failures under load)
54
+ timeout: Request timeout in seconds
55
+ circuit_breaker_enabled: Whether circuit breaker is enabled
56
+ raise_on_error: DEPRECATED no-op kept so 2.x constructor calls
57
+ keep working — since 3.0.0 the client ALWAYS raises
58
+ AuthTransportError on transport failure
59
+ """
60
+ if raise_on_error is not None:
61
+ warnings.warn(
62
+ "raise_on_error is deprecated and ignored: since auth 3.0.0 "
63
+ "the client always raises AuthTransportError on transport "
64
+ "failure.",
65
+ DeprecationWarning,
66
+ stacklevel=2,
67
+ )
68
+ self.api_key = api_key
69
+ self.service_url = service_url
70
+ self.timeout = timeout
71
+ self.circuit_breaker_enabled = circuit_breaker_enabled
72
+
73
+ # Create session with connection pooling
74
+ self.session = requests.Session()
75
+
76
+ # Configure retries
77
+ retry_strategy = _build_retry(
78
+ total=max_retries,
79
+ backoff_factor=0.3,
80
+ status_forcelist=[429, 500, 502, 503, 504],
81
+ )
82
+
83
+ # Use our custom adapter for more control
84
+ adapter = RetryableHTTPAdapter(
85
+ retries=max_retries,
86
+ status_forcelist=[429, 500, 502, 503, 504],
87
+ max_retries=retry_strategy,
88
+ pool_connections=pool_connections,
89
+ pool_maxsize=pool_maxsize,
90
+ )
91
+
92
+ self.session.mount("http://", adapter)
93
+ self.session.mount("https://", adapter)
94
+
95
+ # Set default headers
96
+ self.session.headers.update(
97
+ {
98
+ "Authorization": f"Bearer {self.api_key}",
99
+ "Content-Type": "application/json",
100
+ }
101
+ )
102
+
103
+ # API endpoints
104
+ self.endpoints = {
105
+ "ping": "/ping",
106
+ "membership": "/api/membership/{user}/{group}",
107
+ "permission": "/api/permission/{group}/{name}",
108
+ "has_permission": "/api/has_permission/{user}/{name}",
109
+ "user_permissions": "/api/user_permissions/{user}",
110
+ "role_permissions": "/api/role_permissions/{role}",
111
+ "user_roles": "/api/user_roles/{user}",
112
+ "role_members": "/api/members/{role}",
113
+ "roles": "/api/roles",
114
+ "which_roles_can": "/api/which_roles_can/{name}",
115
+ "which_users_can": "/api/which_users_can/{name}",
116
+ "role": "/api/role/{role}",
117
+ "workflow_users": "/api/workflow/users/{workflow_name}",
118
+ "workflow_permission": "/api/workflow/user/{user}/can_run/{workflow_name}",
119
+ "rotate_key": "/api/keys/rotate",
120
+ "apikeys_user": "/api/apikeys/user/{user}",
121
+ "apikey_revoke": "/api/apikeys/user/{user}/{key_id}",
122
+ "apikey_validate": "/api/apikeys/validate",
123
+ "apikey_check_permission": "/api/apikeys/check_permission",
124
+ "settings": "/api/settings",
125
+ }
126
+
127
+ # Built on first use: a session without retries for non-idempotent
128
+ # calls (create_api_key). Shares the auth headers with the main
129
+ # session so rotate_key() updates both.
130
+ self._no_retry: Optional[requests.Session] = None
131
+
132
+ def _no_retry_session(self) -> requests.Session:
133
+ """Session for non-idempotent calls: same auth headers, zero retries."""
134
+ if self._no_retry is None:
135
+ session = requests.Session()
136
+ session.mount("http://", HTTPAdapter(max_retries=0))
137
+ session.mount("https://", HTTPAdapter(max_retries=0))
138
+ # Shared mapping, not a copy: rotate_key() must apply to both.
139
+ session.headers = self.session.headers
140
+ self._no_retry = session
141
+ return self._no_retry
142
+
143
+ def _make_request(
144
+ self, method: str, endpoint: str, retry: bool = True, **kwargs
145
+ ) -> Dict[str, Any]:
146
+ """
147
+ Make an HTTP request with circuit breaker, retry logic, and error handling
148
+ """
149
+ url = urljoin(self.service_url, endpoint)
150
+ session = self.session if retry else self._no_retry_session()
151
+
152
+ # Prepare the request function for the circuit breaker
153
+ def request_func():
154
+ try:
155
+ response = session.request(
156
+ method=method, url=url, timeout=self.timeout, **kwargs
157
+ )
158
+
159
+ # Raise an exception for bad status codes
160
+ response.raise_for_status()
161
+
162
+ # Try to parse JSON response
163
+ try:
164
+ json_response: Dict[str, Any] = response.json()
165
+ return json_response
166
+ except json.JSONDecodeError:
167
+ # If JSON parsing fails, return the text content
168
+ text_response: Dict[str, Any] = {"result": response.text}
169
+ return text_response
170
+
171
+ except requests.exceptions.RequestException as e:
172
+ # A 4xx means the service ANSWERED and refused. Collapsing that
173
+ # into a transport failure sends the reader to the health
174
+ # endpoint and the network when the cause is the request itself.
175
+ resp = getattr(e, "response", None)
176
+ if resp is not None and 400 <= resp.status_code < 500:
177
+ try:
178
+ payload = resp.json()
179
+ except Exception:
180
+ payload = None
181
+ raise AuthRefused(
182
+ f"auth refused the request: HTTP {resp.status_code}",
183
+ resp.status_code,
184
+ payload,
185
+ ) from e
186
+ # Convert to our expected exception type
187
+ raise ConnectionError(f"Request failed: {str(e)}") from e
188
+
189
+ if self.circuit_breaker_enabled:
190
+ # Use circuit breaker to wrap the request
191
+ try:
192
+ cb_result: Dict[str, Any] = circuit_breaker("api_call")(request_func)()
193
+ return cb_result
194
+ except AuthRefused:
195
+ raise # a refusal is an answer; it is not a breaker event
196
+ except Exception as e:
197
+ raise ConnectionError(
198
+ f"Circuit breaker prevented request: {str(e)}"
199
+ ) from e
200
+ else:
201
+ direct_result: Dict[str, Any] = request_func()
202
+ return direct_result
203
+
204
+ def _transport_failure(
205
+ self, exc: Exception, data: Optional[Dict[str, Any]] = None
206
+ ) -> NoReturn:
207
+ """Raise :class:`AuthTransportError` for a transport-level failure.
208
+
209
+ Since 3.0.0 this ALWAYS raises — the 2.x error-dict return (an
210
+ answer-shaped payload without the answer field) is gone, so an outage
211
+ can never be misread as a denial. ``data`` is accepted for call-site
212
+ compatibility and intentionally unused: inputs like key material must
213
+ never ride on an exception.
214
+ """
215
+ if isinstance(exc, AuthRefused):
216
+ raise exc
217
+ raise AuthTransportError(str(exc)) from exc
218
+
219
+ def ping(self) -> Dict[str, Any]:
220
+ """Health check.
221
+
222
+ Transport failure raises :class:`AuthTransportError` — map it to
223
+ your unavailable/503 path, never to a denial.
224
+ """
225
+ try:
226
+ return self._make_request("GET", self.endpoints["ping"])
227
+ except Exception as e:
228
+ return self._transport_failure(e)
229
+
230
+ def close(self):
231
+ """Close the session"""
232
+ if self.session:
233
+ self.session.close()
234
+ if self._no_retry is not None:
235
+ self._no_retry.close()
236
+
237
+ def __enter__(self):
238
+ return self
239
+
240
+ def __exit__(self, exc_type, exc_val, exc_tb):
241
+ self.close()
@@ -0,0 +1,142 @@
1
+ """Client key rotation, per-user API keys, and tenant settings."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from auth.client.rbac import RbacMixin
6
+
7
+
8
+ class ApiKeyMixin(RbacMixin):
9
+ """Key rotation, per-user API keys and tenant settings."""
10
+
11
+ def rotate_key(self) -> Dict[str, Any]:
12
+ """Rotate this client's API key (atomic cutover).
13
+
14
+ The server mints a fresh key, moves the whole namespace onto it, and
15
+ returns it in ``data.new_key``. On success this client is updated in
16
+ place — ``self.api_key`` and the session ``Authorization`` header switch
17
+ to the new key — so subsequent calls on this instance keep working. The
18
+ returned key is the ONLY copy: persist ``data.new_key`` (e.g. to your
19
+ secret store) or you lose access to the namespace.
20
+
21
+ Transport failure raises :class:`AuthTransportError` — map it to
22
+ your unavailable/503 path, never to a denial.
23
+ """
24
+ endpoint = self.endpoints["rotate_key"]
25
+ try:
26
+ response = self._make_request("POST", endpoint)
27
+ except Exception as e:
28
+ return self._transport_failure(e, data={})
29
+
30
+ new_key = (response or {}).get("data", {}).get("new_key")
31
+ if new_key:
32
+ self.api_key = new_key
33
+ self.session.headers["Authorization"] = f"Bearer {new_key}"
34
+ return response
35
+
36
+ # Per-user API keys (SPEC 0004)
37
+ def create_api_key(self, user: str, label: Optional[str] = None) -> Dict[str, Any]:
38
+ """Mint an API key for a user; ``data.api_key`` is shown only once.
39
+
40
+ Sent WITHOUT automatic retries: create is not idempotent, and a blind
41
+ retry after an ambiguous failure could mint a second key whose secret
42
+ nobody ever saw. On an ambiguous failure, list and revoke instead.
43
+
44
+ Transport failure raises :class:`AuthTransportError` — map it to
45
+ your unavailable/503 path, never to a denial.
46
+ """
47
+ endpoint = self.endpoints["apikeys_user"].format(user=user)
48
+ payload = {"label": label} if label is not None else None
49
+ try:
50
+ return self._make_request("POST", endpoint, retry=False, json=payload)
51
+ except Exception as e:
52
+ return self._transport_failure(e, data={"user": user, "label": label})
53
+
54
+ def list_api_keys(self, user: str) -> Dict[str, Any]:
55
+ """List a user's API keys (metadata only; never the secrets).
56
+
57
+ Transport failure raises :class:`AuthTransportError` — map it to
58
+ your unavailable/503 path, never to a denial.
59
+ """
60
+ endpoint = self.endpoints["apikeys_user"].format(user=user)
61
+ try:
62
+ return self._make_request("GET", endpoint)
63
+ except Exception as e:
64
+ return self._transport_failure(e, data={"user": user})
65
+
66
+ def revoke_api_key(self, user: str, key_id: str) -> Dict[str, Any]:
67
+ """Revoke one of a user's API keys by its public key_id (idempotent).
68
+
69
+ Transport failure raises :class:`AuthTransportError` — map it to
70
+ your unavailable/503 path, never to a denial.
71
+ """
72
+ endpoint = self.endpoints["apikey_revoke"].format(user=user, key_id=key_id)
73
+ try:
74
+ return self._make_request("DELETE", endpoint)
75
+ except Exception as e:
76
+ return self._transport_failure(e, data={"user": user, "key_id": key_id})
77
+
78
+ def validate_api_key(self, api_key: str) -> Dict[str, Any]:
79
+ """Validate an API-key secret; answers ``data.valid`` true/false.
80
+
81
+ The secret travels in the JSON body, never a URL, and never rides
82
+ on an exception.
83
+
84
+ Transport failure raises :class:`AuthTransportError` — map it to
85
+ your unavailable/503 path, never to a denial.
86
+ """
87
+ try:
88
+ return self._make_request(
89
+ "POST", self.endpoints["apikey_validate"], json={"api_key": api_key}
90
+ )
91
+ except Exception as e:
92
+ return self._transport_failure(e, data={"key_prefix": api_key[:12]})
93
+
94
+ def check_api_key_permission(self, api_key: str, permission: str) -> Dict[str, Any]:
95
+ """Validate a secret AND check its subject's permission in one call.
96
+
97
+ ``data.valid`` false → the key failed (reason as in validate_api_key);
98
+ true → ``data.has_permission`` answers for the key's user. The secret
99
+ travels in the JSON body and never rides on an exception.
100
+
101
+ Transport failure raises :class:`AuthTransportError` — map it to
102
+ your unavailable/503 path, never to a denial.
103
+ """
104
+ try:
105
+ return self._make_request(
106
+ "POST",
107
+ self.endpoints["apikey_check_permission"],
108
+ json={"api_key": api_key, "permission": permission},
109
+ )
110
+ except Exception as e:
111
+ return self._transport_failure(
112
+ e, data={"key_prefix": api_key[:12], "permission": permission}
113
+ )
114
+
115
+ # Tenant settings (SPEC 0010)
116
+ def get_settings(self) -> Dict[str, Any]:
117
+ """This tenant's settings (``data.strict_users``).
118
+
119
+ Transport failure raises :class:`AuthTransportError` — map it to
120
+ your unavailable/503 path, never to a denial.
121
+ """
122
+ try:
123
+ return self._make_request("GET", self.endpoints["settings"])
124
+ except Exception as e:
125
+ return self._transport_failure(e)
126
+
127
+ def set_strict_users(self, enabled: bool) -> Dict[str, Any]:
128
+ """Enable/disable strict user identity for this tenant (idempotent).
129
+
130
+ While enabled, authorization decisions about users with no live API
131
+ key answer negatively (``reason: user_not_key_backed``) — issue keys
132
+ before flipping this on.
133
+
134
+ Transport failure raises :class:`AuthTransportError` — map it to
135
+ your unavailable/503 path, never to a denial.
136
+ """
137
+ try:
138
+ return self._make_request(
139
+ "PUT", self.endpoints["settings"], json={"strict_users": enabled}
140
+ )
141
+ except Exception as e:
142
+ return self._transport_failure(e, data={"strict_users": enabled})