cassetta 0.30.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 (72) hide show
  1. cassetta/__init__.py +16 -0
  2. cassetta/app.py +392 -0
  3. cassetta/auth/__init__.py +52 -0
  4. cassetta/auth/dependencies.py +146 -0
  5. cassetta/auth/jwt_hot_reload.py +264 -0
  6. cassetta/auth/jwt_tokens.py +147 -0
  7. cassetta/auth/manifest_validation.py +74 -0
  8. cassetta/auth/models.py +28 -0
  9. cassetta/auth/observability.py +81 -0
  10. cassetta/backends/__init__.py +0 -0
  11. cassetta/backends/filesystem/__init__.py +0 -0
  12. cassetta/backends/filesystem/claim_storage.py +215 -0
  13. cassetta/backends/filesystem/keystore.py +157 -0
  14. cassetta/backends/filesystem/storage.py +399 -0
  15. cassetta/capabilities.py +76 -0
  16. cassetta/claims.py +73 -0
  17. cassetta/cli/__init__.py +32 -0
  18. cassetta/cli/capabilities.py +107 -0
  19. cassetta/cli/download.py +191 -0
  20. cassetta/cli/send.py +154 -0
  21. cassetta/cli/upload.py +125 -0
  22. cassetta/config.py +381 -0
  23. cassetta/defaults/__init__.py +0 -0
  24. cassetta/defaults/default_access.py +33 -0
  25. cassetta/defaults/default_alias.py +40 -0
  26. cassetta/defaults/default_identity.py +21 -0
  27. cassetta/defaults/default_limits.py +149 -0
  28. cassetta/defaults/default_metrics.py +40 -0
  29. cassetta/defaults/default_transport.py +37 -0
  30. cassetta/defaults/factory.py +74 -0
  31. cassetta/dependencies.py +42 -0
  32. cassetta/downloads.py +212 -0
  33. cassetta/envelopes.py +173 -0
  34. cassetta/gc.py +282 -0
  35. cassetta/limits.py +100 -0
  36. cassetta/mcp_auth.py +116 -0
  37. cassetta/mcp_server.py +1451 -0
  38. cassetta/middleware.py +99 -0
  39. cassetta/mime.py +43 -0
  40. cassetta/models.py +226 -0
  41. cassetta/path_validation.py +36 -0
  42. cassetta/protocols/__init__.py +0 -0
  43. cassetta/protocols/access.py +54 -0
  44. cassetta/protocols/alias.py +45 -0
  45. cassetta/protocols/claim_storage.py +76 -0
  46. cassetta/protocols/config.py +103 -0
  47. cassetta/protocols/identity.py +34 -0
  48. cassetta/protocols/keystore.py +41 -0
  49. cassetta/protocols/limits.py +119 -0
  50. cassetta/protocols/metrics.py +52 -0
  51. cassetta/protocols/reference_transport.py +47 -0
  52. cassetta/protocols/storage.py +75 -0
  53. cassetta/py.typed +0 -0
  54. cassetta/rate_limit/__init__.py +23 -0
  55. cassetta/rate_limit/limiter.py +161 -0
  56. cassetta/routes/__init__.py +0 -0
  57. cassetta/routes/agents.py +378 -0
  58. cassetta/routes/capabilities.py +42 -0
  59. cassetta/routes/download.py +432 -0
  60. cassetta/routes/files.py +650 -0
  61. cassetta/routes/inbox.py +577 -0
  62. cassetta/routes/keys.py +246 -0
  63. cassetta/routes/upload.py +428 -0
  64. cassetta/routes/uploads.py +107 -0
  65. cassetta/send_init.py +329 -0
  66. cassetta/streaming.py +68 -0
  67. cassetta/structured_log.py +290 -0
  68. cassetta-0.30.0.dist-info/METADATA +435 -0
  69. cassetta-0.30.0.dist-info/RECORD +72 -0
  70. cassetta-0.30.0.dist-info/WHEEL +4 -0
  71. cassetta-0.30.0.dist-info/entry_points.txt +2 -0
  72. cassetta-0.30.0.dist-info/licenses/LICENSE +105 -0
