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.
- cassetta/__init__.py +16 -0
- cassetta/app.py +392 -0
- cassetta/auth/__init__.py +52 -0
- cassetta/auth/dependencies.py +146 -0
- cassetta/auth/jwt_hot_reload.py +264 -0
- cassetta/auth/jwt_tokens.py +147 -0
- cassetta/auth/manifest_validation.py +74 -0
- cassetta/auth/models.py +28 -0
- cassetta/auth/observability.py +81 -0
- cassetta/backends/__init__.py +0 -0
- cassetta/backends/filesystem/__init__.py +0 -0
- cassetta/backends/filesystem/claim_storage.py +215 -0
- cassetta/backends/filesystem/keystore.py +157 -0
- cassetta/backends/filesystem/storage.py +399 -0
- cassetta/capabilities.py +76 -0
- cassetta/claims.py +73 -0
- cassetta/cli/__init__.py +32 -0
- cassetta/cli/capabilities.py +107 -0
- cassetta/cli/download.py +191 -0
- cassetta/cli/send.py +154 -0
- cassetta/cli/upload.py +125 -0
- cassetta/config.py +381 -0
- cassetta/defaults/__init__.py +0 -0
- cassetta/defaults/default_access.py +33 -0
- cassetta/defaults/default_alias.py +40 -0
- cassetta/defaults/default_identity.py +21 -0
- cassetta/defaults/default_limits.py +149 -0
- cassetta/defaults/default_metrics.py +40 -0
- cassetta/defaults/default_transport.py +37 -0
- cassetta/defaults/factory.py +74 -0
- cassetta/dependencies.py +42 -0
- cassetta/downloads.py +212 -0
- cassetta/envelopes.py +173 -0
- cassetta/gc.py +282 -0
- cassetta/limits.py +100 -0
- cassetta/mcp_auth.py +116 -0
- cassetta/mcp_server.py +1451 -0
- cassetta/middleware.py +99 -0
- cassetta/mime.py +43 -0
- cassetta/models.py +226 -0
- cassetta/path_validation.py +36 -0
- cassetta/protocols/__init__.py +0 -0
- cassetta/protocols/access.py +54 -0
- cassetta/protocols/alias.py +45 -0
- cassetta/protocols/claim_storage.py +76 -0
- cassetta/protocols/config.py +103 -0
- cassetta/protocols/identity.py +34 -0
- cassetta/protocols/keystore.py +41 -0
- cassetta/protocols/limits.py +119 -0
- cassetta/protocols/metrics.py +52 -0
- cassetta/protocols/reference_transport.py +47 -0
- cassetta/protocols/storage.py +75 -0
- cassetta/py.typed +0 -0
- cassetta/rate_limit/__init__.py +23 -0
- cassetta/rate_limit/limiter.py +161 -0
- cassetta/routes/__init__.py +0 -0
- cassetta/routes/agents.py +378 -0
- cassetta/routes/capabilities.py +42 -0
- cassetta/routes/download.py +432 -0
- cassetta/routes/files.py +650 -0
- cassetta/routes/inbox.py +577 -0
- cassetta/routes/keys.py +246 -0
- cassetta/routes/upload.py +428 -0
- cassetta/routes/uploads.py +107 -0
- cassetta/send_init.py +329 -0
- cassetta/streaming.py +68 -0
- cassetta/structured_log.py +290 -0
- cassetta-0.30.0.dist-info/METADATA +435 -0
- cassetta-0.30.0.dist-info/RECORD +72 -0
- cassetta-0.30.0.dist-info/WHEEL +4 -0
- cassetta-0.30.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|