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.
- zubbl_fastapi/__init__.py +37 -0
- zubbl_fastapi/endpoints.py +296 -0
- zubbl_fastapi/identity.py +61 -0
- zubbl_fastapi/integration.py +368 -0
- zubbl_fastapi/middleware.py +257 -0
- zubbl_fastapi/observation.py +41 -0
- zubbl_fastapi-0.1.0.dist-info/METADATA +90 -0
- zubbl_fastapi-0.1.0.dist-info/RECORD +10 -0
- zubbl_fastapi-0.1.0.dist-info/WHEEL +5 -0
- zubbl_fastapi-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
zubbl_fastapi
|