lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Audit of mutating and denied requests (design 10.4).
|
|
2
|
+
|
|
3
|
+
`AuditMiddleware` wraps the application. For every request it creates an `AuditDraft` in the
|
|
4
|
+
ASGI scope state; dependencies and routes fill it in (`annotate`): who called, which Domain,
|
|
5
|
+
a precise action name and target. When the response is complete the middleware writes one
|
|
6
|
+
audit entry if the request mutated state (POST, PUT, PATCH, DELETE by an authenticated caller)
|
|
7
|
+
or was denied (401, 403 when `audit.record_denied`).
|
|
8
|
+
|
|
9
|
+
Writing happens after the response has been produced and never changes it: a failed or slow
|
|
10
|
+
write is logged, counted in the `lorebase.audit.write_failures` metric, and dropped. Request
|
|
11
|
+
bodies, headers and credentials are never recorded.
|
|
12
|
+
|
|
13
|
+
With a `ReadAuditor` (`audit.reads.enabled`), successful reads (GET and HEAD, and the POST
|
|
14
|
+
routes that only read: `keeper.limits.rate.routes`) of an authenticated caller are handed to
|
|
15
|
+
it, with the sensitive values the request showed or masked (`read_audit.collecting`). When the
|
|
16
|
+
auditor rejects (`on_overflow: reject`), the place of the record is taken when the status line
|
|
17
|
+
of such a read passes, before any of the body; without one the response is replaced by a 503
|
|
18
|
+
problem with `Retry-After`, the rest of the application's response is discarded and its
|
|
19
|
+
`receive` reports a disconnect, so a streamed body stops.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from opentelemetry import metrics
|
|
32
|
+
from opentelemetry.metrics import MeterProvider
|
|
33
|
+
from starlette.requests import HTTPConnection
|
|
34
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
35
|
+
|
|
36
|
+
from lorebase_keeper.auth.config import AuditSettings
|
|
37
|
+
from lorebase_keeper.auth.ports import AuthStore
|
|
38
|
+
from lorebase_keeper.auth.read_audit import (
|
|
39
|
+
READ_ACTION,
|
|
40
|
+
ReadAuditor,
|
|
41
|
+
ReadNotes,
|
|
42
|
+
ReadSlot,
|
|
43
|
+
collecting,
|
|
44
|
+
)
|
|
45
|
+
from lorebase_keeper.errors import ProblemRenderer, ProblemType, request_id_of
|
|
46
|
+
from lorebase_store.auth import NewAuditEntry
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("lorebase.keeper.audit")
|
|
49
|
+
|
|
50
|
+
STATE_KEY = "audit"
|
|
51
|
+
MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
52
|
+
DENIED_STATUSES = frozenset({401, 403})
|
|
53
|
+
ANONYMOUS_ACTOR = "anonymous"
|
|
54
|
+
READ_AUDIT_UNAVAILABLE = ProblemType(
|
|
55
|
+
"read-audit-unavailable", 503, "Read cannot be audited right now"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AuditDraft:
|
|
61
|
+
"""What is known about the request so far."""
|
|
62
|
+
|
|
63
|
+
actor: str | None = None
|
|
64
|
+
domain_id: str | None = None
|
|
65
|
+
action: str | None = None
|
|
66
|
+
target: str | None = None
|
|
67
|
+
permission: str | None = None
|
|
68
|
+
principal_type: str | None = None
|
|
69
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
suppressed: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def draft_of(connection: HTTPConnection) -> AuditDraft | None:
|
|
74
|
+
"""The request's audit draft, if the audit middleware is installed."""
|
|
75
|
+
state: MutableMapping[str, Any] = connection.scope.get("state") or {}
|
|
76
|
+
draft = state.get(STATE_KEY)
|
|
77
|
+
return draft if isinstance(draft, AuditDraft) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def annotate(
|
|
81
|
+
connection: HTTPConnection,
|
|
82
|
+
*,
|
|
83
|
+
actor: str | None = None,
|
|
84
|
+
domain_id: str | None = None,
|
|
85
|
+
action: str | None = None,
|
|
86
|
+
target: str | None = None,
|
|
87
|
+
permission: str | None = None,
|
|
88
|
+
principal_type: str | None = None,
|
|
89
|
+
**details: Any,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Add facts to the request's audit entry. Later calls overwrite earlier values."""
|
|
92
|
+
draft = draft_of(connection)
|
|
93
|
+
if draft is None:
|
|
94
|
+
return
|
|
95
|
+
for name, value in (
|
|
96
|
+
("actor", actor),
|
|
97
|
+
("domain_id", domain_id),
|
|
98
|
+
("action", action),
|
|
99
|
+
("target", target),
|
|
100
|
+
("permission", permission),
|
|
101
|
+
("principal_type", principal_type),
|
|
102
|
+
):
|
|
103
|
+
if value is not None:
|
|
104
|
+
setattr(draft, name, value)
|
|
105
|
+
draft.details.update(details)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def suppress(connection: HTTPConnection) -> None:
|
|
109
|
+
"""Leave the request out of the audit log (throttled requests are reported in aggregate)."""
|
|
110
|
+
draft = draft_of(connection)
|
|
111
|
+
if draft is not None:
|
|
112
|
+
draft.suppressed = True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def outcome_of(status: int) -> str:
|
|
116
|
+
"""Coarse result recorded with each entry."""
|
|
117
|
+
if status in DENIED_STATUSES:
|
|
118
|
+
return "denied"
|
|
119
|
+
if status >= 500:
|
|
120
|
+
return "error"
|
|
121
|
+
return "rejected" if status >= 400 else "success"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AuditWriter:
|
|
125
|
+
"""Writes entries through the store; failures are logged and counted, not raised."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
store: AuthStore,
|
|
130
|
+
settings: AuditSettings,
|
|
131
|
+
meter_provider: MeterProvider | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self._store = store
|
|
134
|
+
self._timeout_s = settings.write_timeout_s
|
|
135
|
+
meter = (meter_provider or metrics.get_meter_provider()).get_meter("lorebase.keeper")
|
|
136
|
+
self._failures = meter.create_counter(
|
|
137
|
+
"lorebase.audit.write_failures",
|
|
138
|
+
unit="{entry}",
|
|
139
|
+
description="Audit entries that could not be written.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def write(self, entry: NewAuditEntry) -> bool:
|
|
143
|
+
"""Append an entry; false when it was dropped."""
|
|
144
|
+
try:
|
|
145
|
+
async with asyncio.timeout(self._timeout_s):
|
|
146
|
+
async with self._store.unit() as unit:
|
|
147
|
+
await unit.audit.append(entry)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
self._failures.add(1, {"reason": type(exc).__name__})
|
|
150
|
+
logger.error(
|
|
151
|
+
"audit entry dropped",
|
|
152
|
+
exc_info=True,
|
|
153
|
+
extra={"action": entry.action, "actor": entry.actor},
|
|
154
|
+
)
|
|
155
|
+
return False
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
ReadClassifier = Callable[[str, str], bool]
|
|
160
|
+
"""Whether a request (method, route template without the API prefix) only reads."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AuditMiddleware:
|
|
164
|
+
"""Records one audit entry per mutating or denied request, and hands reads to the read
|
|
165
|
+
auditor when there is one."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
app: ASGIApp,
|
|
170
|
+
*,
|
|
171
|
+
writer: AuditWriter,
|
|
172
|
+
settings: AuditSettings,
|
|
173
|
+
api_prefix: str = "",
|
|
174
|
+
renderer: ProblemRenderer,
|
|
175
|
+
reads: ReadAuditor | None = None,
|
|
176
|
+
is_read: ReadClassifier | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
self.app = app
|
|
179
|
+
self._writer = writer
|
|
180
|
+
self._record_denied = settings.record_denied
|
|
181
|
+
self._retry_after_s = settings.reads.retry_after_s
|
|
182
|
+
self._renderer = renderer
|
|
183
|
+
self._api_prefix = api_prefix
|
|
184
|
+
self._reads = reads
|
|
185
|
+
self._is_read = is_read
|
|
186
|
+
|
|
187
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
188
|
+
if scope["type"] != "http":
|
|
189
|
+
await self.app(scope, receive, send)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
draft = AuditDraft()
|
|
193
|
+
scope.setdefault("state", {})[STATE_KEY] = draft
|
|
194
|
+
status = 500
|
|
195
|
+
reads = self._reads if self._reads is not None and self._reads.rejects else None
|
|
196
|
+
slot: ReadSlot | None = None
|
|
197
|
+
refused = False
|
|
198
|
+
|
|
199
|
+
async def send_and_note(message: Message) -> None:
|
|
200
|
+
nonlocal status, slot, refused
|
|
201
|
+
if refused:
|
|
202
|
+
return # the 503 is out: the rest of the response is discarded
|
|
203
|
+
if message["type"] == "http.response.start":
|
|
204
|
+
status = message["status"]
|
|
205
|
+
if reads is not None and self._read_candidate(scope, draft, status):
|
|
206
|
+
slot = reads.reserve(bool(notes.sensitive))
|
|
207
|
+
if slot is None:
|
|
208
|
+
refused = True
|
|
209
|
+
await self._refuse(scope, send)
|
|
210
|
+
return
|
|
211
|
+
await send(message)
|
|
212
|
+
|
|
213
|
+
async def receive_until_refused() -> Message:
|
|
214
|
+
if refused:
|
|
215
|
+
return {"type": "http.disconnect"}
|
|
216
|
+
return await receive()
|
|
217
|
+
|
|
218
|
+
with collecting() as notes:
|
|
219
|
+
try:
|
|
220
|
+
await self.app(scope, receive_until_refused, send_and_note)
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
entry = None if refused else self._entry(scope, draft, status, notes)
|
|
224
|
+
if entry is not None:
|
|
225
|
+
# Shielded: a client disconnect must not cancel the write half way.
|
|
226
|
+
await asyncio.shield(self._writer.write(entry))
|
|
227
|
+
elif self._reads is not None and not refused:
|
|
228
|
+
self._offer_read(scope, draft, status, notes, slot)
|
|
229
|
+
finally:
|
|
230
|
+
if slot is not None:
|
|
231
|
+
slot.release() # no-op once filled
|
|
232
|
+
|
|
233
|
+
async def _refuse(self, scope: Scope, send: Send) -> None:
|
|
234
|
+
"""Answer 503 in place of a read whose record found no place in the queue."""
|
|
235
|
+
response = self._renderer.response(
|
|
236
|
+
READ_AUDIT_UNAVAILABLE,
|
|
237
|
+
detail="The read could not be recorded in the audit log; retry later.",
|
|
238
|
+
instance=str(scope["path"]),
|
|
239
|
+
request_id=request_id_of(scope.get("state")),
|
|
240
|
+
headers={"Retry-After": str(self._retry_after_s)},
|
|
241
|
+
)
|
|
242
|
+
await response(scope, _no_receive, send)
|
|
243
|
+
|
|
244
|
+
def _read_candidate(self, scope: Scope, draft: AuditDraft, status: int) -> bool:
|
|
245
|
+
"""Whether the response is a successful read of an authenticated caller that the read
|
|
246
|
+
auditor may record (before the filters and the sampling)."""
|
|
247
|
+
if draft.suppressed or draft.actor is None or not 200 <= status < 300:
|
|
248
|
+
return False
|
|
249
|
+
if self._entry(scope, draft, status) is not None:
|
|
250
|
+
return False # recorded in the audit log of actions
|
|
251
|
+
route = self._route_template(scope)
|
|
252
|
+
relative = route[len(self._api_prefix) :] if route.startswith(self._api_prefix) else route
|
|
253
|
+
return self._is_read is not None and self._is_read(scope["method"], relative or "/")
|
|
254
|
+
|
|
255
|
+
def _offer_read(
|
|
256
|
+
self,
|
|
257
|
+
scope: Scope,
|
|
258
|
+
draft: AuditDraft,
|
|
259
|
+
status: int,
|
|
260
|
+
notes: ReadNotes,
|
|
261
|
+
slot: ReadSlot | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Hand a successful read of an authenticated caller to the read auditor, in the place
|
|
264
|
+
held for it when there is one."""
|
|
265
|
+
reads = self._reads
|
|
266
|
+
if reads is None or not self._read_candidate(scope, draft, status):
|
|
267
|
+
return # a held place is given back by the caller
|
|
268
|
+
route = self._route_template(scope)
|
|
269
|
+
kind = _path_param(scope, "kind")
|
|
270
|
+
sensitive = sorted(notes.sensitive)
|
|
271
|
+
if not reads.wants(draft.principal_type, kind, bool(sensitive)):
|
|
272
|
+
return
|
|
273
|
+
details: dict[str, Any] = {
|
|
274
|
+
"outcome": "success",
|
|
275
|
+
"status": status,
|
|
276
|
+
"method": scope["method"],
|
|
277
|
+
"route": route,
|
|
278
|
+
}
|
|
279
|
+
if draft.principal_type:
|
|
280
|
+
details["principal_type"] = draft.principal_type
|
|
281
|
+
if kind:
|
|
282
|
+
details["kind"] = kind
|
|
283
|
+
if sensitive:
|
|
284
|
+
details["sensitive"] = sensitive
|
|
285
|
+
if draft.permission:
|
|
286
|
+
details["permission"] = draft.permission
|
|
287
|
+
if request_id := request_id_of(scope.get("state")):
|
|
288
|
+
details["request_id"] = request_id
|
|
289
|
+
entry = NewAuditEntry(
|
|
290
|
+
actor=draft.actor or ANONYMOUS_ACTOR,
|
|
291
|
+
action=READ_ACTION,
|
|
292
|
+
domain_id=draft.domain_id or _path_param(scope, "domain_id"),
|
|
293
|
+
target=draft.target or kind,
|
|
294
|
+
details=details,
|
|
295
|
+
at=datetime.now(UTC),
|
|
296
|
+
)
|
|
297
|
+
if slot is not None:
|
|
298
|
+
slot.fill(entry)
|
|
299
|
+
else:
|
|
300
|
+
reads.offer(entry)
|
|
301
|
+
|
|
302
|
+
def _route_template(self, scope: Scope) -> str:
|
|
303
|
+
"""Route path with its parameters unexpanded, including the API prefix."""
|
|
304
|
+
path = getattr(scope.get("route"), "path", None)
|
|
305
|
+
if not isinstance(path, str):
|
|
306
|
+
return str(scope["path"])
|
|
307
|
+
under_prefix = self._api_prefix and str(scope["path"]).startswith(self._api_prefix)
|
|
308
|
+
return (
|
|
309
|
+
self._api_prefix + path
|
|
310
|
+
if under_prefix and not path.startswith(self._api_prefix)
|
|
311
|
+
else path
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _entry(
|
|
315
|
+
self, scope: Scope, draft: AuditDraft, status: int, notes: ReadNotes | None = None
|
|
316
|
+
) -> NewAuditEntry | None:
|
|
317
|
+
if draft.suppressed:
|
|
318
|
+
return None
|
|
319
|
+
method = scope["method"]
|
|
320
|
+
denied = status in DENIED_STATUSES
|
|
321
|
+
recorded_mutation = method in MUTATING_METHODS and draft.actor is not None
|
|
322
|
+
recorded_denial = denied and self._record_denied
|
|
323
|
+
if not (recorded_mutation or recorded_denial):
|
|
324
|
+
return None
|
|
325
|
+
route = self._route_template(scope)
|
|
326
|
+
domain_id = draft.domain_id or _path_param(scope, "domain_id")
|
|
327
|
+
details: dict[str, Any] = {
|
|
328
|
+
"outcome": outcome_of(status),
|
|
329
|
+
"status": status,
|
|
330
|
+
"method": method,
|
|
331
|
+
"route": route,
|
|
332
|
+
**draft.details,
|
|
333
|
+
}
|
|
334
|
+
if draft.permission:
|
|
335
|
+
details["permission"] = draft.permission
|
|
336
|
+
if notes is not None and notes.sensitive:
|
|
337
|
+
# A write may answer with values too (Operation edits): what it showed or masked.
|
|
338
|
+
details["sensitive"] = sorted(notes.sensitive)
|
|
339
|
+
if request_id := request_id_of(scope.get("state")):
|
|
340
|
+
details["request_id"] = request_id
|
|
341
|
+
return NewAuditEntry(
|
|
342
|
+
actor=draft.actor or ANONYMOUS_ACTOR,
|
|
343
|
+
action=draft.action or f"{method} {route}",
|
|
344
|
+
domain_id=domain_id,
|
|
345
|
+
target=draft.target,
|
|
346
|
+
details=details,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _no_receive() -> Message:
|
|
351
|
+
return {"type": "http.disconnect"}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _path_param(scope: Mapping[str, Any], name: str) -> str | None:
|
|
355
|
+
value = (scope.get("path_params") or {}).get(name)
|
|
356
|
+
return value if isinstance(value, str) else None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""A small in-process cache with a time to live."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TtlCache[K, V]:
|
|
11
|
+
"""Entries expire `ttl_s` after being stored; the oldest are dropped beyond `max_entries`.
|
|
12
|
+
|
|
13
|
+
A `ttl_s` of 0 stores nothing. Not shared between processes: another process sees a
|
|
14
|
+
change after at most one time to live.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
ttl_s: float,
|
|
20
|
+
max_entries: int,
|
|
21
|
+
clock: Callable[[], float] = time.monotonic,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._ttl = ttl_s
|
|
24
|
+
self._max = max_entries
|
|
25
|
+
self._clock = clock
|
|
26
|
+
self._entries: OrderedDict[K, tuple[float, V]] = OrderedDict()
|
|
27
|
+
|
|
28
|
+
def get(self, key: K) -> V | None:
|
|
29
|
+
"""The stored value, or None when absent or expired."""
|
|
30
|
+
entry = self._entries.get(key)
|
|
31
|
+
if entry is None:
|
|
32
|
+
return None
|
|
33
|
+
expires, value = entry
|
|
34
|
+
if expires <= self._clock():
|
|
35
|
+
del self._entries[key]
|
|
36
|
+
return None
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
def set(self, key: K, value: V) -> None:
|
|
40
|
+
"""Store a value."""
|
|
41
|
+
if self._ttl <= 0:
|
|
42
|
+
return
|
|
43
|
+
self._entries[key] = (self._clock() + self._ttl, value)
|
|
44
|
+
self._entries.move_to_end(key)
|
|
45
|
+
while len(self._entries) > self._max:
|
|
46
|
+
self._entries.popitem(last=False)
|
|
47
|
+
|
|
48
|
+
def clear(self) -> None:
|
|
49
|
+
"""Forget everything (after a local change)."""
|
|
50
|
+
self._entries.clear()
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
return len(self._entries)
|