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.
- {auth-3.1.0 → auth-3.2.0}/PKG-INFO +5 -3
- {auth-3.1.0 → auth-3.2.0}/auth/__init__.py +2 -0
- {auth-3.1.0 → auth-3.2.0}/auth/api_keys.py +22 -0
- {auth-3.1.0 → auth-3.2.0}/auth/audit.py +21 -0
- auth-3.2.0/auth/client/__init__.py +59 -0
- auth-3.2.0/auth/client/_transport.py +81 -0
- auth-3.2.0/auth/client/base.py +241 -0
- auth-3.2.0/auth/client/keys.py +142 -0
- auth-3.2.0/auth/client/rbac.py +200 -0
- auth-3.2.0/auth/client/workflow.py +40 -0
- {auth-3.1.0 → auth-3.2.0}/auth/database.py +29 -146
- auth-3.2.0/auth/docs_page.py +132 -0
- auth-3.2.0/auth/keycheck.py +233 -0
- {auth-3.1.0 → auth-3.2.0}/auth/main.py +21 -0
- {auth-3.1.0 → auth-3.2.0}/auth/models/sql.py +8 -0
- auth-3.2.0/auth/rls.py +122 -0
- auth-3.2.0/auth/routes/__init__.py +51 -0
- auth-3.2.0/auth/routes/_common.py +66 -0
- auth-3.2.0/auth/routes/apikeys.py +198 -0
- auth-3.2.0/auth/routes/membership.py +118 -0
- auth-3.2.0/auth/routes/permissions.py +170 -0
- auth-3.2.0/auth/routes/public.py +166 -0
- auth-3.2.0/auth/routes/roles.py +143 -0
- auth-3.2.0/auth/routes/tenant.py +269 -0
- auth-3.2.0/auth/routes/workflow.py +80 -0
- auth-3.2.0/auth/schema_reconcile.py +241 -0
- auth-3.2.0/auth/services/base.py +106 -0
- auth-3.2.0/auth/services/keys.py +184 -0
- auth-3.2.0/auth/services/queries.py +138 -0
- auth-3.2.0/auth/services/rbac.py +359 -0
- auth-3.2.0/auth/services/recovery.py +127 -0
- auth-3.2.0/auth/services/rotation.py +162 -0
- auth-3.2.0/auth/services/service.py +76 -0
- auth-3.2.0/auth/services/tenant.py +150 -0
- auth-3.2.0/auth/static_docs/__init__.py +8 -0
- auth-3.1.0/auth/docs_page.py → auth-3.2.0/auth/static_docs/api_reference.md +23 -321
- auth-3.2.0/auth/static_docs/claude_guide.md +89 -0
- auth-3.2.0/auth/static_docs/landing.md +81 -0
- auth-3.2.0/auth/static_docs/page.html +21 -0
- {auth-3.1.0 → auth-3.2.0}/auth/validation.py +24 -4
- {auth-3.1.0 → auth-3.2.0}/auth/workflow_checker.py +12 -1
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/PKG-INFO +5 -3
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/SOURCES.txt +32 -2
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/requires.txt +5 -2
- {auth-3.1.0 → auth-3.2.0}/pyproject.toml +13 -3
- auth-3.2.0/tests/test_gunicorn_config_invariants.py +92 -0
- auth-3.2.0/tests/test_no_bundled_libpq.py +69 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_routes_errors.py +3 -1
- auth-3.1.0/auth/client.py +0 -640
- auth-3.1.0/auth/routes.py +0 -806
- auth-3.1.0/auth/services/service.py +0 -977
- {auth-3.1.0 → auth-3.2.0}/LICENSE +0 -0
- {auth-3.1.0 → auth-3.2.0}/README.md +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/circuit_breaker.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/cmd/__init__.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/cmd/server.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/config.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/core/REST/client.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/core/__init__.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/core/models/__init__.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/decorators.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/encryption.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/logging_config.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/response_format.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/sanitizer.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth/server.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.1.0 → auth-3.2.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.1.0 → auth-3.2.0}/setup.cfg +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_api_keys.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_client.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_client_rest.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_cmd_server.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_config.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_docs_page.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_encryption.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_flask.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_key_rotation.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_log_redaction.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_reencryption.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_server.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.1.0 → auth-3.2.0}/tests/test_strict_default.py +0 -0
- {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.
|
|
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
|
|
32
|
+
Requires-Dist: cryptography>=3.0.0
|
|
33
33
|
Requires-Dist: APScheduler<4,>=3.0.0
|
|
34
|
-
Requires-Dist: psycopg
|
|
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})
|