cassetta/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Cassetta — a file exchange bus for AI agents.
2
+
3
+ **Nothing is re-exported here, deliberately.** Importing any submodule executes this file first, so
4
+ whatever it imports is imported by everyone — including the CLI client, which needs none of it. Two
5
+ convenience aliases for names in `cassetta.defaults.factory` used to sit here, and they put FastAPI
6
+ and the whole ASGI stack in front of `cassetta upload`: 57 packages installed where 16 will do.
7
+
8
+ The public composition names live where ADR 002 puts them, in `cassetta.defaults.factory`, which is
9
+ where every consumer already imports them from. Adding an alias back here would reintroduce the
10
+ whole defect in exactly the same way, which is why the absence is a test rather than a habit —
11
+ `tests/test_client_install.py`, and the 0.28.0 entry in `CHANGELOG.md` for what changed and why.
12
+ """
13
+
14
+ __version__ = "0.30.0"
15
+
16
+ __all__ = ["__version__"]
cassetta/app.py ADDED
@@ -0,0 +1,392 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections.abc import AsyncIterator, Sequence
5
+ from contextlib import asynccontextmanager
6
+ from datetime import datetime
7
+
8
+ from fastapi import FastAPI, Request, status
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from cassetta import __version__
12
+ from cassetta import gc as _gc
13
+ from cassetta.auth.jwt_hot_reload import init_jwt_hot_reload, init_jwt_key_slots
14
+ from cassetta.config import load_config
15
+ from cassetta.defaults.default_limits import LimitsRejection
16
+ from cassetta.defaults.factory import BackendConfig, build_core_defaults
17
+ from cassetta.mcp_auth import MCPAuthMiddleware
18
+ from cassetta.mcp_server import configure as configure_mcp
19
+ from cassetta.mcp_server import create_mcp_server
20
+ from cassetta.middleware import RequestIdMiddleware
21
+ from cassetta.models import LimitsRejectionBody
22
+ from cassetta.protocols.config import CoreConfig
23
+ from cassetta.protocols.metrics import MetricsProvider
24
+ from cassetta.protocols.storage import BundlePathConflictError, StorageBackend
25
+ from cassetta.rate_limit.limiter import (
26
+ FanoutCapExceeded,
27
+ RateLimitExceeded,
28
+ _record_rate_limit_hit,
29
+ limiter,
30
+ recorded_rate_limit_route,
31
+ )
32
+ from cassetta.structured_log import configure_logging, safe_emit, struct_log
33
+
34
+ logger = logging.getLogger("cassetta")
35
+
36
+ _BUNDLE_NAMESPACES = ("store/", "inbox/")
37
+
38
+
39
+ def _retry_after_seconds(exc: RateLimitExceeded) -> int:
40
+ """Best-effort window-in-seconds extraction from slowapi's exception.
41
+
42
+ slowapi attaches the parsed ``Limit`` (which carries the underlying
43
+ ``RateLimitItem``) on ``exc.limit``; its ``get_expiry`` reports the
44
+ bucket's window length. Falls back to 60s if the structure changes.
45
+ """
46
+ try:
47
+ limit_item = getattr(exc.limit, "limit", None)
48
+ if limit_item is not None:
49
+ return int(limit_item.get_expiry())
50
+ except Exception:
51
+ pass
52
+ return 60
53
+
54
+
55
+ async def _lease_renewal_task(
56
+ backend: StorageBackend,
57
+ key: str,
58
+ lease_id: str,
59
+ interval: float = 30.0,
60
+ ) -> None:
61
+ """Periodically renew a lease to prevent expiry during long sweeps."""
62
+ while True:
63
+ await asyncio.sleep(interval)
64
+ renewed = await backend.renew_lease(key, lease_id)
65
+ if renewed:
66
+ struct_log(logger, logging.DEBUG, "ttl.lease_renewed", detail={"key": key})
67
+ else:
68
+ struct_log(logger, logging.WARNING, "ttl.lease_renewal_failed", detail={"key": key})
69
+ break
70
+
71
+
72
+ async def _ttl_cleanup_loop(
73
+ backend: StorageBackend,
74
+ config: CoreConfig,
75
+ metrics: MetricsProvider | None = None,
76
+ ) -> None:
77
+ """Periodically remove expired bundles across store/ and inbox/ namespaces.
78
+
79
+ Uses lease-based coordination so that only one pod in a multi-pod
80
+ deployment runs the cleanup sweep at a time.
81
+ """
82
+ interval = 60
83
+ lease_key = "ttl-cleanup"
84
+ lease_ttl = 60
85
+
86
+ while True:
87
+ await asyncio.sleep(interval)
88
+ if config.default_ttl <= 0:
89
+ continue
90
+
91
+ lease_id = await backend.acquire_lease(lease_key, ttl_seconds=lease_ttl)
92
+ if lease_id is None:
93
+ struct_log(logger, logging.DEBUG, "ttl.lease_skipped", detail={"reason": "held by another pod"})
94
+ continue
95
+
96
+ struct_log(logger, logging.INFO, "ttl.sweep_started")
97
+
98
+ renewal = asyncio.create_task(_lease_renewal_task(backend, lease_key, lease_id))
99
+
100
+ deleted_count = 0
101
+ try:
102
+ now = time.time()
103
+ for prefix in _BUNDLE_NAMESPACES:
104
+ for ref in backend.list_bundles(prefix, include_orphans=False):
105
+ try:
106
+ meta = await backend.read_bundle_meta(ref.path)
107
+ except FileNotFoundError:
108
+ continue
109
+ created_iso = str(meta.get("created_at", ""))
110
+ try:
111
+ created = datetime.fromisoformat(created_iso)
112
+ except ValueError:
113
+ continue
114
+ if now - created.timestamp() > config.default_ttl:
115
+ try:
116
+ await backend.delete_bundle(ref.path)
117
+ deleted_count += 1
118
+ struct_log(
119
+ logger, logging.INFO, "ttl.file_deleted", resource=f"bundle:{ref.path}", result="ok"
120
+ )
121
+ except FileNotFoundError:
122
+ continue
123
+ except Exception:
124
+ struct_log(
125
+ logger, logging.ERROR, "ttl.file_error", resource=f"bundle:{ref.path}", result="error"
126
+ )
127
+ except Exception:
128
+ struct_log(logger, logging.ERROR, "ttl.sweep_error", result="error")
129
+ finally:
130
+ renewal.cancel()
131
+ try:
132
+ await renewal
133
+ except asyncio.CancelledError:
134
+ pass
135
+ if metrics is not None and deleted_count > 0:
136
+ safe_emit(
137
+ metric_name="cassetta.ttl.cleanup.files_deleted",
138
+ metric_value=deleted_count,
139
+ metrics=metrics,
140
+ )
141
+
142
+ struct_log(logger, logging.INFO, "ttl.cleanup", result="ok", detail={"files_deleted": deleted_count})
143
+
144
+ released = await backend.release_lease(lease_key, lease_id)
145
+ if released:
146
+ struct_log(logger, logging.INFO, "ttl.lease_released")
147
+ else:
148
+ struct_log(logger, logging.WARNING, "ttl.lease_release_failed")
149
+
150
+
151
+ @asynccontextmanager
152
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
153
+ """Application lifespan: initialise backends, run MCP + TTL + GC loops."""
154
+ config: CoreConfig = app.state.config
155
+ backends: BackendConfig = app.state.backends
156
+
157
+ struct_log(
158
+ logger,
159
+ logging.INFO,
160
+ "config_loaded",
161
+ detail={
162
+ "primary_key_source": config.jwt_primary_key_source,
163
+ "secondary_key": config.jwt_secondary_key is not None,
164
+ "public_base_url": config.public_base_url,
165
+ "dev_mode": app.state.dev_mode,
166
+ "rate_limit_broadcast": config.rate_limit_broadcast,
167
+ "broadcast_max_targets": config.broadcast_max_targets,
168
+ "jwt_key_overlap_ttl": config.jwt_key_overlap_ttl,
169
+ },
170
+ )
171
+ # JWT primary-key hot-reload + boot-time validation. Order
172
+ # matters — the validation warning must surface immediately after
173
+ # ``config_loaded`` so dashboards see them in operator-natural order.
174
+ init_jwt_hot_reload(app)
175
+ if app.state.dev_mode:
176
+ struct_log(logger, logging.WARNING, "dev_mode_enabled")
177
+
178
+ backend = backends.backend
179
+ if hasattr(backend, "initialize"):
180
+ await backend.initialize()
181
+
182
+ key_store = backends.key_store
183
+ if hasattr(key_store, "initialize"):
184
+ await key_store.initialize()
185
+
186
+ # claim_storage_backend emission via safe_emit so the record parses as
187
+ # JSON under CASSETTA_LOG_FORMAT=json. This is the single canonical
188
+ # emission — a duplicate on the downstream side was removed.
189
+ safe_emit(
190
+ logger,
191
+ logging.INFO,
192
+ "claim_storage_backend",
193
+ detail={"kind": backends.claim_store.kind},
194
+ )
195
+
196
+ configure_mcp(config, backends, jwt_keys=app.state.jwt_keys)
197
+
198
+ mcp_server = app.state.mcp_server
199
+ async with mcp_server.session_manager.run():
200
+ cleanup_task: asyncio.Task[None] | None = None
201
+ if config.default_ttl > 0:
202
+ cleanup_task = asyncio.create_task(
203
+ _ttl_cleanup_loop(
204
+ backend,
205
+ config,
206
+ metrics=backends.metrics_provider,
207
+ )
208
+ )
209
+
210
+ gc_task: asyncio.Task[None] = _gc.schedule_reaper(
211
+ app,
212
+ backend,
213
+ backends.limits_policy,
214
+ )
215
+
216
+ yield
217
+
218
+ gc_task.cancel()
219
+ try:
220
+ await gc_task
221
+ except asyncio.CancelledError:
222
+ pass
223
+ if cleanup_task is not None:
224
+ cleanup_task.cancel()
225
+ try:
226
+ await cleanup_task
227
+ except asyncio.CancelledError:
228
+ pass
229
+ if hasattr(backend, "close"):
230
+ await backend.close()
231
+
232
+
233
+ def create_app(
234
+ config: CoreConfig | None = None,
235
+ *,
236
+ backends: BackendConfig | None = None,
237
+ extra_log_trees: Sequence[str] = (),
238
+ ) -> FastAPI:
239
+ """Create and configure the FastAPI application.
240
+
241
+ Args:
242
+ config: application configuration — anything satisfying
243
+ :class:`~cassetta.protocols.config.CoreConfig`. Loaded from the
244
+ environment as an :class:`~cassetta.config.AppConfig` when omitted;
245
+ an application embedding this server passes its own settings object
246
+ here.
247
+ backends: ready-made backend implementations; the core defaults are
248
+ built from ``config`` when omitted.
249
+ extra_log_trees: names of additional logger trees to route through the
250
+ configured handler, forwarded verbatim to
251
+ :func:`cassetta.structured_log.configure_logging`. An application
252
+ embedding this server passes its own tree here so one process
253
+ produces one log stream. Empty by default.
254
+ """
255
+ if config is None:
256
+ config = load_config()
257
+
258
+ configure_logging(config.log_format, extra_log_trees)
259
+
260
+ if backends is None:
261
+ backends = build_core_defaults(config)
262
+
263
+ app = FastAPI(title="Cassetta", version=__version__, lifespan=lifespan)
264
+ app.state.config = config
265
+ app.state.backends = backends
266
+ app.state.dev_mode = config.dev_mode
267
+ app.state.setup_token = config.setup_token
268
+ # Shared limiter instance + decorator-friendly state hook.
269
+ # slowapi looks for ``app.state.limiter`` when the decorator runs.
270
+ app.state.limiter = limiter
271
+ # Populate the runtime JWT key holder so verify-call
272
+ # sites work even when the test client bypasses lifespan.
273
+ init_jwt_key_slots(app)
274
+
275
+ @app.exception_handler(BundlePathConflictError)
276
+ async def _bundle_conflict_handler(_request: Request, exc: BundlePathConflictError) -> JSONResponse:
277
+ return JSONResponse(
278
+ status_code=status.HTTP_409_CONFLICT,
279
+ content={
280
+ "error": "bundle_path_conflict",
281
+ "conflicting_path": exc.conflicting_path,
282
+ "kind": exc.kind,
283
+ },
284
+ )
285
+
286
+ @app.exception_handler(LimitsRejection)
287
+ async def _limits_rejection_handler(
288
+ _request: Request,
289
+ exc: LimitsRejection,
290
+ ) -> JSONResponse:
291
+ if exc.error == "cap_exceeded":
292
+ status_code = status.HTTP_413_CONTENT_TOO_LARGE
293
+ else:
294
+ status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
295
+ return JSONResponse(
296
+ status_code=status_code,
297
+ content=LimitsRejectionBody(
298
+ error=exc.error,
299
+ constraint=exc.constraint,
300
+ limit=exc.limit,
301
+ observed=exc.observed,
302
+ ).model_dump(),
303
+ )
304
+
305
+ @app.exception_handler(RateLimitExceeded)
306
+ async def _rate_limit_handler(
307
+ request: Request,
308
+ exc: RateLimitExceeded,
309
+ ) -> JSONResponse:
310
+ # slowapi exposes the parsed Limit on `exc.limit`; we only need
311
+ # the per-bucket window in seconds for Retry-After.
312
+ retry_after = _retry_after_seconds(exc)
313
+ # The caller names its own route when it drives the limiter, so this
314
+ # handler attributes a route it does not serve without knowing the
315
+ # path. A rejection raised by a decorator never passes through that
316
+ # helper and records nothing; `broadcast` is what it counted before.
317
+ _record_rate_limit_hit(request, route=recorded_rate_limit_route(request), reason="rate")
318
+ return JSONResponse(
319
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
320
+ content={"error": "rate_limit", "retry_after": retry_after},
321
+ headers={"Retry-After": str(retry_after)},
322
+ )
323
+
324
+ @app.exception_handler(FanoutCapExceeded)
325
+ async def _fanout_cap_handler(
326
+ request: Request,
327
+ exc: FanoutCapExceeded,
328
+ ) -> JSONResponse:
329
+ _record_rate_limit_hit(request, route="broadcast", reason="fanout_cap")
330
+ return JSONResponse(
331
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
332
+ content={
333
+ "error": "rate_limit",
334
+ "reason": "fanout_cap",
335
+ "max_targets": exc.max_targets,
336
+ },
337
+ headers={"Retry-After": "0"},
338
+ )
339
+
340
+ from cassetta.routes.agents import router as agents_router
341
+ from cassetta.routes.capabilities import router as capabilities_router
342
+ from cassetta.routes.download import (
343
+ DownloadError,
344
+ download_error_handler,
345
+ )
346
+ from cassetta.routes.download import (
347
+ router as download_router,
348
+ )
349
+ from cassetta.routes.files import router as files_router
350
+ from cassetta.routes.inbox import router as inbox_router
351
+ from cassetta.routes.keys import router as keys_router
352
+ from cassetta.routes.upload import router as upload_router
353
+ from cassetta.routes.uploads import router as uploads_router
354
+
355
+ # Registered through the decorator, like the four handlers above it: it types
356
+ # as an identity over the callable, where `add_exception_handler` declares its
357
+ # handler as taking a bare `Exception` and rejects a narrower annotation.
358
+ app.exception_handler(DownloadError)(download_error_handler)
359
+
360
+ app.include_router(agents_router)
361
+ app.include_router(capabilities_router)
362
+ app.include_router(download_router)
363
+ app.include_router(files_router)
364
+ app.include_router(inbox_router)
365
+ app.include_router(keys_router)
366
+ app.include_router(upload_router)
367
+ app.include_router(uploads_router)
368
+
369
+ mcp_server = create_mcp_server(allowed_hosts=config.mcp_allowed_hosts)
370
+ app.state.mcp_server = mcp_server
371
+ mcp_asgi = mcp_server.streamable_http_app()
372
+ app.mount("/mcp", MCPAuthMiddleware(mcp_asgi, fastapi_app=app))
373
+
374
+ @app.get("/health")
375
+ async def health() -> dict[str, str | bool]:
376
+ key_store = app.state.backends.key_store
377
+ if hasattr(key_store, "is_healthy") and not key_store.is_healthy():
378
+ from fastapi.responses import JSONResponse
379
+
380
+ return JSONResponse( # type: ignore[return-value]
381
+ status_code=503,
382
+ content={
383
+ "status": "unhealthy",
384
+ "reason": "key_store_unreachable",
385
+ "dev_mode": app.state.dev_mode,
386
+ },
387
+ )
388
+ return {"status": "ok", "dev_mode": app.state.dev_mode}
389
+
390
+ app.add_middleware(RequestIdMiddleware)
391
+
392
+ return app
@@ -0,0 +1,52 @@
1
+ """Auth package -- public surface for FastAPI deps and key DTOs."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from cassetta.auth.dependencies import (
6
+ get_current_identity,
7
+ get_current_key,
8
+ get_key_store,
9
+ http_bearer,
10
+ require_setup_token,
11
+ )
12
+ from cassetta.auth.models import KEY_PREFIX, KEY_PREFIX_LEN, KeyInfo, KeyRecord
13
+
14
+ if TYPE_CHECKING:
15
+ from cassetta.backends.filesystem.keystore import (
16
+ FileKeyStore,
17
+ )
18
+
19
+ KeyStore = FileKeyStore
20
+
21
+
22
+ def __getattr__(name: str) -> Any:
23
+ """Lazy re-export to avoid the ``auth`` ↔ ``keystore`` circular.
24
+
25
+ ``cassetta.backends.filesystem.keystore`` imports ``KeyInfo`` /
26
+ ``KeyRecord`` from ``cassetta.auth.models``. Importing the submodule
27
+ initialises the ``cassetta.auth`` package, so if this ``__init__.py``
28
+ eagerly pulled ``FileKeyStore`` back from the Layer 3 module we would
29
+ re-enter a partially-loaded ``keystore`` module — fatal. Deferring
30
+ via PEP 562 sidesteps the cycle while preserving
31
+ ``from cassetta.auth import FileKeyStore`` / ``KeyStore``.
32
+ """
33
+ if name in {"FileKeyStore", "KeyStore"}:
34
+ from cassetta.backends.filesystem import keystore as _ks
35
+
36
+ return _ks.FileKeyStore
37
+ raise AttributeError(f"module 'cassetta.auth' has no attribute {name!r}")
38
+
39
+
40
+ __all__ = [
41
+ "KEY_PREFIX",
42
+ "KEY_PREFIX_LEN",
43
+ "FileKeyStore",
44
+ "KeyInfo",
45
+ "KeyRecord",
46
+ "KeyStore",
47
+ "get_current_identity",
48
+ "get_current_key",
49
+ "get_key_store",
50
+ "http_bearer",
51
+ "require_setup_token",
52
+ ]
@@ -0,0 +1,146 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING, Annotated, cast
3
+
4
+ from fastapi import Depends, HTTPException, Request, status
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+
7
+ from cassetta.auth.models import KeyInfo
8
+ from cassetta.auth.observability import Reason, emit_auth_failure
9
+
10
+ if TYPE_CHECKING:
11
+ from cassetta.protocols.identity import Identity, IdentityProvider
12
+ from cassetta.protocols.keystore import KeyStoreProtocol
13
+
14
+ http_bearer = HTTPBearer(auto_error=False)
15
+ logger = logging.getLogger("cassetta.auth")
16
+
17
+
18
+ def get_key_store(request: Request) -> "KeyStoreProtocol":
19
+ """FastAPI dependency to get the KeyStore instance."""
20
+ key_store = request.app.state.backends.key_store
21
+ return cast("KeyStoreProtocol", key_store)
22
+
23
+
24
+ async def get_current_identity(
25
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)],
26
+ request: Request,
27
+ ) -> "Identity":
28
+ """FastAPI dependency for unified authentication.
29
+
30
+ Accepts either an ``X-Setup-Token`` header or an ``Authorization: Bearer``
31
+ header and always produces an :class:`Identity`. Precedence when both
32
+ are present: setup-token wins (protects admin bootstrap from accidental
33
+ bearer-key shadowing).
34
+
35
+ - Dev mode -> dev identity with ``extra["dev"]=True``
36
+ - Valid setup token -> operator identity with ``extra["operator"]=True``
37
+ - Valid bearer key -> identity resolved via the pluggable IdentityProvider
38
+ - Neither / invalid -> 401
39
+ """
40
+ from cassetta.protocols.identity import Identity
41
+
42
+ dev_mode: bool = request.app.state.dev_mode
43
+ if dev_mode:
44
+ identity = Identity(label="dev", extra={"dev": True})
45
+ request.state.identity = identity
46
+ return identity
47
+
48
+ # Setup-token path wins if both are supplied.
49
+ x_setup_token = request.headers.get("X-Setup-Token")
50
+ setup_token: str = request.app.state.setup_token
51
+ if x_setup_token is not None and x_setup_token == setup_token:
52
+ identity = Identity(label="operator", extra={"operator": True})
53
+ request.state.identity = identity
54
+ return identity
55
+
56
+ # Bearer-token path.
57
+ if credentials is not None:
58
+ key_store = get_key_store(request)
59
+ info = await key_store.validate(credentials.credentials)
60
+ if info is not None:
61
+ identity_provider: IdentityProvider = request.app.state.backends.identity_provider
62
+ identity = await identity_provider.resolve(info)
63
+ # Attach the raw key info so callers that need it (e.g. keys.py)
64
+ # can still reach it without a second lookup.
65
+ request.state.key_info = info
66
+ request.state.identity = identity
67
+ return identity
68
+
69
+ # No valid credentials present — emit a single auth.failure event.
70
+ # Setup-token wins on reason precedence (matches the success-path
71
+ # precedence: setup-token is checked before bearer).
72
+ reason: Reason
73
+ identity_hint: str | None
74
+ if x_setup_token is not None:
75
+ reason = "invalid_setup_token"
76
+ identity_hint = None
77
+ elif credentials is not None:
78
+ reason = "invalid_key"
79
+ identity_hint = credentials.credentials[:12] if credentials.credentials else None
80
+ else:
81
+ reason = "missing_bearer"
82
+ identity_hint = None
83
+ emit_auth_failure(
84
+ metrics=request.app.state.backends.metrics_provider,
85
+ source="rest",
86
+ reason=reason,
87
+ identity_hint=identity_hint,
88
+ )
89
+ raise HTTPException(
90
+ status_code=status.HTTP_401_UNAUTHORIZED,
91
+ detail="Missing or invalid authentication credentials",
92
+ )
93
+
94
+
95
+ async def get_current_key(
96
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)],
97
+ request: Request,
98
+ ) -> KeyInfo | None:
99
+ """Backwards-compat wrapper.
100
+
101
+ Thin shim over :func:`get_current_identity` that returns the validated
102
+ :class:`KeyInfo` when the caller authenticated via bearer token, and
103
+ ``None`` in dev mode. Returns None when the caller is an operator
104
+ (setup-token path) -- existing key-management routes do not need KeyInfo
105
+ in that case.
106
+
107
+ New code SHOULD depend on :func:`get_current_identity` directly.
108
+ """
109
+ from cassetta.protocols.identity import Identity
110
+
111
+ dev_mode: bool = request.app.state.dev_mode
112
+ if dev_mode:
113
+ request.state.identity = Identity(label="dev", extra={"dev": True})
114
+ return None
115
+
116
+ identity = await get_current_identity(credentials, request)
117
+ if identity.extra.get("operator"):
118
+ return None
119
+ return getattr(request.state, "key_info", None)
120
+
121
+
122
+ def require_setup_token(request: Request, x_setup_token: str | None = None) -> None:
123
+ """Deprecated: use :func:`get_current_identity` + policy.check instead.
124
+
125
+ Retained for backwards compatibility with callers that have not yet
126
+ migrated. Does NOT run in dev mode. New admin routes must NOT depend
127
+ on this -- they depend on :func:`get_current_identity` and call
128
+ :meth:`AccessPolicy.check`.
129
+ """
130
+ dev_mode: bool = request.app.state.dev_mode
131
+ if dev_mode:
132
+ return
133
+
134
+ setup_token: str = request.app.state.setup_token
135
+ token = x_setup_token or request.headers.get("X-Setup-Token")
136
+ if token != setup_token:
137
+ emit_auth_failure(
138
+ metrics=request.app.state.backends.metrics_provider,
139
+ source="rest",
140
+ reason="invalid_setup_token",
141
+ identity_hint=None,
142
+ )
143
+ raise HTTPException(
144
+ status_code=status.HTTP_403_FORBIDDEN,
145
+ detail="Invalid setup token",
146
+ )