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,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,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 = []
|