volidator-python 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.
- volidator/__init__.py +5 -0
- volidator/agent.py +137 -0
- volidator/client.py +1129 -0
- volidator/compliance.py +70 -0
- volidator/plugins/__init__.py +1 -0
- volidator/plugins/langchain.py +115 -0
- volidator/plugins/llamaindex.py +96 -0
- volidator_python-0.1.0.dist-info/METADATA +102 -0
- volidator_python-0.1.0.dist-info/RECORD +11 -0
- volidator_python-0.1.0.dist-info/WHEEL +5 -0
- volidator_python-0.1.0.dist-info/top_level.txt +1 -0
volidator/client.py
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import contextlib
|
|
4
|
+
import contextvars
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import httpx
|
|
19
|
+
HAS_HTTPX = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
httpx = None
|
|
22
|
+
HAS_HTTPX = False
|
|
23
|
+
|
|
24
|
+
# Global thread pool executor for urllib async fallbacks
|
|
25
|
+
import concurrent.futures
|
|
26
|
+
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
|
|
27
|
+
|
|
28
|
+
# Context variables for trace propagation and logical clocks
|
|
29
|
+
agent_context_store = contextvars.ContextVar("agent_context_store", default=None)
|
|
30
|
+
logical_clock_store = contextvars.ContextVar("logical_clock_store", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VolidatorClient:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str,
|
|
37
|
+
encryption_key: Optional[str] = None,
|
|
38
|
+
keyring: Optional[dict[str, str]] = None,
|
|
39
|
+
active_encryption_key_id: Optional[str] = None,
|
|
40
|
+
endpoint: str = "https://ingestion.volidator.com",
|
|
41
|
+
project_id: Optional[str] = None,
|
|
42
|
+
client_secret: Optional[str] = None,
|
|
43
|
+
telemetry: Optional[dict[str, Any]] = None,
|
|
44
|
+
redact_keys: Optional[list[str]] = None,
|
|
45
|
+
reference_keys: Optional[list[str]] = None,
|
|
46
|
+
max_metadata_size: int = 10240,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
on_delivery_failure: Optional[Callable[[dict[str, Any], Exception], None]] = None,
|
|
49
|
+
):
|
|
50
|
+
self.api_key = api_key
|
|
51
|
+
self.endpoint = endpoint.rstrip("/")
|
|
52
|
+
self.project_id = project_id
|
|
53
|
+
self.client_secret = client_secret
|
|
54
|
+
self.redact_keys = redact_keys or []
|
|
55
|
+
self.reference_keys = reference_keys or []
|
|
56
|
+
self.max_metadata_size = max_metadata_size
|
|
57
|
+
self.max_retries = max_retries
|
|
58
|
+
self.on_delivery_failure = on_delivery_failure
|
|
59
|
+
self._fallback_logical_clock = 0
|
|
60
|
+
|
|
61
|
+
# Set up keyring
|
|
62
|
+
if keyring and active_encryption_key_id:
|
|
63
|
+
self.keyring = keyring
|
|
64
|
+
self.active_key_id = active_encryption_key_id
|
|
65
|
+
elif encryption_key:
|
|
66
|
+
self.active_key_id = "v1"
|
|
67
|
+
self.keyring = {"v1": encryption_key}
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Either encryption_key OR (keyring AND active_encryption_key_id) must be provided in VolidatorClient constructor."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if len(self.keyring) > 5:
|
|
74
|
+
raise ValueError("Keyring size cannot exceed 5 keys.")
|
|
75
|
+
|
|
76
|
+
if self.active_key_id not in self.keyring:
|
|
77
|
+
raise ValueError(f"Active key ID '{self.active_key_id}' must exist in the keyring.")
|
|
78
|
+
|
|
79
|
+
self._hashed_keyring = {}
|
|
80
|
+
|
|
81
|
+
# Telemetry config resolution
|
|
82
|
+
telemetry = telemetry or {"preset": "standard"}
|
|
83
|
+
self.telemetry_config = self._resolve_telemetry_config(telemetry)
|
|
84
|
+
|
|
85
|
+
# Lazy HTTPX Client for async
|
|
86
|
+
self._httpx_client = None
|
|
87
|
+
|
|
88
|
+
# API Sub-namespaces
|
|
89
|
+
from .compliance import VolidatorCompliance
|
|
90
|
+
from .agent import VolidatorAgent
|
|
91
|
+
self.compliance = VolidatorCompliance(self)
|
|
92
|
+
self.agent = VolidatorAgent(self)
|
|
93
|
+
|
|
94
|
+
async def _get_httpx_client(self):
|
|
95
|
+
if self._httpx_client is None and HAS_HTTPX:
|
|
96
|
+
self._httpx_client = httpx.AsyncClient(timeout=10.0)
|
|
97
|
+
return self._httpx_client
|
|
98
|
+
|
|
99
|
+
async def close(self):
|
|
100
|
+
if self._httpx_client is not None:
|
|
101
|
+
await self._httpx_client.aclose()
|
|
102
|
+
self._httpx_client = None
|
|
103
|
+
|
|
104
|
+
def _resolve_telemetry_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
preset = config.get("preset", "standard")
|
|
106
|
+
ip = "anonymize"
|
|
107
|
+
user_agent = "parse"
|
|
108
|
+
location = True
|
|
109
|
+
|
|
110
|
+
if preset == "strict":
|
|
111
|
+
ip = "skip"
|
|
112
|
+
user_agent = "skip"
|
|
113
|
+
location = False
|
|
114
|
+
elif preset == "full":
|
|
115
|
+
ip = "track"
|
|
116
|
+
user_agent = "track"
|
|
117
|
+
location = True
|
|
118
|
+
|
|
119
|
+
if "ip" in config:
|
|
120
|
+
ip = config["ip"]
|
|
121
|
+
if "userAgent" in config:
|
|
122
|
+
user_agent = config["userAgent"]
|
|
123
|
+
if "location" in config:
|
|
124
|
+
location = config["location"]
|
|
125
|
+
|
|
126
|
+
return {"ip": ip, "userAgent": user_agent, "location": location}
|
|
127
|
+
|
|
128
|
+
@contextlib.contextmanager
|
|
129
|
+
def run_in_agent_context(self, context: dict[str, Any]):
|
|
130
|
+
token = agent_context_store.set(context)
|
|
131
|
+
try:
|
|
132
|
+
yield
|
|
133
|
+
finally:
|
|
134
|
+
agent_context_store.reset(token)
|
|
135
|
+
|
|
136
|
+
@contextlib.contextmanager
|
|
137
|
+
def trace_from_langchain(self, config: Optional[dict[str, Any]]):
|
|
138
|
+
run_id = str(config.get("run_id", "")) if config else ""
|
|
139
|
+
configurable = config.get("configurable") or {} if config else {}
|
|
140
|
+
thread_id = str(configurable.get("thread_id", ""))
|
|
141
|
+
|
|
142
|
+
trace_id = run_id or thread_id or None
|
|
143
|
+
ctx = {}
|
|
144
|
+
if trace_id:
|
|
145
|
+
ctx["traceId"] = trace_id
|
|
146
|
+
|
|
147
|
+
token = agent_context_store.set(ctx)
|
|
148
|
+
try:
|
|
149
|
+
yield
|
|
150
|
+
finally:
|
|
151
|
+
agent_context_store.reset(token)
|
|
152
|
+
|
|
153
|
+
@contextlib.contextmanager
|
|
154
|
+
def run_in_clock_context(self, initial_clock: int = 0):
|
|
155
|
+
token = logical_clock_store.set({"clock": initial_clock})
|
|
156
|
+
try:
|
|
157
|
+
yield
|
|
158
|
+
finally:
|
|
159
|
+
logical_clock_store.reset(token)
|
|
160
|
+
|
|
161
|
+
def get_and_increment_clock(self, incoming_clock: Optional[int] = None) -> int:
|
|
162
|
+
store = logical_clock_store.get()
|
|
163
|
+
if store is not None:
|
|
164
|
+
store["clock"] = max(store.get("clock", 0), incoming_clock or 0) + 1
|
|
165
|
+
return store["clock"]
|
|
166
|
+
|
|
167
|
+
self._fallback_logical_clock = max(self._fallback_logical_clock, incoming_clock or 0) + 1
|
|
168
|
+
return self._fallback_logical_clock
|
|
169
|
+
|
|
170
|
+
def _get_hashed_key(self, key_id: str) -> bytes:
|
|
171
|
+
if key_id in self._hashed_keyring:
|
|
172
|
+
return self._hashed_keyring[key_id]
|
|
173
|
+
raw_key = self.keyring[key_id]
|
|
174
|
+
hashed = hashlib.sha256(raw_key.encode("utf-8")).digest()
|
|
175
|
+
self._hashed_keyring[key_id] = hashed
|
|
176
|
+
return hashed
|
|
177
|
+
|
|
178
|
+
def generate_blind_index(self, value: str, key_buffer: bytes) -> str:
|
|
179
|
+
import hmac
|
|
180
|
+
sig = hmac.digest(key_buffer, value.encode("utf-8"), "sha256")
|
|
181
|
+
return sig.hex()
|
|
182
|
+
|
|
183
|
+
def encrypt_payload(self, payload: dict[str, Any]) -> str:
|
|
184
|
+
text = json.dumps(payload, separators=(",", ":"))
|
|
185
|
+
iv = os.urandom(12)
|
|
186
|
+
active_hashed_key = self._get_hashed_key(self.active_key_id)
|
|
187
|
+
aesgcm = AESGCM(active_hashed_key)
|
|
188
|
+
encrypted_data = aesgcm.encrypt(iv, text.encode("utf-8"), None)
|
|
189
|
+
final_buf = iv + encrypted_data
|
|
190
|
+
base64_str = base64.b64encode(final_buf).decode("utf-8")
|
|
191
|
+
return f"{self.active_key_id}:{base64_str}"
|
|
192
|
+
|
|
193
|
+
def decrypt_payload(self, encrypted_str: str) -> dict[str, Any]:
|
|
194
|
+
if ":" not in encrypted_str:
|
|
195
|
+
raise ValueError("Invalid encrypted payload format")
|
|
196
|
+
key_id, base64_data = encrypted_str.split(":", 1)
|
|
197
|
+
if key_id not in self.keyring:
|
|
198
|
+
raise ValueError(f"Decryption key '{key_id}' not found in keyring")
|
|
199
|
+
hashed_key = self._get_hashed_key(key_id)
|
|
200
|
+
final_buf = base64.b64decode(base64_data)
|
|
201
|
+
if len(final_buf) < 28:
|
|
202
|
+
raise ValueError("Encrypted data is too short")
|
|
203
|
+
iv = final_buf[:12]
|
|
204
|
+
encrypted_data = final_buf[12:]
|
|
205
|
+
aesgcm = AESGCM(hashed_key)
|
|
206
|
+
decrypted_bytes = aesgcm.decrypt(iv, encrypted_data, None)
|
|
207
|
+
return json.loads(decrypted_bytes.decode("utf-8"))
|
|
208
|
+
|
|
209
|
+
def _parse_user_agent(self, ua: str) -> dict[str, str]:
|
|
210
|
+
browser = "Unknown Browser"
|
|
211
|
+
os_name = "Unknown OS"
|
|
212
|
+
device_type = "Desktop"
|
|
213
|
+
|
|
214
|
+
if not ua:
|
|
215
|
+
return {"browser": browser, "os": os_name, "type": device_type}
|
|
216
|
+
|
|
217
|
+
if re.search(r"Volidator-Ingest-Worker", ua, re.IGNORECASE):
|
|
218
|
+
return {
|
|
219
|
+
"browser": "Volidator Ingest Worker",
|
|
220
|
+
"os": "Linux Server",
|
|
221
|
+
"type": "Server",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if re.search(r"mobile", ua, re.IGNORECASE):
|
|
225
|
+
device_type = "Mobile"
|
|
226
|
+
elif re.search(r"tablet|ipad", ua, re.IGNORECASE):
|
|
227
|
+
device_type = "Tablet"
|
|
228
|
+
elif re.search(r"server|bot", ua, re.IGNORECASE):
|
|
229
|
+
device_type = "Server"
|
|
230
|
+
|
|
231
|
+
if re.search(r"chrome|crios", ua, re.IGNORECASE) and not re.search(r"edge|edg", ua, re.IGNORECASE) and not re.search(r"opr", ua, re.IGNORECASE):
|
|
232
|
+
match = re.search(r"(?:chrome|crios)\/([0-9.]+)", ua, re.IGNORECASE)
|
|
233
|
+
browser_ver = match.group(1).split(".")[0] if match else ""
|
|
234
|
+
browser = f"Chrome {browser_ver}".strip()
|
|
235
|
+
elif re.search(r"safari", ua, re.IGNORECASE) and not re.search(r"chrome|crios", ua, re.IGNORECASE) and not re.search(r"android", ua, re.IGNORECASE):
|
|
236
|
+
browser = "Safari Mobile" if re.search(r"mobile", ua, re.IGNORECASE) else "Safari"
|
|
237
|
+
elif re.search(r"firefox|fxios", ua, re.IGNORECASE):
|
|
238
|
+
browser = "Firefox"
|
|
239
|
+
elif re.search(r"edge|edg", ua, re.IGNORECASE):
|
|
240
|
+
browser = "Edge"
|
|
241
|
+
|
|
242
|
+
if re.search(r"windows", ua, re.IGNORECASE):
|
|
243
|
+
os_name = "Windows"
|
|
244
|
+
elif re.search(r"macintosh|mac os x", ua, re.IGNORECASE):
|
|
245
|
+
os_name = "macOS"
|
|
246
|
+
elif re.search(r"iphone|ipad|ipod", ua, re.IGNORECASE):
|
|
247
|
+
os_name = "iOS"
|
|
248
|
+
elif re.search(r"android", ua, re.IGNORECASE):
|
|
249
|
+
os_name = "Android"
|
|
250
|
+
elif re.search(r"linux", ua, re.IGNORECASE):
|
|
251
|
+
os_name = "Linux"
|
|
252
|
+
|
|
253
|
+
return {"browser": browser, "os": os_name, "type": device_type}
|
|
254
|
+
|
|
255
|
+
def _extract_context(self, req: Any) -> dict[str, Any]:
|
|
256
|
+
if not req:
|
|
257
|
+
return {}
|
|
258
|
+
|
|
259
|
+
headers = {}
|
|
260
|
+
if hasattr(req, "headers"):
|
|
261
|
+
# Check if headers has a get method (FastAPI/Flask-like)
|
|
262
|
+
headers = req.headers
|
|
263
|
+
elif hasattr(req, "META") and isinstance(req.META, dict):
|
|
264
|
+
# Django META
|
|
265
|
+
headers = {
|
|
266
|
+
k.replace("HTTP_", "").replace("_", "-").lower(): v
|
|
267
|
+
for k, v in req.META.items()
|
|
268
|
+
}
|
|
269
|
+
if "CONTENT_TYPE" in req.META:
|
|
270
|
+
headers["content-type"] = req.META["CONTENT_TYPE"]
|
|
271
|
+
if "CONTENT_LENGTH" in req.META:
|
|
272
|
+
headers["content-length"] = req.META["CONTENT_LENGTH"]
|
|
273
|
+
if "REMOTE_ADDR" in req.META:
|
|
274
|
+
headers["remote-addr"] = req.META["REMOTE_ADDR"]
|
|
275
|
+
elif isinstance(req, dict):
|
|
276
|
+
headers = req
|
|
277
|
+
|
|
278
|
+
def get_header(name: str) -> str:
|
|
279
|
+
if hasattr(headers, "get"):
|
|
280
|
+
return headers.get(name) or headers.get(name.lower()) or ""
|
|
281
|
+
try:
|
|
282
|
+
return headers[name] or headers[name.lower()] or ""
|
|
283
|
+
except (KeyError, TypeError):
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
cf_ip = get_header("cf-connecting-ip")
|
|
287
|
+
x_real_ip = get_header("x-real-ip")
|
|
288
|
+
x_forwarded = get_header("x-forwarded-for")
|
|
289
|
+
|
|
290
|
+
raw_ip = cf_ip or x_real_ip or x_forwarded
|
|
291
|
+
ip = ""
|
|
292
|
+
if raw_ip:
|
|
293
|
+
ip = raw_ip.split(",")[0].strip()
|
|
294
|
+
else:
|
|
295
|
+
if hasattr(req, "remote_addr"):
|
|
296
|
+
ip = req.remote_addr or ""
|
|
297
|
+
elif "remote-addr" in headers:
|
|
298
|
+
ip = headers["remote-addr"]
|
|
299
|
+
|
|
300
|
+
ua = get_header("user-agent")
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"ip": ip,
|
|
304
|
+
"userAgent": ua,
|
|
305
|
+
"location": {
|
|
306
|
+
"country": get_header("cf-ipcountry") or get_header("x-vercel-ip-country") or "",
|
|
307
|
+
"region": get_header("cf-region-code") or get_header("x-vercel-ip-country-region") or "",
|
|
308
|
+
"city": get_header("cf-ipcity") or get_header("x-vercel-ip-city") or "",
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
def _extract_trace_context(self, req: Any) -> dict[str, Any]:
|
|
313
|
+
if not req:
|
|
314
|
+
return {}
|
|
315
|
+
|
|
316
|
+
headers = {}
|
|
317
|
+
if hasattr(req, "headers"):
|
|
318
|
+
headers = req.headers
|
|
319
|
+
elif hasattr(req, "META") and isinstance(req.META, dict):
|
|
320
|
+
headers = {
|
|
321
|
+
k.replace("HTTP_", "").replace("_", "-").lower(): v
|
|
322
|
+
for k, v in req.META.items()
|
|
323
|
+
}
|
|
324
|
+
elif isinstance(req, dict):
|
|
325
|
+
headers = req
|
|
326
|
+
|
|
327
|
+
def get_header(name: str) -> str:
|
|
328
|
+
if hasattr(headers, "get"):
|
|
329
|
+
return headers.get(name) or headers.get(name.lower()) or ""
|
|
330
|
+
try:
|
|
331
|
+
return headers[name] or headers[name.lower()] or ""
|
|
332
|
+
except (KeyError, TypeError):
|
|
333
|
+
return ""
|
|
334
|
+
|
|
335
|
+
result = {}
|
|
336
|
+
clock_val = get_header("x-volidator-clock")
|
|
337
|
+
if clock_val:
|
|
338
|
+
try:
|
|
339
|
+
result["logicalClock"] = int(clock_val)
|
|
340
|
+
except ValueError:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
traceparent = get_header("traceparent")
|
|
344
|
+
if traceparent:
|
|
345
|
+
parts = traceparent.split("-")
|
|
346
|
+
if len(parts) == 4:
|
|
347
|
+
result["traceId"] = parts[1]
|
|
348
|
+
result["spanId"] = parts[2]
|
|
349
|
+
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
def _prepare_log_entry_common(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> tuple[dict[str, Any], dict[str, Any], str, str, str, str, Optional[str]]:
|
|
353
|
+
metadata = payload.get("metadata") or {}
|
|
354
|
+
payload_size = len(json.dumps(metadata, separators=(",", ":")).encode("utf-8"))
|
|
355
|
+
|
|
356
|
+
if payload_size > 5 * 1024 * 1024:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"Volidator SDK Error: Audit log payload exceeds the 5MB hard limit ({payload_size} bytes)."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
actor_raw = payload.get("actor") or payload.get("actorId") or "unknown"
|
|
362
|
+
target_raw = payload.get("target") or payload.get("targetId") or "unknown"
|
|
363
|
+
tenant_raw = payload.get("tenant") or payload.get("tenantId") or ""
|
|
364
|
+
action = payload.get("action", "unknown")
|
|
365
|
+
|
|
366
|
+
def truncate(s: str, max_len: int) -> str:
|
|
367
|
+
return s[:max_len] + "..." if len(s) > max_len else s
|
|
368
|
+
|
|
369
|
+
def extract_pii(v: Any) -> str:
|
|
370
|
+
if isinstance(v, dict) and "pii" in v:
|
|
371
|
+
return v["pii"]
|
|
372
|
+
if hasattr(v, "pii"):
|
|
373
|
+
return getattr(v, "pii")
|
|
374
|
+
return str(v)
|
|
375
|
+
|
|
376
|
+
def extract_id(v: Any) -> str:
|
|
377
|
+
if isinstance(v, dict) and "id" in v:
|
|
378
|
+
return v["id"]
|
|
379
|
+
if hasattr(v, "id"):
|
|
380
|
+
return getattr(v, "id")
|
|
381
|
+
return str(v)
|
|
382
|
+
|
|
383
|
+
actor_plain = truncate(extract_pii(actor_raw), 255)
|
|
384
|
+
target_plain = truncate(extract_pii(target_raw), 255)
|
|
385
|
+
tenant_plain = truncate(extract_pii(tenant_raw), 255) if tenant_raw else ""
|
|
386
|
+
action_plain = truncate(action, 255)
|
|
387
|
+
|
|
388
|
+
log_telemetry = self.telemetry_config
|
|
389
|
+
if "telemetry" in payload:
|
|
390
|
+
log_telemetry = self._resolve_telemetry_config({**self.telemetry_config, **payload["telemetry"]})
|
|
391
|
+
|
|
392
|
+
payload_ctx = payload.get("context") or {}
|
|
393
|
+
if payload.get("req"):
|
|
394
|
+
extracted = self._extract_context(payload["req"])
|
|
395
|
+
payload_ctx = {
|
|
396
|
+
**extracted,
|
|
397
|
+
**payload_ctx,
|
|
398
|
+
"location": {
|
|
399
|
+
**extracted.get("location", {}),
|
|
400
|
+
**payload_ctx.get("location", {}),
|
|
401
|
+
},
|
|
402
|
+
"device": {
|
|
403
|
+
**extracted.get("device", {}),
|
|
404
|
+
**payload_ctx.get("device", {}),
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
context = {}
|
|
409
|
+
raw_ip = payload_ctx.get("ip") or ""
|
|
410
|
+
raw_ua = payload_ctx.get("userAgent") or ""
|
|
411
|
+
|
|
412
|
+
if log_telemetry.get("location"):
|
|
413
|
+
context["location"] = {}
|
|
414
|
+
if payload_ctx.get("location"):
|
|
415
|
+
context["location"]["country"] = payload_ctx["location"].get("country") or ""
|
|
416
|
+
context["location"]["region"] = payload_ctx["location"].get("region") or ""
|
|
417
|
+
is_full = (
|
|
418
|
+
payload.get("telemetry", {}).get("preset") == "full" or
|
|
419
|
+
(not payload.get("telemetry") and self.telemetry_config.get("ip") == "track")
|
|
420
|
+
)
|
|
421
|
+
if log_telemetry.get("ip") == "track" or is_full:
|
|
422
|
+
context["location"]["city"] = payload_ctx["location"].get("city") or ""
|
|
423
|
+
|
|
424
|
+
active_hashed_key = self._get_hashed_key(self.active_key_id)
|
|
425
|
+
if log_telemetry.get("ip") == "anonymize" and raw_ip:
|
|
426
|
+
context["ip"] = self.generate_blind_index(raw_ip, active_hashed_key)
|
|
427
|
+
elif log_telemetry.get("ip") == "track" and raw_ip:
|
|
428
|
+
context["ip"] = raw_ip
|
|
429
|
+
|
|
430
|
+
if log_telemetry.get("userAgent") != "skip":
|
|
431
|
+
if raw_ua:
|
|
432
|
+
context["device"] = self._parse_user_agent(raw_ua)
|
|
433
|
+
if log_telemetry.get("userAgent") == "track":
|
|
434
|
+
context["userAgent"] = truncate(raw_ua, 1000)
|
|
435
|
+
elif payload_ctx.get("device"):
|
|
436
|
+
context["device"] = payload_ctx["device"]
|
|
437
|
+
|
|
438
|
+
def scrub(val: str, key: str) -> str:
|
|
439
|
+
return f"[REDACTED:{key}]" if key in self.redact_keys else val
|
|
440
|
+
|
|
441
|
+
def apply_ref(raw_val: Any, key: str) -> str:
|
|
442
|
+
if key in self.reference_keys:
|
|
443
|
+
ref_id = extract_id(raw_val)
|
|
444
|
+
return f"[REF:{ref_id}]"
|
|
445
|
+
return scrub(extract_pii(raw_val), key)
|
|
446
|
+
|
|
447
|
+
safe_metadata = {}
|
|
448
|
+
for k, v in metadata.items():
|
|
449
|
+
meta_key = f"metadata.{k}"
|
|
450
|
+
if meta_key in self.reference_keys and isinstance(v, dict) and "id" in v:
|
|
451
|
+
safe_metadata[k] = f"[REF:{v['id']}]"
|
|
452
|
+
elif meta_key in self.redact_keys and isinstance(v, str):
|
|
453
|
+
safe_metadata[k] = f"[REDACTED:{k}]"
|
|
454
|
+
else:
|
|
455
|
+
safe_metadata[k] = v
|
|
456
|
+
|
|
457
|
+
did_truncate_depth = False
|
|
458
|
+
did_truncate_string = False
|
|
459
|
+
|
|
460
|
+
def limit_depth(obj, current_depth=1):
|
|
461
|
+
nonlocal did_truncate_depth, did_truncate_string
|
|
462
|
+
if current_depth > 5:
|
|
463
|
+
did_truncate_depth = True
|
|
464
|
+
return "[Truncated - Depth Exceeded]"
|
|
465
|
+
if obj is None:
|
|
466
|
+
return None
|
|
467
|
+
if isinstance(obj, str):
|
|
468
|
+
truncated_val = truncate(obj, 1000)
|
|
469
|
+
if len(truncated_val) != len(obj):
|
|
470
|
+
did_truncate_string = True
|
|
471
|
+
return truncated_val
|
|
472
|
+
if isinstance(obj, (int, float, bool)):
|
|
473
|
+
return obj
|
|
474
|
+
if isinstance(obj, list):
|
|
475
|
+
return [limit_depth(item, current_depth + 1) for item in obj]
|
|
476
|
+
if isinstance(obj, dict):
|
|
477
|
+
return {k: limit_depth(val, current_depth + 1) for k, val in obj.items()}
|
|
478
|
+
return limit_depth(str(obj), current_depth)
|
|
479
|
+
|
|
480
|
+
processed_metadata = limit_depth(safe_metadata)
|
|
481
|
+
|
|
482
|
+
serialized_meta = json.dumps(processed_metadata, separators=(",", ":"))
|
|
483
|
+
max_meta_limit = max_meta_override or self.max_metadata_size
|
|
484
|
+
if len(serialized_meta) > max_meta_limit:
|
|
485
|
+
raise ValueError(f"Metadata size exceeds maximum allowed limit of {max_meta_limit / 1024}KB.")
|
|
486
|
+
|
|
487
|
+
safe_actor = apply_ref(actor_raw, "actor")
|
|
488
|
+
safe_target = apply_ref(target_raw, "target")
|
|
489
|
+
safe_tenant = apply_ref(tenant_raw, "tenant") if tenant_raw else None
|
|
490
|
+
|
|
491
|
+
enriched_payload = {
|
|
492
|
+
"actor": safe_actor,
|
|
493
|
+
"action": action_plain,
|
|
494
|
+
"target": safe_target,
|
|
495
|
+
"metadata": processed_metadata,
|
|
496
|
+
}
|
|
497
|
+
if safe_tenant:
|
|
498
|
+
enriched_payload["tenant"] = safe_tenant
|
|
499
|
+
if context:
|
|
500
|
+
enriched_payload["context"] = context
|
|
501
|
+
|
|
502
|
+
# Extract trace variables
|
|
503
|
+
trace_id = payload.get("traceId")
|
|
504
|
+
span_id = payload.get("spanId")
|
|
505
|
+
parent_span_id = payload.get("parentSpanId")
|
|
506
|
+
logical_clock = payload.get("logicalClock")
|
|
507
|
+
|
|
508
|
+
agent_ctx = agent_context_store.get()
|
|
509
|
+
if agent_ctx:
|
|
510
|
+
if not trace_id and "traceId" in agent_ctx:
|
|
511
|
+
trace_id = agent_ctx["traceId"]
|
|
512
|
+
if not span_id and "spanId" in agent_ctx:
|
|
513
|
+
span_id = agent_ctx["spanId"]
|
|
514
|
+
|
|
515
|
+
if payload.get("req"):
|
|
516
|
+
extracted_trace = self._extract_trace_context(payload["req"])
|
|
517
|
+
if not trace_id and "traceId" in extracted_trace:
|
|
518
|
+
trace_id = extracted_trace["traceId"]
|
|
519
|
+
if not span_id and "spanId" in extracted_trace:
|
|
520
|
+
span_id = extracted_trace["spanId"]
|
|
521
|
+
if logical_clock is None and "logicalClock" in extracted_trace:
|
|
522
|
+
logical_clock = extracted_trace["logicalClock"]
|
|
523
|
+
|
|
524
|
+
resolved_clock = self.get_and_increment_clock(logical_clock)
|
|
525
|
+
|
|
526
|
+
if span_id:
|
|
527
|
+
enriched_payload["spanId"] = span_id
|
|
528
|
+
if parent_span_id:
|
|
529
|
+
enriched_payload["parentSpanId"] = parent_span_id
|
|
530
|
+
|
|
531
|
+
# Return parameters for final cryptographic serialization
|
|
532
|
+
return enriched_payload, active_hashed_key, actor_plain, action_plain, target_plain, tenant_plain, trace_id
|
|
533
|
+
|
|
534
|
+
def prepare_log_entry(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> dict[str, Any]:
|
|
535
|
+
enriched_payload, active_hashed_key, actor, action, target, tenant, trace_id = self._prepare_log_entry_common(
|
|
536
|
+
payload, max_meta_override
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
actor_blind = self.generate_blind_index(actor, active_hashed_key)
|
|
540
|
+
action_blind = self.generate_blind_index(action, active_hashed_key)
|
|
541
|
+
target_blind = self.generate_blind_index(target, active_hashed_key)
|
|
542
|
+
tenant_blind = self.generate_blind_index(tenant, active_hashed_key) if tenant else None
|
|
543
|
+
trace_blind = self.generate_blind_index(trace_id, active_hashed_key) if trace_id else None
|
|
544
|
+
|
|
545
|
+
encrypted_payload = self.encrypt_payload(enriched_payload)
|
|
546
|
+
is_claim_check = False
|
|
547
|
+
|
|
548
|
+
if len(encrypted_payload) > 30720:
|
|
549
|
+
hash_hex = hashlib.sha256(encrypted_payload.encode("utf-8")).hexdigest()
|
|
550
|
+
# Upload encrypted payload to edge worker storage endpoint synchronously
|
|
551
|
+
try:
|
|
552
|
+
headers = {
|
|
553
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
554
|
+
"Content-Type": "application/octet-stream",
|
|
555
|
+
}
|
|
556
|
+
self._fetch_with_retry(
|
|
557
|
+
f"{self.endpoint}/v1/log/upload/{hash_hex}",
|
|
558
|
+
"PUT",
|
|
559
|
+
headers,
|
|
560
|
+
encrypted_payload.encode("utf-8"),
|
|
561
|
+
)
|
|
562
|
+
encrypted_payload = hash_hex
|
|
563
|
+
is_claim_check = True
|
|
564
|
+
except Exception as err:
|
|
565
|
+
# Fall back to original payload
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
rationale = payload.get("rationale") or (agent_context_store.get() or {}).get("rationale")
|
|
569
|
+
tool_name = payload.get("toolName") or (agent_context_store.get() or {}).get("toolName")
|
|
570
|
+
|
|
571
|
+
agent_context = None
|
|
572
|
+
if rationale or tool_name:
|
|
573
|
+
truncated_rationale = rationale[:1000] if rationale else None
|
|
574
|
+
agent_context = self.encrypt_payload({
|
|
575
|
+
"rationale": truncated_rationale,
|
|
576
|
+
"toolName": tool_name,
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
attestation_proof = json.dumps(payload["attestation"], separators=(",", ":")) if payload.get("attestation") else None
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
"actorBlindIndex": actor_blind,
|
|
583
|
+
"actionBlindIndex": action_blind,
|
|
584
|
+
"targetBlindIndex": target_blind,
|
|
585
|
+
"tenantBlindIndex": tenant_blind,
|
|
586
|
+
"traceBlindIndex": trace_blind,
|
|
587
|
+
"encryptedPayload": encrypted_payload,
|
|
588
|
+
"logicalClock": enriched_payload.get("logicalClock", self._fallback_logical_clock),
|
|
589
|
+
"isClaimCheck": is_claim_check,
|
|
590
|
+
"agentContext": agent_context,
|
|
591
|
+
"attestationProof": attestation_proof,
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async def prepare_log_entry_async(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> dict[str, Any]:
|
|
595
|
+
# Perform synchronous core preparation
|
|
596
|
+
enriched_payload, active_hashed_key, actor, action, target, tenant, trace_id = self._prepare_log_entry_common(
|
|
597
|
+
payload, max_meta_override
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
actor_blind = self.generate_blind_index(actor, active_hashed_key)
|
|
601
|
+
action_blind = self.generate_blind_index(action, active_hashed_key)
|
|
602
|
+
target_blind = self.generate_blind_index(target, active_hashed_key)
|
|
603
|
+
tenant_blind = self.generate_blind_index(tenant, active_hashed_key) if tenant else None
|
|
604
|
+
trace_blind = self.generate_blind_index(trace_id, active_hashed_key) if trace_id else None
|
|
605
|
+
|
|
606
|
+
encrypted_payload = self.encrypt_payload(enriched_payload)
|
|
607
|
+
is_claim_check = False
|
|
608
|
+
|
|
609
|
+
if len(encrypted_payload) > 30720:
|
|
610
|
+
hash_hex = hashlib.sha256(encrypted_payload.encode("utf-8")).hexdigest()
|
|
611
|
+
# Upload encrypted payload asynchronously
|
|
612
|
+
try:
|
|
613
|
+
headers = {
|
|
614
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
615
|
+
"Content-Type": "application/octet-stream",
|
|
616
|
+
}
|
|
617
|
+
await self._fetch_with_retry_async(
|
|
618
|
+
f"{self.endpoint}/v1/log/upload/{hash_hex}",
|
|
619
|
+
"PUT",
|
|
620
|
+
headers,
|
|
621
|
+
encrypted_payload.encode("utf-8"),
|
|
622
|
+
)
|
|
623
|
+
encrypted_payload = hash_hex
|
|
624
|
+
is_claim_check = True
|
|
625
|
+
except Exception as err:
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
rationale = payload.get("rationale") or (agent_context_store.get() or {}).get("rationale")
|
|
629
|
+
tool_name = payload.get("toolName") or (agent_context_store.get() or {}).get("toolName")
|
|
630
|
+
|
|
631
|
+
agent_context = None
|
|
632
|
+
if rationale or tool_name:
|
|
633
|
+
truncated_rationale = rationale[:1000] if rationale else None
|
|
634
|
+
agent_context = self.encrypt_payload({
|
|
635
|
+
"rationale": truncated_rationale,
|
|
636
|
+
"toolName": tool_name,
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
attestation_proof = json.dumps(payload["attestation"], separators=(",", ":")) if payload.get("attestation") else None
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
"actorBlindIndex": actor_blind,
|
|
643
|
+
"actionBlindIndex": action_blind,
|
|
644
|
+
"targetBlindIndex": target_blind,
|
|
645
|
+
"tenantBlindIndex": tenant_blind,
|
|
646
|
+
"traceBlindIndex": trace_blind,
|
|
647
|
+
"encryptedPayload": encrypted_payload,
|
|
648
|
+
"logicalClock": enriched_payload.get("logicalClock", self._fallback_logical_clock),
|
|
649
|
+
"isClaimCheck": is_claim_check,
|
|
650
|
+
"agentContext": agent_context,
|
|
651
|
+
"attestationProof": attestation_proof,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
def _fetch_with_retry(self, url: str, method: str, headers: dict[str, str], body: bytes, max_retries: Optional[int] = None) -> bytes:
|
|
655
|
+
if max_retries is None:
|
|
656
|
+
max_retries = self.max_retries
|
|
657
|
+
|
|
658
|
+
attempt = 0
|
|
659
|
+
delay = 0.5
|
|
660
|
+
last_error = Exception("Unknown error")
|
|
661
|
+
|
|
662
|
+
while attempt <= max_retries:
|
|
663
|
+
req = urllib.request.Request(
|
|
664
|
+
url,
|
|
665
|
+
data=body if method in ("POST", "PUT") else None,
|
|
666
|
+
headers=headers,
|
|
667
|
+
method=method,
|
|
668
|
+
)
|
|
669
|
+
try:
|
|
670
|
+
with urllib.request.urlopen(req, timeout=10.0) as response:
|
|
671
|
+
return response.read()
|
|
672
|
+
except urllib.error.HTTPError as e:
|
|
673
|
+
last_error = e
|
|
674
|
+
if 400 <= e.code < 500:
|
|
675
|
+
raise e
|
|
676
|
+
except Exception as e:
|
|
677
|
+
last_error = e
|
|
678
|
+
|
|
679
|
+
attempt += 1
|
|
680
|
+
if attempt <= max_retries:
|
|
681
|
+
time.sleep(delay)
|
|
682
|
+
delay *= 3
|
|
683
|
+
|
|
684
|
+
raise last_error
|
|
685
|
+
|
|
686
|
+
async def _fetch_with_retry_async(self, url: str, method: str, headers: dict[str, str], body: bytes, max_retries: Optional[int] = None) -> bytes:
|
|
687
|
+
if HAS_HTTPX:
|
|
688
|
+
if max_retries is None:
|
|
689
|
+
max_retries = self.max_retries
|
|
690
|
+
|
|
691
|
+
attempt = 0
|
|
692
|
+
delay = 0.5
|
|
693
|
+
last_error = Exception("Unknown error")
|
|
694
|
+
client = await self._get_httpx_client()
|
|
695
|
+
|
|
696
|
+
while attempt <= max_retries:
|
|
697
|
+
try:
|
|
698
|
+
response = await client.request(
|
|
699
|
+
method,
|
|
700
|
+
url,
|
|
701
|
+
headers=headers,
|
|
702
|
+
content=body,
|
|
703
|
+
timeout=10.0,
|
|
704
|
+
)
|
|
705
|
+
if response.is_success:
|
|
706
|
+
return response.content
|
|
707
|
+
response.raise_for_status()
|
|
708
|
+
except httpx.HTTPStatusError as e:
|
|
709
|
+
last_error = e
|
|
710
|
+
if 400 <= e.response.status_code < 500:
|
|
711
|
+
raise e
|
|
712
|
+
except Exception as e:
|
|
713
|
+
last_error = e
|
|
714
|
+
|
|
715
|
+
attempt += 1
|
|
716
|
+
if attempt <= max_retries:
|
|
717
|
+
await asyncio.sleep(delay)
|
|
718
|
+
delay *= 3
|
|
719
|
+
|
|
720
|
+
raise last_error
|
|
721
|
+
else:
|
|
722
|
+
# Fall back to running urllib inside the thread pool executor
|
|
723
|
+
loop = asyncio.get_running_loop()
|
|
724
|
+
return await loop.run_in_executor(
|
|
725
|
+
_executor,
|
|
726
|
+
lambda: self._fetch_with_retry(url, method, headers, body, max_retries)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
def log(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> bool:
|
|
730
|
+
try:
|
|
731
|
+
entry = self.prepare_log_entry(payload, max_meta_override)
|
|
732
|
+
headers = {
|
|
733
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
734
|
+
"Content-Type": "application/json",
|
|
735
|
+
}
|
|
736
|
+
body = json.dumps(entry, separators=(",", ":")).encode("utf-8")
|
|
737
|
+
self._fetch_with_retry(f"{self.endpoint}/v1/log", "POST", headers, body)
|
|
738
|
+
return True
|
|
739
|
+
except Exception as e:
|
|
740
|
+
import sys
|
|
741
|
+
print(f"[Volidator] Failed to send log: {str(e)}", file=sys.stderr)
|
|
742
|
+
if self.on_delivery_failure:
|
|
743
|
+
try:
|
|
744
|
+
self.on_delivery_failure(payload, e)
|
|
745
|
+
except Exception as cb_err:
|
|
746
|
+
print(f"[Volidator] Error in on_delivery_failure callback: {str(cb_err)}", file=sys.stderr)
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
async def log_async(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> bool:
|
|
750
|
+
try:
|
|
751
|
+
entry = await self.prepare_log_entry_async(payload, max_meta_override)
|
|
752
|
+
headers = {
|
|
753
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
754
|
+
"Content-Type": "application/json",
|
|
755
|
+
}
|
|
756
|
+
body = json.dumps(entry, separators=(",", ":")).encode("utf-8")
|
|
757
|
+
await self._fetch_with_retry_async(f"{self.endpoint}/v1/log", "POST", headers, body)
|
|
758
|
+
return True
|
|
759
|
+
except Exception as e:
|
|
760
|
+
import sys
|
|
761
|
+
print(f"[Volidator] Failed to send log async: {str(e)}", file=sys.stderr)
|
|
762
|
+
if self.on_delivery_failure:
|
|
763
|
+
try:
|
|
764
|
+
self.on_delivery_failure(payload, e)
|
|
765
|
+
except Exception as cb_err:
|
|
766
|
+
print(f"[Volidator] Error in on_delivery_failure callback: {str(cb_err)}", file=sys.stderr)
|
|
767
|
+
return False
|
|
768
|
+
|
|
769
|
+
def log_batch(self, payloads: list[dict[str, Any]], max_meta_override: Optional[int] = None) -> dict[str, int]:
|
|
770
|
+
if not payloads:
|
|
771
|
+
return {"accepted": 0, "rejected": 0}
|
|
772
|
+
|
|
773
|
+
batch = payloads[:100]
|
|
774
|
+
prepared_entries = []
|
|
775
|
+
rejected = len(payloads) - len(batch)
|
|
776
|
+
|
|
777
|
+
for p in batch:
|
|
778
|
+
try:
|
|
779
|
+
prepared_entries.append(self.prepare_log_entry(p, max_meta_override))
|
|
780
|
+
except Exception as e:
|
|
781
|
+
import sys
|
|
782
|
+
print(f"[Volidator] Failed to prepare batch entry: {str(e)}", file=sys.stderr)
|
|
783
|
+
rejected += 1
|
|
784
|
+
|
|
785
|
+
if not prepared_entries:
|
|
786
|
+
return {"accepted": 0, "rejected": rejected}
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
headers = {
|
|
790
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
791
|
+
"Content-Type": "application/json",
|
|
792
|
+
}
|
|
793
|
+
body = json.dumps({"logs": prepared_entries}, separators=(",", ":")).encode("utf-8")
|
|
794
|
+
self._fetch_with_retry(f"{self.endpoint}/v1/logs/batch", "POST", headers, body)
|
|
795
|
+
return {"accepted": len(prepared_entries), "rejected": rejected}
|
|
796
|
+
except Exception as e:
|
|
797
|
+
import sys
|
|
798
|
+
print(f"[Volidator] Failed to send log batch: {str(e)}", file=sys.stderr)
|
|
799
|
+
if self.on_delivery_failure:
|
|
800
|
+
for payload in batch:
|
|
801
|
+
try:
|
|
802
|
+
self.on_delivery_failure(payload, e)
|
|
803
|
+
except Exception as cb_err:
|
|
804
|
+
pass
|
|
805
|
+
return {"accepted": 0, "rejected": rejected + len(prepared_entries)}
|
|
806
|
+
|
|
807
|
+
async def log_batch_async(self, payloads: list[dict[str, Any]], max_meta_override: Optional[int] = None) -> dict[str, int]:
|
|
808
|
+
if not payloads:
|
|
809
|
+
return {"accepted": 0, "rejected": 0}
|
|
810
|
+
|
|
811
|
+
batch = payloads[:100]
|
|
812
|
+
prepared_entries = []
|
|
813
|
+
rejected = len(payloads) - len(batch)
|
|
814
|
+
|
|
815
|
+
# Crytographic parallelization is handled implicitly in Python async via gather
|
|
816
|
+
tasks = [self.prepare_log_entry_async(p, max_meta_override) for p in batch]
|
|
817
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
818
|
+
|
|
819
|
+
for i, res in enumerate(results):
|
|
820
|
+
if isinstance(res, Exception):
|
|
821
|
+
import sys
|
|
822
|
+
print(f"[Volidator] Failed to prepare batch entry async: {str(res)}", file=sys.stderr)
|
|
823
|
+
rejected += 1
|
|
824
|
+
else:
|
|
825
|
+
prepared_entries.append(res)
|
|
826
|
+
|
|
827
|
+
if not prepared_entries:
|
|
828
|
+
return {"accepted": 0, "rejected": rejected}
|
|
829
|
+
|
|
830
|
+
try:
|
|
831
|
+
headers = {
|
|
832
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
833
|
+
"Content-Type": "application/json",
|
|
834
|
+
}
|
|
835
|
+
body = json.dumps({"logs": prepared_entries}, separators=(",", ":")).encode("utf-8")
|
|
836
|
+
await self._fetch_with_retry_async(f"{self.endpoint}/v1/logs/batch", "POST", headers, body)
|
|
837
|
+
return {"accepted": len(prepared_entries), "rejected": rejected}
|
|
838
|
+
except Exception as e:
|
|
839
|
+
import sys
|
|
840
|
+
print(f"[Volidator] Failed to send log batch async: {str(e)}", file=sys.stderr)
|
|
841
|
+
if self.on_delivery_failure:
|
|
842
|
+
for payload in batch:
|
|
843
|
+
try:
|
|
844
|
+
self.on_delivery_failure(payload, e)
|
|
845
|
+
except Exception:
|
|
846
|
+
pass
|
|
847
|
+
return {"accepted": 0, "rejected": rejected + len(prepared_entries)}
|
|
848
|
+
|
|
849
|
+
def batcher(self, options: Optional[dict[str, Any]] = None) -> "VolidatorBatcher":
|
|
850
|
+
options = options or {}
|
|
851
|
+
return VolidatorBatcher(
|
|
852
|
+
self,
|
|
853
|
+
auto_flush_count=options.get("autoFlushCount", 100),
|
|
854
|
+
auto_flush_interval=options.get("autoFlushInterval"),
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def generate_embed_token(
|
|
858
|
+
self,
|
|
859
|
+
config: dict[str, Any]
|
|
860
|
+
) -> dict[str, str]:
|
|
861
|
+
project_id = config.get("projectId") or self.project_id
|
|
862
|
+
client_secret = config.get("clientSecret") or self.client_secret
|
|
863
|
+
|
|
864
|
+
if not project_id or not client_secret:
|
|
865
|
+
raise ValueError(
|
|
866
|
+
"generate_embed_token() requires projectId and clientSecret in either config or VolidatorClient constructor."
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
actor_id = config.get("actorId")
|
|
870
|
+
target_id = config.get("targetId")
|
|
871
|
+
tenant_id = config.get("tenantId")
|
|
872
|
+
scope = config.get("scope")
|
|
873
|
+
expires_in = config.get("expiresIn", "2h")
|
|
874
|
+
dashboard_url = config.get("dashboardUrl", "https://dash.volidator.com").rstrip("/")
|
|
875
|
+
host_origin = config.get("hostOrigin")
|
|
876
|
+
view = config.get("view")
|
|
877
|
+
|
|
878
|
+
default_scope = scope or ("tenant" if tenant_id else "actor")
|
|
879
|
+
|
|
880
|
+
if default_scope != "auditor" and not actor_id and not target_id and not tenant_id:
|
|
881
|
+
raise ValueError(
|
|
882
|
+
"At least one of actorId, targetId, or tenantId must be provided to generate_embed_token()."
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
actor_blinds = [
|
|
886
|
+
self.generate_blind_index(actor_id, self._get_hashed_key(kid))
|
|
887
|
+
for kid in self.keyring
|
|
888
|
+
] if actor_id else None
|
|
889
|
+
|
|
890
|
+
target_blinds = [
|
|
891
|
+
self.generate_blind_index(target_id, self._get_hashed_key(kid))
|
|
892
|
+
for kid in self.keyring
|
|
893
|
+
] if target_id else None
|
|
894
|
+
|
|
895
|
+
tenant_blinds = [
|
|
896
|
+
self.generate_blind_index(tenant_id, self._get_hashed_key(kid))
|
|
897
|
+
for kid in self.keyring
|
|
898
|
+
] if tenant_id else None
|
|
899
|
+
|
|
900
|
+
def parse_expiry(exp_str: str) -> int:
|
|
901
|
+
match = re.match(r"^(\d+)(s|m|h|d)$", exp_str)
|
|
902
|
+
if not match:
|
|
903
|
+
return 7200
|
|
904
|
+
num, unit = match.groups()
|
|
905
|
+
n = int(num)
|
|
906
|
+
if unit == "s":
|
|
907
|
+
return n
|
|
908
|
+
if unit == "m":
|
|
909
|
+
return n * 60
|
|
910
|
+
if unit == "h":
|
|
911
|
+
return n * 3600
|
|
912
|
+
if unit == "d":
|
|
913
|
+
return n * 86400
|
|
914
|
+
return 7200
|
|
915
|
+
|
|
916
|
+
parsed_exp = parse_expiry(expires_in)
|
|
917
|
+
max_exp = 604800 if default_scope == "auditor" else 3600
|
|
918
|
+
expires_seconds = min(parsed_exp, max_exp)
|
|
919
|
+
now = int(time.time())
|
|
920
|
+
|
|
921
|
+
payload = {
|
|
922
|
+
"pid": project_id,
|
|
923
|
+
"scope": default_scope,
|
|
924
|
+
"iat": now,
|
|
925
|
+
"exp": now + expires_seconds,
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if actor_blinds:
|
|
929
|
+
payload["abi"] = actor_blinds
|
|
930
|
+
if target_blinds:
|
|
931
|
+
payload["tgb"] = target_blinds
|
|
932
|
+
if tenant_blinds:
|
|
933
|
+
payload["tbi"] = tenant_blinds
|
|
934
|
+
|
|
935
|
+
if view:
|
|
936
|
+
compressed = {}
|
|
937
|
+
if isinstance(view.get("columns"), list):
|
|
938
|
+
cols = []
|
|
939
|
+
for col in view["columns"]:
|
|
940
|
+
if col == "createdAt":
|
|
941
|
+
cols.append("cat")
|
|
942
|
+
elif col == "actor":
|
|
943
|
+
cols.append("act")
|
|
944
|
+
elif col == "action":
|
|
945
|
+
cols.append("acn")
|
|
946
|
+
elif col == "target":
|
|
947
|
+
cols.append("tgt")
|
|
948
|
+
elif col.startswith("metadata."):
|
|
949
|
+
cols.append(f"m.{col[9:]}")
|
|
950
|
+
else:
|
|
951
|
+
cols.append(col)
|
|
952
|
+
compressed["cols"] = cols
|
|
953
|
+
|
|
954
|
+
if view.get("defaultFilter"):
|
|
955
|
+
flt = {}
|
|
956
|
+
df = view["defaultFilter"]
|
|
957
|
+
if "search" in df:
|
|
958
|
+
flt["q"] = df["search"]
|
|
959
|
+
if "action" in df:
|
|
960
|
+
flt["act"] = df["action"]
|
|
961
|
+
compressed["flt"] = flt
|
|
962
|
+
payload["view"] = compressed
|
|
963
|
+
|
|
964
|
+
client_secret_hash = hashlib.sha256(client_secret.encode("utf-8")).hexdigest()
|
|
965
|
+
token = self._sign_hs256_jwt(payload, client_secret_hash)
|
|
966
|
+
|
|
967
|
+
keyring_str = ",".join(f"{k}:{v}" for k, v in self.keyring.items())
|
|
968
|
+
host_param = f"?host={urllib.parse.quote(host_origin)}" if host_origin else ""
|
|
969
|
+
embed_url = f"{dashboard_url}/embed/{token}{host_param}#{keyring_str}"
|
|
970
|
+
|
|
971
|
+
return {"token": token, "embedUrl": embed_url}
|
|
972
|
+
|
|
973
|
+
async def generate_embed_token_async(self, config: dict[str, Any]) -> dict[str, str]:
|
|
974
|
+
loop = asyncio.get_running_loop()
|
|
975
|
+
return await loop.run_in_executor(_executor, lambda: self.generate_embed_token(config))
|
|
976
|
+
|
|
977
|
+
def _sign_hs256_jwt(self, payload: dict[str, Any], secret: str) -> str:
|
|
978
|
+
import hmac
|
|
979
|
+
header = {"alg": "HS256", "typ": "JWT"}
|
|
980
|
+
|
|
981
|
+
def base64url(data: bytes) -> str:
|
|
982
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
|
|
983
|
+
|
|
984
|
+
unsigned = f"{base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))}.{base64url(json.dumps(payload, separators=(',', ':')).encode('utf-8'))}"
|
|
985
|
+
signature = hmac.digest(secret.encode("utf-8"), unsigned.encode("utf-8"), "sha256")
|
|
986
|
+
return f"{unsigned}.{base64url(signature)}"
|
|
987
|
+
|
|
988
|
+
def purge_actor_logs(self, actor_id: str, options: Optional[dict[str, Any]] = None) -> dict[str, int]:
|
|
989
|
+
active_key_buffer = self._get_hashed_key(self.active_key_id)
|
|
990
|
+
actor_blind = self.generate_blind_index(actor_id, active_key_buffer)
|
|
991
|
+
|
|
992
|
+
headers = {
|
|
993
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
994
|
+
}
|
|
995
|
+
body = None
|
|
996
|
+
if options:
|
|
997
|
+
headers["Content-Type"] = "application/json"
|
|
998
|
+
body = json.dumps(options, separators=(",", ":")).encode("utf-8")
|
|
999
|
+
|
|
1000
|
+
res_bytes = self._fetch_with_retry(
|
|
1001
|
+
f"{self.endpoint}/v1/projects/{self.project_id}/actors/{actor_blind}",
|
|
1002
|
+
"DELETE",
|
|
1003
|
+
headers,
|
|
1004
|
+
body or b"",
|
|
1005
|
+
)
|
|
1006
|
+
return json.loads(res_bytes.decode("utf-8"))
|
|
1007
|
+
|
|
1008
|
+
async def purge_actor_logs_async(self, actor_id: str, options: Optional[dict[str, Any]] = None) -> dict[str, int]:
|
|
1009
|
+
active_key_buffer = self._get_hashed_key(self.active_key_id)
|
|
1010
|
+
actor_blind = self.generate_blind_index(actor_id, active_key_buffer)
|
|
1011
|
+
|
|
1012
|
+
headers = {
|
|
1013
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
1014
|
+
}
|
|
1015
|
+
body = None
|
|
1016
|
+
if options:
|
|
1017
|
+
headers["Content-Type"] = "application/json"
|
|
1018
|
+
body = json.dumps(options, separators=(",", ":")).encode("utf-8")
|
|
1019
|
+
|
|
1020
|
+
res_bytes = await self._fetch_with_retry_async(
|
|
1021
|
+
f"{self.endpoint}/v1/projects/{self.project_id}/actors/{actor_blind}",
|
|
1022
|
+
"DELETE",
|
|
1023
|
+
headers,
|
|
1024
|
+
body or b"",
|
|
1025
|
+
)
|
|
1026
|
+
return json.loads(res_bytes.decode("utf-8"))
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
class VolidatorBatcher:
|
|
1030
|
+
def __init__(self, client: VolidatorClient, auto_flush_count: int = 100, auto_flush_interval: Optional[float] = None):
|
|
1031
|
+
self.client = client
|
|
1032
|
+
self.auto_flush_count = min(auto_flush_count, 100)
|
|
1033
|
+
# auto_flush_interval is in seconds
|
|
1034
|
+
self.auto_flush_interval = auto_flush_interval
|
|
1035
|
+
|
|
1036
|
+
self.buffer = []
|
|
1037
|
+
self.lock = threading.Lock()
|
|
1038
|
+
self.timer = None
|
|
1039
|
+
|
|
1040
|
+
self.start_timer()
|
|
1041
|
+
|
|
1042
|
+
def push(self, payload: dict[str, Any]):
|
|
1043
|
+
with self.lock:
|
|
1044
|
+
self.buffer.append(payload)
|
|
1045
|
+
if len(self.buffer) >= self.auto_flush_count:
|
|
1046
|
+
threading.Thread(target=self.flush).start()
|
|
1047
|
+
|
|
1048
|
+
def flush(self) -> dict[str, int]:
|
|
1049
|
+
with self.lock:
|
|
1050
|
+
self.stop_timer()
|
|
1051
|
+
if not self.buffer:
|
|
1052
|
+
self.start_timer()
|
|
1053
|
+
return {"accepted": 0, "rejected": 0}
|
|
1054
|
+
|
|
1055
|
+
payloads_to_flush = list(self.buffer)
|
|
1056
|
+
self.buffer.clear()
|
|
1057
|
+
self.start_timer()
|
|
1058
|
+
|
|
1059
|
+
return self.client.log_batch(payloads_to_flush)
|
|
1060
|
+
|
|
1061
|
+
async def flush_async(self) -> dict[str, int]:
|
|
1062
|
+
with self.lock:
|
|
1063
|
+
self.stop_timer()
|
|
1064
|
+
if not self.buffer:
|
|
1065
|
+
self.start_timer()
|
|
1066
|
+
return {"accepted": 0, "rejected": 0}
|
|
1067
|
+
|
|
1068
|
+
payloads_to_flush = list(self.buffer)
|
|
1069
|
+
self.buffer.clear()
|
|
1070
|
+
self.start_timer()
|
|
1071
|
+
|
|
1072
|
+
return await self.client.log_batch_async(payloads_to_flush)
|
|
1073
|
+
|
|
1074
|
+
def size(self) -> int:
|
|
1075
|
+
with self.lock:
|
|
1076
|
+
return len(self.buffer)
|
|
1077
|
+
|
|
1078
|
+
def start_timer(self):
|
|
1079
|
+
if self.auto_flush_interval and not self.timer:
|
|
1080
|
+
self.timer = threading.Timer(self.auto_flush_interval, self._timer_flush)
|
|
1081
|
+
self.timer.daemon = True
|
|
1082
|
+
self.timer.start()
|
|
1083
|
+
|
|
1084
|
+
def stop_timer(self):
|
|
1085
|
+
if self.timer:
|
|
1086
|
+
self.timer.cancel()
|
|
1087
|
+
self.timer = None
|
|
1088
|
+
|
|
1089
|
+
def _timer_flush(self):
|
|
1090
|
+
try:
|
|
1091
|
+
self.flush()
|
|
1092
|
+
except Exception:
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def canonicalize(obj: Any) -> str:
|
|
1097
|
+
if obj is None:
|
|
1098
|
+
return "null"
|
|
1099
|
+
if isinstance(obj, (str, int, float, bool)):
|
|
1100
|
+
return json.dumps(obj, separators=(",", ":"))
|
|
1101
|
+
import datetime
|
|
1102
|
+
if isinstance(obj, (datetime.datetime, datetime.date)):
|
|
1103
|
+
if isinstance(obj, datetime.datetime):
|
|
1104
|
+
if obj.tzinfo is not None:
|
|
1105
|
+
obj = obj.astimezone(datetime.timezone.utc)
|
|
1106
|
+
else:
|
|
1107
|
+
obj = obj.replace(tzinfo=datetime.timezone.utc)
|
|
1108
|
+
val = obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
1109
|
+
else:
|
|
1110
|
+
val = obj.isoformat()
|
|
1111
|
+
return json.dumps(val)
|
|
1112
|
+
if isinstance(obj, list):
|
|
1113
|
+
items = []
|
|
1114
|
+
for item in obj:
|
|
1115
|
+
val = canonicalize(item)
|
|
1116
|
+
items.append("null" if val == "" else val)
|
|
1117
|
+
return "[" + ",".join(items) + "]"
|
|
1118
|
+
if isinstance(obj, dict):
|
|
1119
|
+
keys = sorted(obj.keys())
|
|
1120
|
+
pairs = []
|
|
1121
|
+
for k in keys:
|
|
1122
|
+
val = obj[k]
|
|
1123
|
+
if val is None:
|
|
1124
|
+
pairs.append(json.dumps(k, separators=(",", ":")) + ":null")
|
|
1125
|
+
else:
|
|
1126
|
+
pairs.append(json.dumps(k, separators=(",", ":")) + ":" + canonicalize(val))
|
|
1127
|
+
return "{" + ",".join(pairs) + "}"
|
|
1128
|
+
return json.dumps(str(obj))
|
|
1129
|
+
|