veridian-sdk 0.1.0__tar.gz

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,9 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ .atl/
5
+ .claude/worktrees/
6
+ *.db
7
+ *.sqlite3
8
+ .pytest_cache/
9
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Veridian authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: veridian-sdk
3
+ Version: 0.1.0
4
+ Summary: ASGI middleware that reports access events to Veridian and blocks sessions, IPs and users on its blocklist
5
+ Project-URL: Homepage, https://github.com/Juanalv1/veridian
6
+ Project-URL: Repository, https://github.com/Juanalv1/veridian
7
+ Project-URL: Issues, https://github.com/Juanalv1/veridian/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: httpx
12
+ Requires-Dist: starlette
13
+ Description-Content-Type: text/markdown
14
+
15
+ # veridian-sdk
16
+
17
+ ASGI middleware for [Veridian](https://github.com/Juanalv1/veridian). It reports each request of your app to the Veridian engine and answers `403 {"error": "blocked"}` to any session, IP or user that is on the engine's blocklist.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install veridian-sdk
23
+ ```
24
+
25
+ Requires Python 3.12 or newer.
26
+
27
+ ## Usage
28
+
29
+ Works with any ASGI app (FastAPI, Starlette). Wrap the app and tell the middleware who the user and the session are:
30
+
31
+ ```python
32
+ import os
33
+
34
+ from fastapi import FastAPI
35
+ from veridian_sdk import VeridianMiddleware
36
+
37
+ app = FastAPI()
38
+
39
+ app = VeridianMiddleware(
40
+ app,
41
+ token=os.environ["VERIDIAN_TOKEN"],
42
+ base_url=os.environ["VERIDIAN_URL"],
43
+ user_id=lambda request: request.state.user_id if hasattr(request.state, "user_id") else None,
44
+ session_id=lambda request: request.cookies.get("sid"),
45
+ trusted_proxies=0,
46
+ )
47
+ ```
48
+
49
+ - `user_id` and `session_id` are callables that take a Starlette `Request` and return a string or `None`. They run before the app (to enforce the blocklist) and again after it (the app may have just logged the user in). Requests with no user are not reported.
50
+ - Pass a hash of the session cookie as `session_id`, never the cookie itself.
51
+ - `trusted_proxies` is the number of your own proxies in front of the app. With `0` the connection IP is used and `X-Forwarded-For` is ignored, because any client can write it.
52
+ - `exclude_paths` (default `("/static",)`) lists path prefixes that pass straight through with no event and no blocklist check.
53
+
54
+ ## What is sent
55
+
56
+ One event per request, sent in the background in batches: `id`, `ts`, `user_id`, `session_id`, `ip`, `user_agent` (first 200 characters), `device_id`, `method`, `route` (the route template, for example `/items/{item_id}`), `status` and `bytes`.
57
+
58
+ The middleware also sets its own device cookie (`vrd_did`) in the user's browser.
59
+
60
+ ## What is never sent
61
+
62
+ Request and response bodies, and passwords, are never read into events.
63
+
64
+ ## Fail-open
65
+
66
+ If the engine is slow or down, your app is not delayed or broken: the middleware keeps using the last blocklist it knew.
67
+
68
+ ## License
69
+
70
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ # veridian-sdk
2
+
3
+ ASGI middleware for [Veridian](https://github.com/Juanalv1/veridian). It reports each request of your app to the Veridian engine and answers `403 {"error": "blocked"}` to any session, IP or user that is on the engine's blocklist.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install veridian-sdk
9
+ ```
10
+
11
+ Requires Python 3.12 or newer.
12
+
13
+ ## Usage
14
+
15
+ Works with any ASGI app (FastAPI, Starlette). Wrap the app and tell the middleware who the user and the session are:
16
+
17
+ ```python
18
+ import os
19
+
20
+ from fastapi import FastAPI
21
+ from veridian_sdk import VeridianMiddleware
22
+
23
+ app = FastAPI()
24
+
25
+ app = VeridianMiddleware(
26
+ app,
27
+ token=os.environ["VERIDIAN_TOKEN"],
28
+ base_url=os.environ["VERIDIAN_URL"],
29
+ user_id=lambda request: request.state.user_id if hasattr(request.state, "user_id") else None,
30
+ session_id=lambda request: request.cookies.get("sid"),
31
+ trusted_proxies=0,
32
+ )
33
+ ```
34
+
35
+ - `user_id` and `session_id` are callables that take a Starlette `Request` and return a string or `None`. They run before the app (to enforce the blocklist) and again after it (the app may have just logged the user in). Requests with no user are not reported.
36
+ - Pass a hash of the session cookie as `session_id`, never the cookie itself.
37
+ - `trusted_proxies` is the number of your own proxies in front of the app. With `0` the connection IP is used and `X-Forwarded-For` is ignored, because any client can write it.
38
+ - `exclude_paths` (default `("/static",)`) lists path prefixes that pass straight through with no event and no blocklist check.
39
+
40
+ ## What is sent
41
+
42
+ One event per request, sent in the background in batches: `id`, `ts`, `user_id`, `session_id`, `ip`, `user_agent` (first 200 characters), `device_id`, `method`, `route` (the route template, for example `/items/{item_id}`), `status` and `bytes`.
43
+
44
+ The middleware also sets its own device cookie (`vrd_did`) in the user's browser.
45
+
46
+ ## What is never sent
47
+
48
+ Request and response bodies, and passwords, are never read into events.
49
+
50
+ ## Fail-open
51
+
52
+ If the engine is slow or down, your app is not delayed or broken: the middleware keeps using the last blocklist it knew.
53
+
54
+ ## License
55
+
56
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "veridian-sdk"
7
+ version = "0.1.0"
8
+ description = "ASGI middleware that reports access events to Veridian and blocks sessions, IPs and users on its blocklist"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = [
14
+ "httpx",
15
+ "starlette",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/Juanalv1/veridian"
20
+ Repository = "https://github.com/Juanalv1/veridian"
21
+ Issues = "https://github.com/Juanalv1/veridian/issues"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/veridian_sdk"]
@@ -0,0 +1,3 @@
1
+ from veridian_sdk.middleware import VeridianMiddleware, resolve_client_ip
2
+
3
+ __all__ = ["VeridianMiddleware", "resolve_client_ip"]
@@ -0,0 +1,332 @@
1
+ """Veridian ASGI middleware.
2
+
3
+ Reports one event per request to the engine (in the background, batched) and
4
+ refuses requests whose session, IP or user is on the engine's blocklist
5
+ (each refusal is reported as a 403 event too). It is fail-open: the engine being slow or down never delays or breaks the app.
6
+ Request and response bodies are never read into events.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import uuid
12
+ from collections import deque
13
+ from datetime import datetime, timezone
14
+ from http.cookies import SimpleCookie
15
+
16
+ import httpx
17
+ from starlette.requests import Request
18
+ from starlette.responses import JSONResponse
19
+ from starlette.routing import Match
20
+
21
+ log = logging.getLogger("veridian")
22
+
23
+ DEVICE_COOKIE = "vrd_did"
24
+ DEVICE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
25
+ MAX_BATCH = 200
26
+ DEFAULT_EXCLUDE_PATHS = ("/static",)
27
+
28
+
29
+ def resolve_client_ip(scope: dict, trusted_proxies: int = 0) -> str:
30
+ """Client IP that the client cannot choose.
31
+
32
+ With 0 trusted proxies the socket peer is used and X-Forwarded-For is
33
+ ignored (anyone can write it). With N, the Nth value from the right is
34
+ used: the one appended by our outermost proxy. Fewer values than N falls
35
+ back to the first value.
36
+ """
37
+ client = scope.get("client")
38
+ peer = client[0] if client else ""
39
+ if trusted_proxies <= 0:
40
+ return peer
41
+ raw = [v for k, v in scope.get("headers", []) if k == b"x-forwarded-for"]
42
+ values = [p.strip() for h in raw for p in h.decode("latin-1").split(",") if p.strip()]
43
+ if not values:
44
+ return peer
45
+ if len(values) < trusted_proxies:
46
+ return values[0]
47
+ return values[-trusted_proxies]
48
+
49
+
50
+ def _cookie(scope: dict, name: str) -> str | None:
51
+ for k, v in scope.get("headers", []):
52
+ if k == b"cookie":
53
+ jar = SimpleCookie()
54
+ try:
55
+ jar.load(v.decode("latin-1"))
56
+ except Exception:
57
+ return None
58
+ if name in jar:
59
+ return jar[name].value
60
+ return None
61
+
62
+
63
+ def _header(scope: dict, name: bytes) -> str | None:
64
+ for k, v in scope.get("headers", []):
65
+ if k == name:
66
+ return v.decode("latin-1")
67
+ return None
68
+
69
+
70
+ def _route_template(scope: dict) -> str:
71
+ route = scope.get("route")
72
+ if route is not None:
73
+ template = getattr(route, "path_format", None) or getattr(route, "path", None)
74
+ if template:
75
+ return template
76
+ return scope.get("path", "")
77
+
78
+
79
+ def _routes_of(app) -> list:
80
+ """The router's routes, found by walking the `.app` chain of wrapped apps."""
81
+ for _ in range(16):
82
+ routes = getattr(app, "routes", None)
83
+ if routes is not None:
84
+ return list(routes)
85
+ app = getattr(app, "app", None)
86
+ if app is None:
87
+ break
88
+ return []
89
+
90
+
91
+ def _template_without_calling(scope: dict, routes: list) -> str:
92
+ """Route template for a request the app never saw (it was blocked).
93
+
94
+ Matches the path against the router's routes without running any endpoint.
95
+ Mounted sub-apps and anything unmatched fall back to the raw path: it may
96
+ carry IDs (e.g. /invoices/42), so the engine groups that access less well.
97
+ """
98
+ for route in routes:
99
+ try:
100
+ match, _ = route.matches(scope)
101
+ except Exception:
102
+ continue
103
+ template = getattr(route, "path_format", None)
104
+ if match == Match.FULL and template and not hasattr(route, "routes"):
105
+ return template
106
+ return scope.get("path", "")
107
+
108
+
109
+ class VeridianMiddleware:
110
+ def __init__(
111
+ self,
112
+ app,
113
+ token: str,
114
+ base_url: str,
115
+ user_id=lambda request: None,
116
+ session_id=lambda request: None,
117
+ trusted_proxies: int = 0,
118
+ http_client: httpx.AsyncClient | None = None,
119
+ flush_interval: float = 2.0,
120
+ poll_interval: float = 2.0,
121
+ max_queue: int = 1000,
122
+ background: bool = True,
123
+ exclude_paths: tuple[str, ...] = DEFAULT_EXCLUDE_PATHS,
124
+ ):
125
+ """`user_id` and `session_id` are callables taking a Starlette Request
126
+ and returning a string or None. They run before the app (to enforce the
127
+ blocklist) and again after it (the app may have just logged the user in,
128
+ e.g. by setting request.state). `background=False` disables the
129
+ flush/poll tasks so tests can drive flush() and refresh_blocklist().
130
+
131
+ `exclude_paths` are path prefixes (whole segments) that pass straight
132
+ through: no event, no device cookie and no blocklist check. Static
133
+ assets are not user actions, and a blocked attacker only needs to be
134
+ stopped on the app's routes, so `/static` is excluded by default."""
135
+ self.app = app
136
+ self.token = token
137
+ self.base_url = base_url.rstrip("/")
138
+ self.user_id = user_id
139
+ self.session_id = session_id
140
+ self.trusted_proxies = trusted_proxies
141
+ self.exclude_paths = tuple(p.rstrip("/") for p in exclude_paths)
142
+ self.client = http_client or httpx.AsyncClient(timeout=2.0)
143
+ self.flush_interval = flush_interval
144
+ self.poll_interval = poll_interval
145
+ self.background = background
146
+ self.queue: deque[dict] = deque(maxlen=max_queue) # drops oldest when full
147
+ self.blocklist_version: int | None = None
148
+ self.blocked = {"users": set(), "sessions": set(), "ips": set()}
149
+ self._tasks: list[asyncio.Task] = []
150
+
151
+ # --- ASGI --------------------------------------------------------------
152
+
153
+ async def __call__(self, scope, receive, send):
154
+ if scope["type"] == "lifespan":
155
+ await self.app(scope, self._watch_shutdown(receive), send)
156
+ return
157
+ if scope["type"] != "http":
158
+ await self.app(scope, receive, send)
159
+ return
160
+
161
+ self._ensure_background()
162
+ if self._is_excluded(scope.get("path", "")):
163
+ await self.app(scope, receive, send)
164
+ return
165
+ request = Request(scope)
166
+ ip = resolve_client_ip(scope, self.trusted_proxies)
167
+
168
+ if self._is_blocked(request, ip):
169
+ response = JSONResponse({"error": "blocked"}, status_code=403)
170
+ await response(scope, receive, send)
171
+ # The app is not called, so the route template is matched by hand.
172
+ # The device cookie is only read here, never issued to a blocked client.
173
+ self._record(
174
+ scope, request, ip, _cookie(scope, DEVICE_COOKIE), 403, len(response.body),
175
+ route=_template_without_calling(scope, _routes_of(scope.get("app") or self.app)),
176
+ )
177
+ return
178
+
179
+ device_id = _cookie(scope, DEVICE_COOKIE)
180
+ new_device = device_id is None
181
+ if new_device:
182
+ device_id = "d_" + uuid.uuid4().hex[:12]
183
+ status = 0
184
+ size = 0
185
+
186
+ async def send_wrapper(message):
187
+ nonlocal status, size
188
+ if message["type"] == "http.response.start":
189
+ status = message["status"]
190
+ if new_device:
191
+ cookie = (
192
+ f"{DEVICE_COOKIE}={device_id}; Path=/; HttpOnly; SameSite=Lax; "
193
+ f"Max-Age={DEVICE_COOKIE_MAX_AGE}"
194
+ )
195
+ message = {
196
+ **message,
197
+ "headers": [*message.get("headers", []), (b"set-cookie", cookie.encode())],
198
+ }
199
+ elif message["type"] == "http.response.body":
200
+ size += len(message.get("body", b""))
201
+ await send(message)
202
+
203
+ try:
204
+ await self.app(scope, receive, send_wrapper)
205
+ finally:
206
+ self._record(scope, request, ip, device_id, status, size)
207
+
208
+ def _is_excluded(self, path: str) -> bool:
209
+ return any(path == p or path.startswith(p + "/") for p in self.exclude_paths)
210
+
211
+ def _watch_shutdown(self, receive):
212
+ async def wrapped():
213
+ message = await receive()
214
+ if message["type"] == "lifespan.shutdown":
215
+ await self.aclose()
216
+ return message
217
+
218
+ return wrapped
219
+
220
+ # --- blocklist ---------------------------------------------------------
221
+
222
+ def _safe(self, fn, request):
223
+ try:
224
+ value = fn(request)
225
+ return value if value else None
226
+ except Exception:
227
+ log.exception("veridian: identity callable failed")
228
+ return None
229
+
230
+ def _is_blocked(self, request: Request, ip: str) -> bool:
231
+ b = self.blocked
232
+ if ip and ip in b["ips"]:
233
+ return True
234
+ session = self._safe(self.session_id, request)
235
+ if session and session in b["sessions"]:
236
+ return True
237
+ user = self._safe(self.user_id, request)
238
+ return bool(user and user in b["users"])
239
+
240
+ async def refresh_blocklist(self) -> None:
241
+ """Poll the engine once. Any failure keeps the last known list."""
242
+ try:
243
+ params = {"since": self.blocklist_version} if self.blocklist_version is not None else None
244
+ r = await self.client.get(
245
+ f"{self.base_url}/v1/blocklist", params=params, headers=self._auth()
246
+ )
247
+ if r.status_code == 304:
248
+ return
249
+ if r.status_code != 200:
250
+ return
251
+ data = r.json()
252
+ self.blocked = {
253
+ "users": set(map(str, data.get("users", []))),
254
+ "sessions": set(map(str, data.get("sessions", []))),
255
+ "ips": set(map(str, data.get("ips", []))),
256
+ }
257
+ self.blocklist_version = int(data["version"])
258
+ except Exception as exc:
259
+ log.warning("veridian: blocklist refresh failed: %s", exc)
260
+
261
+ # --- events ------------------------------------------------------------
262
+
263
+ def _record(self, scope, request, ip, device_id, status, size, route=None) -> None:
264
+ try:
265
+ user = self._safe(self.user_id, request)
266
+ if not user:
267
+ return
268
+ self.queue.append(
269
+ {
270
+ "id": "e_" + uuid.uuid4().hex,
271
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
272
+ "user_id": user,
273
+ "session_id": self._safe(self.session_id, request),
274
+ "ip": ip,
275
+ "user_agent": (_header(scope, b"user-agent") or "")[:200],
276
+ "device_id": device_id,
277
+ "method": scope["method"],
278
+ "route": route if route is not None else _route_template(scope),
279
+ "status": status,
280
+ "bytes": size,
281
+ }
282
+ )
283
+ except Exception:
284
+ log.exception("veridian: could not record event")
285
+
286
+ async def flush(self) -> None:
287
+ """Send queued events once. On failure they stay queued (ids make the
288
+ retry idempotent)."""
289
+ batch = list(self.queue)[:MAX_BATCH]
290
+ if not batch:
291
+ return
292
+ try:
293
+ r = await self.client.post(
294
+ f"{self.base_url}/v1/events", json={"events": batch}, headers=self._auth()
295
+ )
296
+ if r.status_code != 202:
297
+ return
298
+ except Exception as exc:
299
+ log.warning("veridian: event flush failed: %s", exc)
300
+ return
301
+ sent = {e["id"] for e in batch}
302
+ remaining = [e for e in self.queue if e["id"] not in sent]
303
+ self.queue.clear()
304
+ self.queue.extend(remaining)
305
+
306
+ # --- background --------------------------------------------------------
307
+
308
+ def _auth(self) -> dict:
309
+ return {"Authorization": f"Bearer {self.token}"}
310
+
311
+ def _ensure_background(self) -> None:
312
+ if not self.background or self._tasks:
313
+ return
314
+ loop = asyncio.get_running_loop()
315
+ self._tasks = [
316
+ loop.create_task(self._loop(self.flush, self.flush_interval)),
317
+ loop.create_task(self._loop(self.refresh_blocklist, self.poll_interval)),
318
+ ]
319
+
320
+ @staticmethod
321
+ async def _loop(fn, interval: float) -> None:
322
+ while True:
323
+ try:
324
+ await fn()
325
+ except Exception:
326
+ log.exception("veridian: background task error")
327
+ await asyncio.sleep(interval)
328
+
329
+ async def aclose(self) -> None:
330
+ for t in self._tasks:
331
+ t.cancel()
332
+ self._tasks = []