zubbl-fastapi 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.
@@ -0,0 +1,37 @@
1
+ from .integration import (
2
+ BlockResponseHandler,
3
+ DEFAULT_EXCLUDED_PATHS,
4
+ IdentityResolver,
5
+ MissingIdentityMode,
6
+ Zubbl,
7
+ ZubblFastAPIError,
8
+ )
9
+ from .endpoints import (
10
+ RouteCatalogue,
11
+ RouteCatalogueDiff,
12
+ RouteDescriptor,
13
+ )
14
+ from .identity import (
15
+ IdentityResolutionError,
16
+ identify_from_state,
17
+ )
18
+ from .middleware import ZubblMiddleware
19
+ from .observation import RuntimeResponseObservation
20
+
21
+ __all__ = [
22
+ "BlockResponseHandler",
23
+ "IdentityResolutionError",
24
+ "identify_from_state",
25
+ "DEFAULT_EXCLUDED_PATHS",
26
+ "IdentityResolver",
27
+ "MissingIdentityMode",
28
+ "RouteDescriptor",
29
+ "RouteCatalogueDiff",
30
+ "RouteCatalogue",
31
+ "RuntimeResponseObservation",
32
+ "Zubbl",
33
+ "ZubblFastAPIError",
34
+ "ZubblMiddleware",
35
+ ]
36
+
37
+ __version__ = "0.1.0"
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.routing import APIRoute
10
+
11
+
12
+ def _normalise_path(value: str) -> str:
13
+ cleaned = value.strip()
14
+
15
+ if not cleaned:
16
+ raise ValueError("endpoint path must be a non-empty string")
17
+
18
+ if not cleaned.startswith("/"):
19
+ cleaned = f"/{cleaned}"
20
+
21
+ if len(cleaned) > 1:
22
+ cleaned = cleaned.rstrip("/")
23
+
24
+ return cleaned or "/"
25
+
26
+
27
+ def _clean_optional_string(value: Any) -> str | None:
28
+ if not isinstance(value, str):
29
+ return None
30
+
31
+ cleaned = value.strip()
32
+ return cleaned or None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True, order=True)
36
+ class RouteDescriptor:
37
+ """
38
+ One stable application endpoint.
39
+
40
+ A FastAPI route supporting several methods becomes one descriptor per
41
+ method, giving every endpoint a deterministic path/method identity.
42
+ """
43
+
44
+ path: str
45
+ method: str
46
+ name: str
47
+ operation_id: str | None = None
48
+ tags: tuple[str, ...] = ()
49
+ include_in_schema: bool = True
50
+
51
+ def __post_init__(self) -> None:
52
+ path = _normalise_path(self.path)
53
+ method = self.method.strip().upper()
54
+ name = self.name.strip()
55
+
56
+ if not method:
57
+ raise ValueError("endpoint method must be a non-empty string")
58
+
59
+ if not name:
60
+ raise ValueError("endpoint name must be a non-empty string")
61
+
62
+ tags = tuple(
63
+ dict.fromkeys(
64
+ str(tag).strip()
65
+ for tag in self.tags
66
+ if str(tag).strip()
67
+ )
68
+ )
69
+
70
+ object.__setattr__(self, "path", path)
71
+ object.__setattr__(self, "method", method)
72
+ object.__setattr__(self, "name", name)
73
+ object.__setattr__(
74
+ self,
75
+ "operation_id",
76
+ _clean_optional_string(self.operation_id),
77
+ )
78
+ object.__setattr__(self, "tags", tags)
79
+
80
+ @property
81
+ def key(self) -> tuple[str, str]:
82
+ return self.path, self.method
83
+
84
+ @property
85
+ def identifier(self) -> str:
86
+ return f"{self.method} {self.path}"
87
+
88
+ def as_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "path": self.path,
91
+ "method": self.method,
92
+ "name": self.name,
93
+ "operation_id": self.operation_id,
94
+ "tags": list(self.tags),
95
+ "include_in_schema": self.include_in_schema,
96
+ }
97
+
98
+ @property
99
+ def fingerprint(self) -> str:
100
+ payload = json.dumps(
101
+ self.as_dict(),
102
+ sort_keys=True,
103
+ separators=(",", ":"),
104
+ ensure_ascii=False,
105
+ ).encode("utf-8")
106
+
107
+ return hashlib.sha256(payload).hexdigest()
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class RouteCatalogueDiff:
112
+ added: tuple[RouteDescriptor, ...]
113
+ removed: tuple[RouteDescriptor, ...]
114
+ changed: tuple[
115
+ tuple[RouteDescriptor, RouteDescriptor],
116
+ ...,
117
+ ]
118
+
119
+ @property
120
+ def has_changes(self) -> bool:
121
+ return bool(self.added or self.removed or self.changed)
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class RouteCatalogue:
126
+ routes: tuple[RouteDescriptor, ...]
127
+
128
+ def __post_init__(self) -> None:
129
+ ordered = tuple(
130
+ sorted(
131
+ self.routes,
132
+ key=lambda route: (
133
+ route.path,
134
+ route.method,
135
+ route.name,
136
+ ),
137
+ )
138
+ )
139
+
140
+ keys = [route.key for route in ordered]
141
+
142
+ if len(keys) != len(set(keys)):
143
+ raise ValueError(
144
+ "route catalogue contains duplicate path/method entries"
145
+ )
146
+
147
+ object.__setattr__(self, "routes", ordered)
148
+
149
+ @classmethod
150
+ def empty(cls) -> RouteCatalogue:
151
+ return cls(routes=())
152
+
153
+ @classmethod
154
+ def from_app(
155
+ cls,
156
+ app: FastAPI,
157
+ *,
158
+ include_hidden: bool = False,
159
+ ) -> RouteCatalogue:
160
+ descriptors: list[RouteDescriptor] = []
161
+
162
+ for route in app.routes:
163
+ if not isinstance(route, APIRoute):
164
+ continue
165
+
166
+ if not include_hidden and not route.include_in_schema:
167
+ continue
168
+
169
+ methods = sorted(route.methods or ())
170
+
171
+ for method in methods:
172
+ if method in {"HEAD", "OPTIONS"}:
173
+ continue
174
+
175
+ operation_id = (
176
+ route.operation_id
177
+ or getattr(route, "unique_id", None)
178
+ )
179
+
180
+ descriptors.append(
181
+ RouteDescriptor(
182
+ path=route.path,
183
+ method=method,
184
+ name=route.name,
185
+ operation_id=operation_id,
186
+ tags=tuple(route.tags or ()),
187
+ include_in_schema=route.include_in_schema,
188
+ )
189
+ )
190
+
191
+ return cls(routes=tuple(descriptors))
192
+
193
+ def __len__(self) -> int:
194
+ return len(self.routes)
195
+
196
+ def __iter__(self):
197
+ return iter(self.routes)
198
+
199
+ def lookup(
200
+ self,
201
+ path: str,
202
+ method: str,
203
+ ) -> RouteDescriptor | None:
204
+ key = (
205
+ _normalise_path(path),
206
+ method.strip().upper(),
207
+ )
208
+
209
+ for route in self.routes:
210
+ if route.key == key:
211
+ return route
212
+
213
+ return None
214
+
215
+ def lookup_by_name(
216
+ self,
217
+ name: str,
218
+ ) -> tuple[RouteDescriptor, ...]:
219
+ cleaned = name.strip()
220
+
221
+ return tuple(
222
+ route
223
+ for route in self.routes
224
+ if route.name == cleaned
225
+ )
226
+
227
+ def lookup_by_operation_id(
228
+ self,
229
+ operation_id: str,
230
+ ) -> tuple[RouteDescriptor, ...]:
231
+ cleaned = operation_id.strip()
232
+
233
+ return tuple(
234
+ route
235
+ for route in self.routes
236
+ if route.operation_id == cleaned
237
+ )
238
+
239
+ def as_list(self) -> list[dict[str, Any]]:
240
+ return [route.as_dict() for route in self.routes]
241
+
242
+ @property
243
+ def fingerprint(self) -> str:
244
+ payload = json.dumps(
245
+ self.as_list(),
246
+ sort_keys=True,
247
+ separators=(",", ":"),
248
+ ensure_ascii=False,
249
+ ).encode("utf-8")
250
+
251
+ return hashlib.sha256(payload).hexdigest()
252
+
253
+ def diff(
254
+ self,
255
+ previous: RouteCatalogue,
256
+ ) -> RouteCatalogueDiff:
257
+ current_by_key = {
258
+ route.key: route
259
+ for route in self.routes
260
+ }
261
+ previous_by_key = {
262
+ route.key: route
263
+ for route in previous.routes
264
+ }
265
+
266
+ added = tuple(
267
+ current_by_key[key]
268
+ for key in sorted(
269
+ current_by_key.keys() - previous_by_key.keys()
270
+ )
271
+ )
272
+ removed = tuple(
273
+ previous_by_key[key]
274
+ for key in sorted(
275
+ previous_by_key.keys() - current_by_key.keys()
276
+ )
277
+ )
278
+
279
+ changed: list[
280
+ tuple[RouteDescriptor, RouteDescriptor]
281
+ ] = []
282
+
283
+ for key in sorted(
284
+ current_by_key.keys() & previous_by_key.keys()
285
+ ):
286
+ old = previous_by_key[key]
287
+ new = current_by_key[key]
288
+
289
+ if old != new:
290
+ changed.append((old, new))
291
+
292
+ return RouteCatalogueDiff(
293
+ added=added,
294
+ removed=removed,
295
+ changed=tuple(changed),
296
+ )
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from starlette.requests import Request
7
+
8
+
9
+ class IdentityResolutionError(ValueError):
10
+ """Raised when an identity helper is configured incorrectly."""
11
+
12
+
13
+ def _resolve_attribute_path(
14
+ value: Any,
15
+ attribute_path: str,
16
+ ) -> Any:
17
+ current = value
18
+
19
+ for segment in attribute_path.split("."):
20
+ cleaned = segment.strip()
21
+
22
+ if not cleaned:
23
+ raise IdentityResolutionError(
24
+ "attribute_path contains an empty segment"
25
+ )
26
+
27
+ if current is None:
28
+ return None
29
+
30
+ if isinstance(current, dict):
31
+ current = current.get(cleaned)
32
+ else:
33
+ current = getattr(current, cleaned, None)
34
+
35
+ return current
36
+
37
+
38
+ def identify_from_state(
39
+ attribute_path: str,
40
+ ) -> Callable[[Request], Any]:
41
+ """
42
+ Build an identity resolver from request.state.
43
+
44
+ Examples:
45
+ identify_from_state("user.id")
46
+ identify_from_state("external_user_id")
47
+ """
48
+ if not isinstance(attribute_path, str) or not attribute_path.strip():
49
+ raise IdentityResolutionError(
50
+ "attribute_path must be a non-empty string"
51
+ )
52
+
53
+ cleaned_path = attribute_path.strip()
54
+
55
+ def resolver(request: Request) -> Any:
56
+ return _resolve_attribute_path(
57
+ request.state,
58
+ cleaned_path,
59
+ )
60
+
61
+ return resolver
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from contextlib import asynccontextmanager
6
+ from collections.abc import Awaitable, Callable, Iterable
7
+ from typing import AsyncIterator, Literal, TypeAlias
8
+
9
+ from fastapi import FastAPI
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+ from starlette.routing import Match
13
+ from starlette.types import Scope
14
+
15
+ from zubbl_runtime import RuntimeClient, route_matches
16
+
17
+ from .endpoints import RouteCatalogue, RouteCatalogueDiff
18
+
19
+
20
+ IdentityValue: TypeAlias = str | int | None
21
+ IdentityResult: TypeAlias = IdentityValue | Awaitable[IdentityValue]
22
+ IdentityResolver: TypeAlias = Callable[[Request], IdentityResult]
23
+ BlockResponseResult: TypeAlias = Response | Awaitable[Response]
24
+ BlockResponseHandler: TypeAlias = Callable[
25
+ [Request, object],
26
+ BlockResponseResult,
27
+ ]
28
+ MissingIdentityMode: TypeAlias = Literal["skip", "block"]
29
+
30
+
31
+ DEFAULT_EXCLUDED_PATHS = frozenset(
32
+ {
33
+ "/health",
34
+ "/healthz",
35
+ "/ready",
36
+ "/readiness",
37
+ "/metrics",
38
+ "/docs",
39
+ "/redoc",
40
+ "/openapi.json",
41
+ }
42
+ )
43
+
44
+
45
+ class ZubblFastAPIError(RuntimeError):
46
+ """Raised when the FastAPI integration is misconfigured."""
47
+
48
+
49
+ class Zubbl:
50
+ """
51
+ FastAPI integration for Zubbl Runtime Guard.
52
+
53
+ The adapter contains framework glue only. Policy retrieval, caching,
54
+ resilience and enforcement remain inside zubbl-runtime.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: str,
60
+ *,
61
+ identify: IdentityResolver,
62
+ base_url: str = "https://api.zubbl.com",
63
+ timeout_seconds: float = 8.0,
64
+ excluded_paths: Iterable[str] = DEFAULT_EXCLUDED_PATHS,
65
+ missing_identity: MissingIdentityMode = "skip",
66
+ trust_proxy_headers: bool = False,
67
+ on_block: BlockResponseHandler | None = None,
68
+ client: RuntimeClient | None = None,
69
+ ) -> None:
70
+ if not callable(identify):
71
+ raise TypeError("identify must be callable")
72
+
73
+ if missing_identity not in {"skip", "block"}:
74
+ raise ValueError(
75
+ "missing_identity must be 'skip' or 'block'"
76
+ )
77
+
78
+ self._identify = identify
79
+ self._missing_identity = missing_identity
80
+ self._trust_proxy_headers = trust_proxy_headers
81
+ self._on_block = on_block
82
+
83
+ self._excluded_paths = frozenset(
84
+ self._normalise_excluded_path(path)
85
+ for path in excluded_paths
86
+ )
87
+
88
+ self._client = client or RuntimeClient(
89
+ api_key,
90
+ base_url=base_url,
91
+ timeout_seconds=timeout_seconds,
92
+ )
93
+
94
+ self._initialise_lock = asyncio.Lock()
95
+ self._attached_app: FastAPI | None = None
96
+ self._endpoint_catalogue = RouteCatalogue.empty()
97
+ self._last_endpoint_sync = None
98
+ self._endpoint_sync_error: str | None = None
99
+
100
+ @staticmethod
101
+ def _normalise_excluded_path(path: str) -> str:
102
+ if not isinstance(path, str) or not path.strip():
103
+ raise ValueError(
104
+ "excluded paths must be non-empty strings"
105
+ )
106
+
107
+ cleaned = path.strip()
108
+
109
+ if not cleaned.startswith("/"):
110
+ cleaned = f"/{cleaned}"
111
+
112
+ if len(cleaned) > 1:
113
+ cleaned = cleaned.rstrip("/")
114
+
115
+ return cleaned
116
+
117
+ @property
118
+ def client(self) -> RuntimeClient:
119
+ return self._client
120
+
121
+ @property
122
+ def endpoint_catalogue(self) -> RouteCatalogue:
123
+ return self._endpoint_catalogue
124
+
125
+ @property
126
+ def last_endpoint_sync(self):
127
+ return self._last_endpoint_sync
128
+
129
+ @property
130
+ def endpoint_sync_error(self) -> str | None:
131
+ return self._endpoint_sync_error
132
+
133
+ def refresh_endpoint_catalogue(
134
+ self,
135
+ ) -> RouteCatalogueDiff:
136
+ if self._attached_app is None:
137
+ raise ZubblFastAPIError(
138
+ "Zubbl must be attached before discovering endpoints"
139
+ )
140
+
141
+ previous = self._endpoint_catalogue
142
+ current = RouteCatalogue.from_app(
143
+ self._attached_app,
144
+ )
145
+
146
+ self._endpoint_catalogue = current
147
+ return current.diff(previous)
148
+
149
+ @property
150
+ def missing_identity(self) -> MissingIdentityMode:
151
+ return self._missing_identity
152
+
153
+ @property
154
+ def trust_proxy_headers(self) -> bool:
155
+ return self._trust_proxy_headers
156
+
157
+ async def build_block_response(
158
+ self,
159
+ request: Request,
160
+ result,
161
+ ) -> Response | None:
162
+ if self._on_block is None:
163
+ return None
164
+
165
+ response = self._on_block(request, result)
166
+
167
+ if inspect.isawaitable(response):
168
+ response = await response
169
+
170
+ if not isinstance(response, Response):
171
+ raise ZubblFastAPIError(
172
+ "on_block must return a Starlette Response"
173
+ )
174
+
175
+ return response
176
+
177
+ def is_excluded(self, path: str) -> bool:
178
+ cleaned = path if path == "/" else path.rstrip("/")
179
+
180
+ return any(
181
+ route_matches(pattern, cleaned)
182
+ for pattern in self._excluded_paths
183
+ )
184
+
185
+ def resolve_route_template(
186
+ self,
187
+ scope: Scope,
188
+ ) -> str:
189
+ """
190
+ Resolve the stable FastAPI/Starlette route template for a request.
191
+
192
+ Falls back to the concrete request path when no route matches.
193
+ """
194
+ concrete_path = str(scope.get("path") or "/")
195
+
196
+ if self._attached_app is None:
197
+ return concrete_path
198
+
199
+ for route in self._attached_app.router.routes:
200
+ match, child_scope = route.matches(scope)
201
+
202
+ if match is not Match.FULL:
203
+ continue
204
+
205
+ matched_route = child_scope.get("route", route)
206
+ template = getattr(matched_route, "path", None)
207
+
208
+ if isinstance(template, str) and template.strip():
209
+ return template
210
+
211
+ route_path = getattr(route, "path", None)
212
+
213
+ if isinstance(route_path, str) and route_path.strip():
214
+ return route_path
215
+
216
+ return concrete_path
217
+
218
+ async def resolve_identity(
219
+ self,
220
+ request: Request,
221
+ ) -> str | None:
222
+ value = self._identify(request)
223
+
224
+ if inspect.isawaitable(value):
225
+ value = await value
226
+
227
+ if value is None:
228
+ return None
229
+
230
+ cleaned = str(value).strip()
231
+ return cleaned or None
232
+
233
+ async def ensure_initialised(self) -> RuntimeClient:
234
+ if self._client.initialized:
235
+ return self._client
236
+
237
+ if self._client.closed:
238
+ raise ZubblFastAPIError(
239
+ "The Zubbl Runtime Client has already been closed"
240
+ )
241
+
242
+ async with self._initialise_lock:
243
+ if not self._client.initialized:
244
+ await self._client.init()
245
+
246
+ return self._client
247
+
248
+ async def startup(self) -> None:
249
+ if self._attached_app is not None:
250
+ self.refresh_endpoint_catalogue()
251
+
252
+ client = await self.ensure_initialised()
253
+
254
+ if (
255
+ client.config.endpoint_discovery
256
+ and len(self._endpoint_catalogue) > 0
257
+ ):
258
+ try:
259
+ self._last_endpoint_sync = (
260
+ await client.sync_endpoint_catalogue(
261
+ fingerprint=(
262
+ self._endpoint_catalogue.fingerprint
263
+ ),
264
+ routes=(
265
+ self._endpoint_catalogue.as_list()
266
+ ),
267
+ )
268
+ )
269
+ self._endpoint_sync_error = None
270
+ except Exception as exc:
271
+ # Endpoint inventory must never prevent the host application
272
+ # from starting. Retain the error for diagnostics.
273
+ self._endpoint_sync_error = (
274
+ f"{type(exc).__name__}: {exc}"
275
+ )
276
+
277
+ async def shutdown(self) -> None:
278
+ await self._client.close()
279
+
280
+ @asynccontextmanager
281
+ async def lifecycle(self) -> AsyncIterator[Zubbl]:
282
+ """
283
+ Explicit lifecycle context for applications that already compose
284
+ multiple lifespan-managed services.
285
+
286
+ Example:
287
+
288
+ @asynccontextmanager
289
+ async def lifespan(app):
290
+ async with zubbl.lifecycle():
291
+ yield
292
+ """
293
+ await self.startup()
294
+
295
+ try:
296
+ yield self
297
+ finally:
298
+ await self.shutdown()
299
+
300
+ def lifespan(self):
301
+ """
302
+ Return a FastAPI-compatible lifespan callable.
303
+
304
+ Example:
305
+
306
+ zubbl = Zubbl(...)
307
+ app = FastAPI(lifespan=zubbl.lifespan())
308
+ """
309
+
310
+ @asynccontextmanager
311
+ async def zubbl_lifespan(
312
+ app: FastAPI,
313
+ ) -> AsyncIterator[None]:
314
+ async with self.lifecycle():
315
+ yield
316
+
317
+ return zubbl_lifespan
318
+
319
+ def attach(
320
+ self,
321
+ app: FastAPI,
322
+ *,
323
+ manage_lifecycle: bool = True,
324
+ ) -> Zubbl:
325
+ if self._attached_app is not None:
326
+ if self._attached_app is app:
327
+ return self
328
+
329
+ raise ZubblFastAPIError(
330
+ "This Zubbl instance is already attached to another app"
331
+ )
332
+
333
+ # Imported lazily to avoid an integration/middleware import cycle.
334
+ from .middleware import ZubblMiddleware
335
+
336
+ app.add_middleware(
337
+ ZubblMiddleware,
338
+ integration=self,
339
+ )
340
+
341
+ # FastAPI/Starlette inserts newly added middleware at the outermost
342
+ # position. Runtime Guard identity resolution commonly depends on
343
+ # authentication middleware having already populated request.state.
344
+ #
345
+ # Move Zubbl to the innermost user-middleware position so existing
346
+ # authentication/session middleware runs first, while Zubbl still
347
+ # evaluates policy before the route handler executes.
348
+ zubbl_middleware = app.user_middleware.pop(0)
349
+ app.user_middleware.append(zubbl_middleware)
350
+
351
+ if manage_lifecycle:
352
+ app.add_event_handler(
353
+ "startup",
354
+ self.startup,
355
+ )
356
+ app.add_event_handler(
357
+ "shutdown",
358
+ self.shutdown,
359
+ )
360
+
361
+ app.state.zubbl = self
362
+ self._attached_app = app
363
+
364
+ # Capture routes already registered before attachment. Startup refresh
365
+ # captures routes registered afterwards.
366
+ self.refresh_endpoint_catalogue()
367
+
368
+ return self
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from starlette.requests import Request
6
+ from starlette.responses import JSONResponse
7
+ from starlette.types import (
8
+ ASGIApp,
9
+ Message,
10
+ Receive,
11
+ Scope,
12
+ Send,
13
+ )
14
+
15
+ from zubbl_runtime import RuntimeRequestContext
16
+
17
+ from .integration import Zubbl
18
+ from .observation import RuntimeResponseObservation
19
+
20
+
21
+ def _truthy_header(value: str | None) -> bool:
22
+ return str(value or "").strip().lower() in {
23
+ "1",
24
+ "true",
25
+ "yes",
26
+ "on",
27
+ }
28
+
29
+
30
+ def _trace_id(request: Request) -> str | None:
31
+ return (
32
+ request.headers.get("X-Zubbl-Trace-ID")
33
+ or request.headers.get("X-Trace-ID")
34
+ or request.headers.get("traceparent")
35
+ )
36
+
37
+
38
+ class ZubblMiddleware:
39
+ """
40
+ Pure ASGI middleware for Zubbl Runtime Guard.
41
+
42
+ It does not consume the request body and does not use BaseHTTPMiddleware.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ app: ASGIApp,
48
+ *,
49
+ integration: Zubbl,
50
+ ) -> None:
51
+ self._app = app
52
+ self._integration = integration
53
+
54
+ async def __call__(
55
+ self,
56
+ scope: Scope,
57
+ receive: Receive,
58
+ send: Send,
59
+ ) -> None:
60
+ if scope["type"] != "http":
61
+ await self._app(scope, receive, send)
62
+ return
63
+
64
+ path = str(scope.get("path") or "/")
65
+
66
+ if self._integration.is_excluded(path):
67
+ await self._app(scope, receive, send)
68
+ return
69
+
70
+ request = Request(scope, receive=receive)
71
+ external_user_id = (
72
+ await self._integration.resolve_identity(request)
73
+ )
74
+
75
+ if external_user_id is None:
76
+ if self._integration.missing_identity == "block":
77
+ response = JSONResponse(
78
+ status_code=401,
79
+ content={
80
+ "detail": (
81
+ "Zubbl could not resolve an application "
82
+ "identity for this request"
83
+ ),
84
+ "action": "identity_required",
85
+ },
86
+ headers={
87
+ "X-Zubbl-Action": "identity_required",
88
+ },
89
+ )
90
+ await response(scope, receive, send)
91
+ return
92
+
93
+ scope.setdefault("state", {})
94
+ scope["state"]["zubbl"] = None
95
+ scope["state"]["zubbl_skipped_reason"] = (
96
+ "missing_identity"
97
+ )
98
+
99
+ await self._app(scope, receive, send)
100
+ return
101
+
102
+ client = await self._integration.ensure_initialised()
103
+ route_template = (
104
+ self._integration.resolve_route_template(scope)
105
+ )
106
+
107
+ context = self._build_context(
108
+ request,
109
+ external_user_id=external_user_id,
110
+ route_template=route_template,
111
+ )
112
+ result = await client.evaluate(context)
113
+
114
+ state = scope.setdefault("state", {})
115
+ state["zubbl"] = result
116
+ state["zubbl_context"] = context
117
+
118
+ if result.should_block:
119
+ started_at = time.perf_counter()
120
+
121
+ response = (
122
+ await self._integration.build_block_response(
123
+ request,
124
+ result,
125
+ )
126
+ )
127
+
128
+ if response is None:
129
+ response = JSONResponse(
130
+ status_code=result.status_code,
131
+ content=dict(result.body),
132
+ headers=dict(result.headers),
133
+ )
134
+
135
+ await response(scope, receive, send)
136
+
137
+ state["zubbl_observation"] = RuntimeResponseObservation(
138
+ status_code=result.status_code,
139
+ latency_ms=max(
140
+ 0.0,
141
+ (time.perf_counter() - started_at) * 1000,
142
+ ),
143
+ completed=True,
144
+ route_template=route_template,
145
+ request_id=context.request_id,
146
+ trace_id=context.trace_id,
147
+ )
148
+ return
149
+
150
+ await self._call_and_observe(
151
+ scope=scope,
152
+ receive=receive,
153
+ send=send,
154
+ context=context,
155
+ route_template=route_template,
156
+ )
157
+
158
+ async def _call_and_observe(
159
+ self,
160
+ *,
161
+ scope: Scope,
162
+ receive: Receive,
163
+ send: Send,
164
+ context: RuntimeRequestContext,
165
+ route_template: str,
166
+ ) -> None:
167
+ started_at = time.perf_counter()
168
+ status_code = 500
169
+ completed = False
170
+ exception_type: str | None = None
171
+
172
+ async def observed_send(message: Message) -> None:
173
+ nonlocal status_code, completed
174
+
175
+ if message["type"] == "http.response.start":
176
+ status_code = int(message["status"])
177
+
178
+ if (
179
+ message["type"] == "http.response.body"
180
+ and not message.get("more_body", False)
181
+ ):
182
+ completed = True
183
+
184
+ await send(message)
185
+
186
+ try:
187
+ await self._app(
188
+ scope,
189
+ receive,
190
+ observed_send,
191
+ )
192
+ except Exception as exc:
193
+ exception_type = type(exc).__name__
194
+ raise
195
+ finally:
196
+ latency_ms = max(
197
+ 0.0,
198
+ (time.perf_counter() - started_at) * 1000,
199
+ )
200
+
201
+ scope.setdefault("state", {})[
202
+ "zubbl_observation"
203
+ ] = RuntimeResponseObservation(
204
+ status_code=status_code,
205
+ latency_ms=latency_ms,
206
+ completed=completed,
207
+ route_template=route_template,
208
+ request_id=context.request_id,
209
+ trace_id=context.trace_id,
210
+ exception_type=exception_type,
211
+ )
212
+
213
+ def _build_context(
214
+ self,
215
+ request: Request,
216
+ *,
217
+ external_user_id: str,
218
+ route_template: str,
219
+ ) -> RuntimeRequestContext:
220
+ country = "XX"
221
+ asn: str | None = None
222
+ vpn = False
223
+ hosting = False
224
+
225
+ if self._integration.trust_proxy_headers:
226
+ country = (
227
+ request.headers.get("X-Zubbl-Geo-Country")
228
+ or request.headers.get("CF-IPCountry")
229
+ or "XX"
230
+ )
231
+ asn = request.headers.get("X-Zubbl-ASN")
232
+ vpn = _truthy_header(
233
+ request.headers.get(
234
+ "X-Zubbl-Privacy-VPN"
235
+ )
236
+ )
237
+ hosting = _truthy_header(
238
+ request.headers.get(
239
+ "X-Zubbl-Privacy-Hosting"
240
+ )
241
+ )
242
+
243
+ return RuntimeRequestContext(
244
+ external_user_id=external_user_id,
245
+ method=request.method,
246
+ path=request.url.path,
247
+ route_template=route_template,
248
+ request_id=(
249
+ request.headers.get("X-Request-ID")
250
+ or request.headers.get("X-Zubbl-Request-ID")
251
+ ),
252
+ trace_id=_trace_id(request),
253
+ country=country,
254
+ asn=asn,
255
+ vpn=vpn,
256
+ hosting=hosting,
257
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RuntimeResponseObservation:
8
+ """
9
+ Completed framework response facts.
10
+
11
+ This is deliberately observation only. Telemetry transport is introduced
12
+ later in Sprint 21.8.
13
+ """
14
+
15
+ status_code: int
16
+ latency_ms: float
17
+ completed: bool
18
+
19
+ route_template: str
20
+ request_id: str | None = None
21
+ trace_id: str | None = None
22
+ exception_type: str | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ if not 100 <= self.status_code <= 599:
26
+ raise ValueError(
27
+ "status_code must be a valid HTTP status"
28
+ )
29
+
30
+ if self.latency_ms < 0:
31
+ raise ValueError(
32
+ "latency_ms must not be negative"
33
+ )
34
+
35
+ if (
36
+ not isinstance(self.route_template, str)
37
+ or not self.route_template.strip()
38
+ ):
39
+ raise ValueError(
40
+ "route_template must be a non-empty string"
41
+ )
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: zubbl-fastapi
3
+ Version: 0.1.0
4
+ Summary: Official FastAPI integration for the Zubbl Runtime Intelligence Platform
5
+ Author-email: Zubbl <support@zubbl.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://zubbl.com
8
+ Project-URL: Documentation, https://zubbl.com
9
+ Keywords: fastapi,security,runtime,application-security,observability,telemetry,behaviour,api-security,zubbl
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: fastapi>=0.110.0
24
+ Requires-Dist: zubbl-runtime>=0.1.0
25
+ Provides-Extra: test
26
+ Requires-Dist: httpx>=0.27.0; extra == "test"
27
+ Requires-Dist: pytest>=8.0.0; extra == "test"
28
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
29
+
30
+ # Zubbl FastAPI
31
+
32
+ The official FastAPI integration for the Zubbl Runtime Intelligence Platform.
33
+
34
+ Zubbl provides real-time runtime visibility, behavioural security and adaptive protection from inside your application.
35
+
36
+ ## Features
37
+
38
+ - 5 minute integration
39
+ - Automatic endpoint discovery
40
+ - Runtime request telemetry
41
+ - Behavioural security signals
42
+ - Runtime protection policies
43
+ - Live Runtime Viewer integration
44
+ - Runtime Guard heartbeat
45
+ - Zero application code changes beyond initial setup
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.10+
50
+ - FastAPI 0.110+
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ pip install zubbl-fastapi
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from fastapi import FastAPI
62
+ from zubbl_fastapi import Zubbl
63
+
64
+ app = FastAPI()
65
+
66
+ zubbl = Zubbl("YOUR_API_KEY")
67
+
68
+ zubbl.attach(
69
+ app,
70
+ identify=lambda request: request.state.user.id
71
+ )
72
+ ```
73
+
74
+ Start your application.
75
+
76
+ Your application will automatically appear inside the Zubbl Runtime Viewer.
77
+
78
+ ## Documentation
79
+
80
+ https://zubbl.com
81
+
82
+ ## Runtime Intelligence
83
+
84
+ Unlike traditional security tooling, Zubbl observes what happens after authentication.
85
+
86
+ It continuously builds runtime evidence, behavioural intelligence and protection decisions directly from application behaviour.
87
+
88
+ ---
89
+
90
+ Built by Zubbl.
@@ -0,0 +1,10 @@
1
+ zubbl_fastapi/__init__.py,sha256=uVwdZuPC_itJ57BoNL2azLYansgrP2GmDj_0mBlI00U,799
2
+ zubbl_fastapi/endpoints.py,sha256=1kor5u1ttpNxnt2CTAobfE0l5W5sjdbh6ZMuWrZOyNE,7457
3
+ zubbl_fastapi/identity.py,sha256=Y-zwI_fKZbxUEWOjT4La8SNHFl09Ch4vMEQZBg1NN9E,1438
4
+ zubbl_fastapi/integration.py,sha256=nKL9dyMxWQOq6nng47WTyx0rn4l_o4LuQ3EpWDAx8AM,10496
5
+ zubbl_fastapi/middleware.py,sha256=RCPxSAtR3rW1Ram7y12shUJD_5ldKVFSpr6YBTZjMRU,7185
6
+ zubbl_fastapi/observation.py,sha256=TIGckGhMi0nR9SBXf-yboumjobJ07ZNgYSldmhwif5Y,1038
7
+ zubbl_fastapi-0.1.0.dist-info/METADATA,sha256=K_2gEAIn6L-Isee-cwSIS-E33KjNv8CpGv-qio-8f2A,2380
8
+ zubbl_fastapi-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ zubbl_fastapi-0.1.0.dist-info/top_level.txt,sha256=QX_nDBBOpnHp__qunQYruBnNCvVwvlBHECuyb6XIs-s,14
10
+ zubbl_fastapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ zubbl_fastapi