safe-api-keys 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. safe_api_keys/__init__.py +52 -0
  2. safe_api_keys/_logic.py +414 -0
  3. safe_api_keys/_util.py +65 -0
  4. safe_api_keys/amanager.py +281 -0
  5. safe_api_keys/audit.py +98 -0
  6. safe_api_keys/cache.py +75 -0
  7. safe_api_keys/cli.py +271 -0
  8. safe_api_keys/contrib/__init__.py +1 -0
  9. safe_api_keys/contrib/_common.py +66 -0
  10. safe_api_keys/contrib/django/__init__.py +29 -0
  11. safe_api_keys/contrib/django/admin.py +131 -0
  12. safe_api_keys/contrib/django/apps.py +14 -0
  13. safe_api_keys/contrib/django/conf.py +214 -0
  14. safe_api_keys/contrib/django/decorators.py +90 -0
  15. safe_api_keys/contrib/django/drf.py +130 -0
  16. safe_api_keys/contrib/django/management/__init__.py +0 -0
  17. safe_api_keys/contrib/django/management/commands/__init__.py +0 -0
  18. safe_api_keys/contrib/django/management/commands/apikey.py +28 -0
  19. safe_api_keys/contrib/django/middleware.py +33 -0
  20. safe_api_keys/contrib/django/migrations/0001_initial.py +39 -0
  21. safe_api_keys/contrib/django/migrations/__init__.py +0 -0
  22. safe_api_keys/contrib/django/models.py +63 -0
  23. safe_api_keys/contrib/fastapi.py +165 -0
  24. safe_api_keys/contrib/flask.py +133 -0
  25. safe_api_keys/contrib/starlette.py +111 -0
  26. safe_api_keys/env.py +56 -0
  27. safe_api_keys/exceptions.py +139 -0
  28. safe_api_keys/extract.py +78 -0
  29. safe_api_keys/format.py +150 -0
  30. safe_api_keys/hashing.py +186 -0
  31. safe_api_keys/http.py +114 -0
  32. safe_api_keys/manager.py +283 -0
  33. safe_api_keys/models.py +180 -0
  34. safe_api_keys/policy.py +112 -0
  35. safe_api_keys/py.typed +0 -0
  36. safe_api_keys/scopes.py +42 -0
  37. safe_api_keys/stores/__init__.py +84 -0
  38. safe_api_keys/stores/base.py +147 -0
  39. safe_api_keys/stores/django.py +135 -0
  40. safe_api_keys/stores/memory.py +79 -0
  41. safe_api_keys/stores/redis.py +303 -0
  42. safe_api_keys/stores/sqlalchemy.py +329 -0
  43. safe_api_keys/stores/sqlite.py +149 -0
  44. safe_api_keys-0.1.0.dist-info/METADATA +1252 -0
  45. safe_api_keys-0.1.0.dist-info/RECORD +49 -0
  46. safe_api_keys-0.1.0.dist-info/WHEEL +5 -0
  47. safe_api_keys-0.1.0.dist-info/entry_points.txt +2 -0
  48. safe_api_keys-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. safe_api_keys-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ """safe-api-keys: framework-agnostic API key lifecycle management.
2
+
3
+ Quick start::
4
+
5
+ from safe_api_keys import KeyManager
6
+ from safe_api_keys.stores import MemoryStore
7
+
8
+ km = KeyManager(MemoryStore(), prefix="sk_test", pepper=b"change-me-32-bytes-of-randomness!")
9
+ issued = km.issue("user-1", scopes=["orders:read"])
10
+ record = km.verify(issued.raw_key, scopes=["orders:read"])
11
+ """
12
+
13
+ from .amanager import AsyncKeyManager, AsyncKeyVerifier
14
+ from .audit import AuditEvent, AuditSink, CallbackAuditSink, LoggingAuditSink, NullAuditSink
15
+ from .cache import VerifyCache
16
+ from .exceptions import (
17
+ APIKeyError,
18
+ ConfigurationError,
19
+ ExpiredKey,
20
+ InsufficientScope,
21
+ IPNotAllowed,
22
+ MalformedKey,
23
+ MissingDependency,
24
+ MissingKey,
25
+ NotSupported,
26
+ PolicyViolation,
27
+ RevokedKey,
28
+ SafeAPIKeysError,
29
+ StoreError,
30
+ UnknownKey,
31
+ )
32
+ from .extract import ExtractConfig, extract_key
33
+ from .format import KeyFormat, mask_key, parse_key
34
+ from .hashing import DUMMY_HASH, Argon2Hasher, Hasher, HmacSha256Hasher, Sha256Hasher
35
+ from .manager import KeyManager, KeyVerifier
36
+ from .models import IssuedKey, KeyRecord, ParsedKey, VerifyResult
37
+ from .policy import KeyPolicy
38
+ from .scopes import has_scope, missing_scopes
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "KeyManager", "KeyVerifier", "AsyncKeyManager", "AsyncKeyVerifier",
44
+ "KeyRecord", "IssuedKey", "ParsedKey", "VerifyResult", "KeyFormat", "KeyPolicy", "VerifyCache",
45
+ "Hasher", "HmacSha256Hasher", "Sha256Hasher", "Argon2Hasher", "DUMMY_HASH",
46
+ "AuditEvent", "AuditSink", "NullAuditSink", "LoggingAuditSink", "CallbackAuditSink",
47
+ "ExtractConfig", "extract_key", "parse_key", "mask_key", "has_scope", "missing_scopes",
48
+ "SafeAPIKeysError", "APIKeyError", "MissingKey", "MalformedKey", "UnknownKey", "RevokedKey", "ExpiredKey",
49
+ "InsufficientScope", "IPNotAllowed", "StoreError", "PolicyViolation", "ConfigurationError",
50
+ "MissingDependency", "NotSupported",
51
+ "__version__",
52
+ ]
@@ -0,0 +1,414 @@
1
+ """Pure decision logic shared by the sync and async managers (§2.4).
2
+
3
+ Every rule (verification order, policy, rotation maths, IP matching) lives here
4
+ exactly once. The managers only add I/O around these functions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import logging
11
+ import warnings
12
+ from datetime import datetime, timedelta
13
+ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Tuple, Union
14
+
15
+ from ._util import ensure_aware, optional_aware, utcnow
16
+ from .audit import AuditEvent, AuditSink, NullAuditSink, safe_emit
17
+ from .cache import VerifyCache
18
+ from .exceptions import (
19
+ APIKeyError,
20
+ ConfigurationError,
21
+ ExpiredKey,
22
+ InsufficientScope,
23
+ IPNotAllowed,
24
+ MalformedKey,
25
+ RevokedKey,
26
+ SafeAPIKeysError,
27
+ StoreError,
28
+ UnknownKey,
29
+ )
30
+ from .format import MAX_RAW_KEY_LENGTH, KeyFormat, generate_key, parse_key, validate_prefix
31
+ from .hashing import Hasher, HasherRegistry
32
+ from .models import IssuedKey, KeyRecord, ParsedKey
33
+ from .policy import KeyPolicy, validate_metadata
34
+ from .scopes import missing_scopes, normalize_scopes, scope_covers
35
+
36
+ __all__ = [
37
+ "IPNetwork",
38
+ "normalize_ip",
39
+ "normalize_allowlist",
40
+ "ip_allowed",
41
+ "prepare_raw",
42
+ "check_secret",
43
+ "decide",
44
+ "check_scopes",
45
+ "check_ip",
46
+ "should_touch",
47
+ "build_new_key",
48
+ "plan_revoke",
49
+ "plan_rotation",
50
+ "key_id_from",
51
+ "KEY_ID_RETRIES",
52
+ ]
53
+
54
+ logger = logging.getLogger("safe_api_keys")
55
+ IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
56
+ IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
57
+ KEY_ID_RETRIES = 3
58
+ Clock = Callable[[], datetime]
59
+
60
+
61
+ # ---------------------------------------------------------------------------- IP
62
+ def normalize_ip(value: str) -> IPAddr:
63
+ """Parse an address; IPv4-mapped IPv6 (``::ffff:1.2.3.4``) becomes IPv4 (§7.3)."""
64
+ addr = ipaddress.ip_address(str(value).strip().split("%", 1)[0])
65
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
66
+ return addr.ipv4_mapped
67
+ return addr
68
+
69
+
70
+ def _parse_network(value: str) -> IPNetwork:
71
+ net = ipaddress.ip_network(str(value).strip(), strict=False)
72
+ if isinstance(net, ipaddress.IPv6Network) and net.network_address.ipv4_mapped is not None \
73
+ and net.prefixlen >= 96:
74
+ return ipaddress.ip_network(f"{net.network_address.ipv4_mapped}/{net.prefixlen - 96}")
75
+ return net
76
+
77
+
78
+ def normalize_allowlist(entries: Optional[Iterable[str]]) -> Tuple[str, ...]:
79
+ if entries is None:
80
+ return ()
81
+ if isinstance(entries, str):
82
+ entries = [entries]
83
+ out = []
84
+ for e in entries:
85
+ try:
86
+ out.append(str(_parse_network(e)))
87
+ except ValueError:
88
+ raise ValueError(f"invalid IP/CIDR in ip_allowlist: {e!r}") from None
89
+ return tuple(sorted(set(out)))
90
+
91
+
92
+ def ip_allowed(allowlist: Iterable[str], client_ip: str) -> bool:
93
+ try:
94
+ addr = normalize_ip(client_ip)
95
+ except ValueError:
96
+ return False
97
+ for entry in allowlist:
98
+ try:
99
+ net = _parse_network(entry)
100
+ except ValueError:
101
+ continue
102
+ if addr.version == net.version and addr in net:
103
+ return True
104
+ return False
105
+
106
+
107
+ # ---------------------------------------------------------------------------- verify steps
108
+ def prepare_raw(raw_key: Any, key_format: KeyFormat) -> ParsedKey:
109
+ """Steps 1-3: length/ASCII, parse + checksum, prefix binding. Never touches the store."""
110
+ if not isinstance(raw_key, str):
111
+ raise MalformedKey(reason="malformed")
112
+ raw = raw_key.strip()
113
+ if len(raw) > MAX_RAW_KEY_LENGTH or not raw.isascii():
114
+ raise MalformedKey(reason="malformed")
115
+ parsed = parse_key(raw, key_format)
116
+ if parsed.prefix != key_format.prefix:
117
+ raise MalformedKey(reason="prefix", key_id=parsed.key_id)
118
+ return parsed
119
+
120
+
121
+ def check_secret(registry: HasherRegistry, parsed: ParsedKey, record: Optional[KeyRecord]) -> KeyRecord:
122
+ """Steps 5-6. Unknown ids burn a dummy hash so timing matches a wrong secret (§4.3)."""
123
+ if record is None:
124
+ registry.dummy_verify()
125
+ raise UnknownKey(reason="unknown", key_id=parsed.key_id)
126
+ hasher = registry.get(record.hash_alg)
127
+ if hasher is None:
128
+ registry.dummy_verify()
129
+ raise UnknownKey(reason="pepper_version_missing", key_id=parsed.key_id)
130
+ if not hasher.verify(parsed.body, record.hash) or record.prefix != parsed.prefix:
131
+ raise UnknownKey(reason="bad_secret", key_id=parsed.key_id)
132
+ return record
133
+
134
+
135
+ def check_scopes(record: KeyRecord, scopes: Optional[Sequence[str]], any_scopes: Optional[Sequence[str]]) -> None:
136
+ required = tuple(scopes or ())
137
+ missing = missing_scopes(record.scopes, required)
138
+ any_req = tuple(any_scopes or ())
139
+ if any_req and not any(scope_covers(g, r) for g in record.scopes for r in any_req):
140
+ missing = missing + [r for r in any_req if r not in missing]
141
+ if missing:
142
+ raise InsufficientScope(required=list(required) + [r for r in any_req if r not in required],
143
+ missing=missing, key_id=record.key_id)
144
+
145
+
146
+ def check_ip(record: KeyRecord, client_ip: Optional[str]) -> None:
147
+ if not record.ip_allowlist:
148
+ return
149
+ if not client_ip:
150
+ raise IPNotAllowed(reason="no_client_ip", key_id=record.key_id)
151
+ if not ip_allowed(record.ip_allowlist, client_ip):
152
+ raise IPNotAllowed(reason="ip", key_id=record.key_id)
153
+
154
+
155
+ def decide(record: KeyRecord, now: datetime, *, scopes: Optional[Sequence[str]] = None,
156
+ any_scopes: Optional[Sequence[str]] = None, client_ip: Optional[str] = None) -> None:
157
+ """Steps 7-10 in fixed order: revoked -> expired -> scope -> ip (§5.2)."""
158
+ now = ensure_aware(now, "now")
159
+ if record.revoked_at is not None:
160
+ raise RevokedKey(key_id=record.key_id)
161
+ if record.expires_at is not None and record.expires_at <= now:
162
+ raise ExpiredKey(key_id=record.key_id)
163
+ check_scopes(record, scopes, any_scopes)
164
+ check_ip(record, client_ip)
165
+
166
+
167
+ def should_touch(record: KeyRecord, now: datetime, interval: Optional[float]) -> bool:
168
+ if interval is None:
169
+ return False
170
+ if record.last_used_at is None:
171
+ return True
172
+ return (now - record.last_used_at).total_seconds() >= interval
173
+
174
+
175
+ def key_id_from(value: str, key_format: KeyFormat) -> str:
176
+ """Accept either a key_id or a raw key (key ids never contain ``_``)."""
177
+ value = (value or "").strip()
178
+ if "_" in value:
179
+ return prepare_raw(value, key_format).key_id
180
+ if not value:
181
+ raise UnknownKey(reason="unknown")
182
+ return value
183
+
184
+
185
+ # ---------------------------------------------------------------------------- issue / rotate
186
+ def _resolve_expiry(now: datetime, expires_at: Optional[datetime],
187
+ expires_in: Optional[timedelta]) -> Optional[datetime]:
188
+ if expires_at is not None and expires_in is not None:
189
+ raise ValueError("expires_at and expires_in are mutually exclusive")
190
+ if expires_in is not None:
191
+ if not isinstance(expires_in, timedelta):
192
+ raise TypeError("expires_in must be a timedelta")
193
+ if expires_in <= timedelta(0):
194
+ raise ValueError("expires_in must be positive")
195
+ return now + expires_in
196
+ expires_at = optional_aware(expires_at, "expires_at")
197
+ if expires_at is not None and expires_at <= now:
198
+ raise ValueError("expires_at must be in the future")
199
+ return expires_at
200
+
201
+
202
+ def _validate_text(value: Any, name: str, *, required: bool) -> str:
203
+ if value is None:
204
+ value = ""
205
+ if not isinstance(value, str):
206
+ raise ValueError(f"{name} must be a string")
207
+ if required and not value.strip():
208
+ raise ValueError(f"{name} is required")
209
+ if len(value) > 255:
210
+ raise ValueError(f"{name} must be at most 255 characters")
211
+ return value
212
+
213
+
214
+ def build_new_key(
215
+ *,
216
+ key_format: KeyFormat,
217
+ hasher: Hasher,
218
+ policy: KeyPolicy,
219
+ now: datetime,
220
+ owner: str,
221
+ scopes: Iterable[str] = (),
222
+ expires_at: Optional[datetime] = None,
223
+ expires_in: Optional[timedelta] = None,
224
+ name: str = "",
225
+ metadata: Optional[Mapping[str, Any]] = None,
226
+ ip_allowlist: Iterable[str] = (),
227
+ key_id: Optional[str] = None,
228
+ rotated_from: Optional[str] = None,
229
+ ) -> IssuedKey:
230
+ """Validate + apply policy + generate + hash. Pure: does not persist."""
231
+ owner = _validate_text(owner, "owner", required=True)
232
+ name = _validate_text(name, "name", required=False)
233
+ norm_scopes = normalize_scopes(scopes)
234
+ policy.check_scopes(norm_scopes)
235
+ exp = policy.resolve_expiry(now, _resolve_expiry(now, expires_at, expires_in))
236
+ meta = validate_metadata(metadata, policy.max_metadata_bytes)
237
+ allow = normalize_allowlist(ip_allowlist)
238
+ raw, parsed = generate_key(key_format, key_id)
239
+ record = KeyRecord(
240
+ key_id=parsed.key_id, prefix=key_format.prefix, hash=hasher.hash(parsed.body), hash_alg=hasher.alg_id,
241
+ secret_last4=parsed.secret[-4:], owner=owner, created_at=now, name=name, scopes=norm_scopes,
242
+ expires_at=exp, ip_allowlist=allow, metadata=meta, rotated_from=rotated_from,
243
+ )
244
+ return IssuedKey(raw_key=raw, record=record)
245
+
246
+
247
+ def plan_revoke(record: KeyRecord, now: datetime, reason: Optional[str]) -> KeyRecord:
248
+ if reason is not None:
249
+ reason = _validate_text(reason, "reason", required=False) or None
250
+ return record.replace(revoked_at=now, revoke_reason=reason)
251
+
252
+
253
+ def plan_rotation(old: KeyRecord, new_key_id: str, now: datetime, grace: timedelta) -> KeyRecord:
254
+ """Old key: ``expires_at = min(original, now + grace)``; ``grace == 0`` revokes immediately."""
255
+ if not isinstance(grace, timedelta) or grace < timedelta(0):
256
+ raise ValueError("grace must be a non-negative timedelta")
257
+ if grace == timedelta(0):
258
+ return old.replace(rotated_to=new_key_id, revoked_at=now, revoke_reason="rotated")
259
+ cutoff = now + grace
260
+ exp = cutoff if old.expires_at is None else min(old.expires_at, cutoff)
261
+ return old.replace(rotated_to=new_key_id, expires_at=exp)
262
+
263
+
264
+ # ---------------------------------------------------------------------------- shared manager core
265
+ class _Core:
266
+ """Configuration, validation and audit plumbing shared by every manager/verifier."""
267
+
268
+ _is_manager = False
269
+
270
+ def __init__(
271
+ self,
272
+ store: Any,
273
+ prefix: str,
274
+ *,
275
+ peppers: Optional[Mapping[str, bytes]] = None,
276
+ current_pepper: Optional[str] = None,
277
+ pepper: Optional[bytes] = None,
278
+ hasher: Optional[Hasher] = None,
279
+ key_format: Optional[KeyFormat] = None,
280
+ policy: Optional[KeyPolicy] = None,
281
+ touch_interval: Optional[float] = 60.0,
282
+ cache: Optional[VerifyCache] = None,
283
+ audit: Optional[AuditSink] = None,
284
+ clock: Optional[Clock] = None,
285
+ reveal_state: bool = True,
286
+ audit_success: bool = True,
287
+ require_pepper: Optional[bool] = None,
288
+ ) -> None:
289
+ if store is None:
290
+ raise ConfigurationError("store is required")
291
+ validate_prefix(prefix)
292
+ if key_format is None:
293
+ key_format = KeyFormat(prefix)
294
+ elif key_format.prefix != prefix:
295
+ raise ConfigurationError("key_format.prefix must equal prefix")
296
+ if touch_interval is not None and touch_interval < 0:
297
+ raise ConfigurationError("touch_interval must be >= 0 or None")
298
+ self.policy = policy or KeyPolicy()
299
+ self.registry, self.has_pepper = HasherRegistry.build(
300
+ peppers=peppers, current_pepper=current_pepper, pepper=pepper, hasher=hasher)
301
+ if require_pepper is None:
302
+ require_pepper = self.policy.require_pepper
303
+ if not self.has_pepper and hasher is None:
304
+ if require_pepper:
305
+ raise ConfigurationError(
306
+ "a pepper is required (pass pepper=/peppers= or set SAFE_API_KEYS_PEPPER); "
307
+ "to allow unpeppered SHA-256 explicitly use KeyPolicy(require_pepper=False)")
308
+ warnings.warn("safe_api_keys: no pepper configured; falling back to unpeppered SHA-256. "
309
+ "Configure a pepper for production.", UserWarning, stacklevel=3)
310
+ self.store = store
311
+ self.prefix = prefix
312
+ self.key_format = key_format
313
+ self.touch_interval = touch_interval
314
+ self.cache = cache
315
+ self.audit: AuditSink = audit if audit is not None else NullAuditSink()
316
+ self._clock = clock or utcnow
317
+ self.reveal_state = reveal_state
318
+ self.audit_success = audit_success
319
+
320
+ @classmethod
321
+ def from_env(cls, store: Any, prefix: str, **overrides: Any) -> Any:
322
+ """Build with peppers from ``SAFE_API_KEYS_PEPPER(S)`` / ``SAFE_API_KEYS_CURRENT_PEPPER``."""
323
+ from .env import from_env
324
+
325
+ return from_env(cls, store, prefix, **overrides)
326
+
327
+ # -- helpers --------------------------------------------------------------
328
+ def now(self) -> datetime:
329
+ return ensure_aware(self._clock(), "clock()")
330
+
331
+ def _event(self, type_: str, **kw: Any) -> None:
332
+ if "at" not in kw:
333
+ kw["at"] = self.now()
334
+ safe_emit(self.audit, AuditEvent(type=type_, **kw))
335
+
336
+ def _require(self, name: str) -> Callable[..., Any]:
337
+ from .exceptions import NotSupported
338
+
339
+ fn = getattr(self.store, name, None)
340
+ if fn is None or not callable(fn):
341
+ raise NotSupported(f"{type(self.store).__name__} does not support {name}()")
342
+ return fn # type: ignore[no-any-return]
343
+
344
+ @staticmethod
345
+ def _wrap_store_error(exc: BaseException) -> StoreError:
346
+ if isinstance(exc, StoreError):
347
+ return exc
348
+ err = StoreError(f"store operation failed: {type(exc).__name__}")
349
+ err.__cause__ = exc
350
+ return err
351
+
352
+ @staticmethod
353
+ def _is_passthrough(exc: BaseException) -> bool:
354
+ return isinstance(exc, SafeAPIKeysError) and not isinstance(exc, StoreError)
355
+
356
+ def parse(self, raw_key: str) -> ParsedKey:
357
+ return prepare_raw(raw_key, self.key_format)
358
+
359
+ def mask(self, raw_key: str) -> str:
360
+ return self.parse(raw_key).masked
361
+
362
+ def _reject(self, exc: APIKeyError, parsed: Optional[ParsedKey], record: Optional[KeyRecord],
363
+ scopes: Optional[Sequence[str]], any_scopes: Optional[Sequence[str]],
364
+ client_ip: Optional[str]) -> APIKeyError:
365
+ """Audit a rejection and map revealing errors when ``reveal_state=False``."""
366
+ req = tuple(scopes or ()) + tuple(any_scopes or ())
367
+ self._event("key.rejected", key_id=exc.key_id or (parsed.key_id if parsed else None),
368
+ owner=record.owner if record else None, reason=exc.reason,
369
+ scopes_required=req or None, client_ip=client_ip)
370
+ if not self.reveal_state and isinstance(exc, (RevokedKey, ExpiredKey)):
371
+ hidden = UnknownKey(reason=exc.reason, key_id=exc.key_id)
372
+ hidden.__suppress_context__ = True
373
+ return hidden
374
+ return exc
375
+
376
+ def _verified(self, record: KeyRecord, scopes: Optional[Sequence[str]],
377
+ any_scopes: Optional[Sequence[str]], client_ip: Optional[str]) -> None:
378
+ if not self.audit_success:
379
+ return
380
+ extra = {"rotated_to": record.rotated_to} if record.rotated_to else {}
381
+ req = tuple(scopes or ()) + tuple(any_scopes or ())
382
+ self._event("key.verified", key_id=record.key_id, owner=record.owner,
383
+ scopes_required=req or None, client_ip=client_ip, extra=extra)
384
+
385
+ def authorize(self, record: KeyRecord, *, scopes: Optional[Sequence[str]] = None,
386
+ any_scopes: Optional[Sequence[str]] = None, client_ip: Optional[str] = None) -> KeyRecord:
387
+ """Scope check for an already-verified record (e.g. set by middleware). No I/O."""
388
+ try:
389
+ check_scopes(record, scopes, any_scopes)
390
+ except APIKeyError as exc:
391
+ raise self._reject(exc, None, record, scopes, any_scopes, client_ip) from None
392
+ return record
393
+
394
+ def _touched(self, record: KeyRecord, now: datetime) -> KeyRecord:
395
+ updated = record.replace(last_used_at=now, use_count=record.use_count + 1)
396
+ if self.cache is not None:
397
+ self.cache.update(updated)
398
+ return updated
399
+
400
+ def _touch_failed(self, record: KeyRecord, exc: BaseException) -> None:
401
+ logger.warning("touch failed for key_id=%s: %s", record.key_id, type(exc).__name__)
402
+ self._event("key.touch_failed", key_id=record.key_id, owner=record.owner,
403
+ reason=type(exc).__name__)
404
+
405
+ def _invalidate(self, key_id: str) -> None:
406
+ if self.cache is not None:
407
+ self.cache.invalidate(key_id)
408
+
409
+ def _issue_check_policy_count(self, active: int) -> None:
410
+ self.policy.check_active_count(active)
411
+
412
+ def __repr__(self) -> str:
413
+ return (f"{type(self).__name__}(prefix={self.prefix!r}, store={type(self.store).__name__}, "
414
+ f"hash_alg={self.registry.current.alg_id!r})")
safe_api_keys/_util.py ADDED
@@ -0,0 +1,65 @@
1
+ """Small shared helpers (time, durations). Zero dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timedelta, timezone
7
+ from email.utils import format_datetime
8
+ from typing import Optional, Union
9
+
10
+ UTC = timezone.utc
11
+
12
+ _DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhdw]?)\s*$")
13
+ _UNITS = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
14
+
15
+
16
+ def utcnow() -> datetime:
17
+ return datetime.now(UTC)
18
+
19
+
20
+ def ensure_aware(value: datetime, name: str = "datetime") -> datetime:
21
+ """Reject naive datetimes (§2.5) and normalise to UTC."""
22
+ if not isinstance(value, datetime):
23
+ raise TypeError(f"{name} must be a datetime, got {type(value).__name__}")
24
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
25
+ raise ValueError(f"{name} must be timezone-aware (UTC); got naive datetime")
26
+ return value.astimezone(UTC)
27
+
28
+
29
+ def optional_aware(value: Optional[datetime], name: str) -> Optional[datetime]:
30
+ return None if value is None else ensure_aware(value, name)
31
+
32
+
33
+ def parse_duration(value: Union[str, int, float, timedelta]) -> timedelta:
34
+ """Parse ``"90d"``, ``"24h"``, ``"30m"``, ``"45s"``, ``"2w"`` or a number of seconds."""
35
+ if isinstance(value, timedelta):
36
+ return value
37
+ if isinstance(value, bool):
38
+ raise ValueError(f"invalid duration: {value!r}")
39
+ if isinstance(value, (int, float)):
40
+ return timedelta(seconds=value)
41
+ m = _DURATION_RE.match(str(value))
42
+ if not m:
43
+ raise ValueError(f"invalid duration: {value!r} (expected e.g. 90d, 24h, 30m, 45s)")
44
+ return timedelta(seconds=int(m.group(1)) * _UNITS[m.group(2)])
45
+
46
+
47
+ def parse_datetime(value: str) -> datetime:
48
+ """Parse an ISO-8601 timestamp; ``Z`` suffix accepted; must carry a timezone."""
49
+ text = value.strip()
50
+ if text.endswith(("Z", "z")):
51
+ text = text[:-1] + "+00:00"
52
+ return ensure_aware(datetime.fromisoformat(text), "timestamp")
53
+
54
+
55
+ def to_iso(value: datetime) -> str:
56
+ """Fixed-width UTC ISO-8601 (lexicographically sortable): ``YYYY-MM-DDTHH:MM:SS.ffffff+00:00``."""
57
+ return ensure_aware(value).strftime("%Y-%m-%dT%H:%M:%S.%f+00:00")
58
+
59
+
60
+ def from_iso(value: str) -> datetime:
61
+ return parse_datetime(value)
62
+
63
+
64
+ def rfc1123(value: datetime) -> str:
65
+ return format_datetime(ensure_aware(value), usegmt=True)