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,357 @@
|
|
|
1
|
+
"""Audit of reads (`keeper.auth.audit.reads`, design 13.6).
|
|
2
|
+
|
|
3
|
+
`AuditMiddleware` hands every successful read of an authenticated caller to `ReadAuditor`,
|
|
4
|
+
which applies the filters and the sampling and queues the record; a background task writes
|
|
5
|
+
the queue in batches to the read stream of the audit log (`audit_read_log`, hash chains of its
|
|
6
|
+
own: `lorebase_store.auth.audit`). Nothing waits for the database on the request path: when the
|
|
7
|
+
queue is full a record is dropped, and a batch that cannot be written is dropped; both are
|
|
8
|
+
counted in `lorebase.audit.reads.dropped` by reason and sensitivity. The last tenth of the queue
|
|
9
|
+
takes only records of sensitive reads, and a batch holding one is tried twice.
|
|
10
|
+
|
|
11
|
+
With `on_overflow: reject` nothing is dropped while the service runs: the middleware takes a
|
|
12
|
+
place in the queue (`reserve`) before the status line of a read leaves, and refuses the read
|
|
13
|
+
with 503 when there is none; a batch that fails is kept and retried every `flush_interval_s`,
|
|
14
|
+
and after `unready_after_failures` failures in a row `ReadAuditCheck` turns readiness failed
|
|
15
|
+
until a batch is written again. Only a shutdown can then lose queued records (counted).
|
|
16
|
+
|
|
17
|
+
Code that renders values reports reads of sensitive Attributes with `note_sensitive`
|
|
18
|
+
(`shown` to a cleared caller, `masked` otherwise); the middleware collects the notes of the
|
|
19
|
+
request (`collecting`) and such a read is recorded whatever the filters and the sampling. The
|
|
20
|
+
notes live in a context variable, so rendering code needs no request object; outside a request
|
|
21
|
+
they are ignored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import random
|
|
30
|
+
from collections.abc import Awaitable, Callable, Iterable, Iterator, Sequence
|
|
31
|
+
from contextvars import ContextVar
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
from opentelemetry import metrics
|
|
36
|
+
from opentelemetry.metrics import MeterProvider
|
|
37
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
38
|
+
|
|
39
|
+
from lorebase_keeper.auth.config import ReadAuditSettings
|
|
40
|
+
from lorebase_keeper.readiness import CheckResult, CheckStatus
|
|
41
|
+
from lorebase_store.auth import AuditRepository, AuditStream, NewAuditEntry
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("lorebase.keeper.audit")
|
|
44
|
+
|
|
45
|
+
SensitiveOutcome = Literal["shown", "masked"]
|
|
46
|
+
READ_ACTION = "read"
|
|
47
|
+
SENSITIVE_RESERVE = 10
|
|
48
|
+
"""1/SENSITIVE_RESERVE of the queue only takes records of sensitive reads."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sensitive(entry: NewAuditEntry) -> bool:
|
|
52
|
+
return bool(entry.details.get("sensitive"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ReadNotes:
|
|
57
|
+
"""What rendering code reported about the current request."""
|
|
58
|
+
|
|
59
|
+
sensitive: set[str] = field(default_factory=set)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_NOTES: ContextVar[ReadNotes | None] = ContextVar("lorebase_read_notes", default=None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def note_sensitive(outcome: SensitiveOutcome) -> None:
|
|
66
|
+
"""Report that the current request showed or masked a value of a sensitive Attribute."""
|
|
67
|
+
notes = _NOTES.get()
|
|
68
|
+
if notes is not None:
|
|
69
|
+
notes.sensitive.add(outcome)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def noted_sensitive() -> frozenset[str]:
|
|
73
|
+
"""What the current request reported so far (`note_sensitive`)."""
|
|
74
|
+
notes = _NOTES.get()
|
|
75
|
+
return frozenset(notes.sensitive) if notes is not None else frozenset()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def note_all(outcomes: Iterable[str]) -> None:
|
|
79
|
+
"""Report outcomes noted earlier (a cached body replays those of its first rendering)."""
|
|
80
|
+
notes = _NOTES.get()
|
|
81
|
+
if notes is not None:
|
|
82
|
+
notes.sensitive.update(outcomes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@contextlib.contextmanager
|
|
86
|
+
def collecting() -> Iterator[ReadNotes]:
|
|
87
|
+
"""Collect the notes of the code run inside the block."""
|
|
88
|
+
notes = ReadNotes()
|
|
89
|
+
token = _NOTES.set(notes)
|
|
90
|
+
try:
|
|
91
|
+
yield notes
|
|
92
|
+
finally:
|
|
93
|
+
_NOTES.reset(token)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
ReadSink = Callable[[Sequence[NewAuditEntry]], Awaitable[None]]
|
|
97
|
+
"""Writes one batch of read records."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def database_sink(sessions: async_sessionmaker[AsyncSession]) -> ReadSink:
|
|
101
|
+
"""A sink appending to `audit_read_log`, one transaction per batch."""
|
|
102
|
+
|
|
103
|
+
async def write(entries: Sequence[NewAuditEntry]) -> None:
|
|
104
|
+
async with sessions() as session, session.begin():
|
|
105
|
+
await AuditRepository(session, AuditStream.READS).append_many(entries)
|
|
106
|
+
|
|
107
|
+
return write
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ReadSlot:
|
|
111
|
+
"""A place in the queue held for the record of a read being answered (`ReadAuditor.reserve`).
|
|
112
|
+
`fill` queues the record in it, `release` gives it back; the first call wins."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, auditor: ReadAuditor) -> None:
|
|
115
|
+
self._auditor: ReadAuditor | None = auditor
|
|
116
|
+
|
|
117
|
+
def fill(self, entry: NewAuditEntry) -> None:
|
|
118
|
+
"""Queue the record in the held place."""
|
|
119
|
+
auditor, self._auditor = self._auditor, None
|
|
120
|
+
if auditor is not None:
|
|
121
|
+
auditor._reserved -= 1
|
|
122
|
+
auditor._queue.put_nowait(entry) # cannot fail: the place was counted as taken
|
|
123
|
+
|
|
124
|
+
def release(self) -> None:
|
|
125
|
+
"""Give the place back (the read is not recorded)."""
|
|
126
|
+
auditor, self._auditor = self._auditor, None
|
|
127
|
+
if auditor is not None:
|
|
128
|
+
auditor._reserved -= 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ReadAuditor:
|
|
132
|
+
"""Filters, samples, queues and writes read records (see the module doc)."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
settings: ReadAuditSettings,
|
|
137
|
+
sink: ReadSink,
|
|
138
|
+
*,
|
|
139
|
+
meter_provider: MeterProvider | None = None,
|
|
140
|
+
sample: Callable[[], float] = random.random,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._settings = settings
|
|
143
|
+
self._sink = sink
|
|
144
|
+
self._sample = sample
|
|
145
|
+
self._queue: asyncio.Queue[NewAuditEntry] = asyncio.Queue(maxsize=settings.queue_size)
|
|
146
|
+
self._task: asyncio.Task[None] | None = None
|
|
147
|
+
self._reserved = 0
|
|
148
|
+
self._in_flight: Sequence[NewAuditEntry] = ()
|
|
149
|
+
self._failures = 0
|
|
150
|
+
meter = (meter_provider or metrics.get_meter_provider()).get_meter("lorebase.keeper")
|
|
151
|
+
self._recorded = meter.create_counter(
|
|
152
|
+
"lorebase.audit.reads.recorded",
|
|
153
|
+
unit="{entry}",
|
|
154
|
+
description="Read records written to the read stream of the audit log.",
|
|
155
|
+
)
|
|
156
|
+
self._dropped = meter.create_counter(
|
|
157
|
+
"lorebase.audit.reads.dropped",
|
|
158
|
+
unit="{entry}",
|
|
159
|
+
description="Read records dropped, by reason (queue_full, write_failed).",
|
|
160
|
+
)
|
|
161
|
+
self._refused = meter.create_counter(
|
|
162
|
+
"lorebase.audit.reads.refused",
|
|
163
|
+
unit="{request}",
|
|
164
|
+
description="Reads refused with 503 because their record found no place in the "
|
|
165
|
+
"queue (on_overflow reject).",
|
|
166
|
+
)
|
|
167
|
+
self._write_failures = meter.create_counter(
|
|
168
|
+
"lorebase.audit.reads.write_failures",
|
|
169
|
+
unit="{batch}",
|
|
170
|
+
description="Failed attempts to write a batch of read records.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def rejects(self) -> bool:
|
|
175
|
+
"""Whether reads are refused rather than left unrecorded (`on_overflow: reject`)."""
|
|
176
|
+
return self._settings.on_overflow == "reject"
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def pending(self) -> int:
|
|
180
|
+
"""Records queued and places held, not yet handed to the writer."""
|
|
181
|
+
return self._queue.qsize() + self._reserved
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def healthy(self) -> bool:
|
|
185
|
+
"""False while batches keep failing (`unready_after_failures` attempts in a row)."""
|
|
186
|
+
return self._failures < self._settings.unready_after_failures
|
|
187
|
+
|
|
188
|
+
def wants(self, principal_type: str | None, kind: str | None, sensitive: bool) -> bool:
|
|
189
|
+
"""Whether a read is recorded: always when sensitive, else when it passes the
|
|
190
|
+
filters and the sampling."""
|
|
191
|
+
if sensitive:
|
|
192
|
+
return True
|
|
193
|
+
settings = self._settings
|
|
194
|
+
if settings.principal_types and principal_type not in settings.principal_types:
|
|
195
|
+
return False
|
|
196
|
+
if settings.kinds and kind not in settings.kinds:
|
|
197
|
+
return False
|
|
198
|
+
return settings.sample_rate >= 1 or self._sample() < settings.sample_rate
|
|
199
|
+
|
|
200
|
+
def _has_room(self, sensitive: bool) -> bool:
|
|
201
|
+
"""The last tenth of the queue is kept for sensitive reads, so a flood of ordinary reads
|
|
202
|
+
cannot crowd them out. Held places (`reserve`) count as taken."""
|
|
203
|
+
taken = self.pending
|
|
204
|
+
reserve = max(1, self._settings.queue_size // SENSITIVE_RESERVE)
|
|
205
|
+
return taken < self._settings.queue_size - (0 if sensitive else reserve)
|
|
206
|
+
|
|
207
|
+
def offer(self, entry: NewAuditEntry) -> bool:
|
|
208
|
+
"""Queue a record without waiting; false when it was dropped."""
|
|
209
|
+
self._ensure_running()
|
|
210
|
+
sensitive = _sensitive(entry)
|
|
211
|
+
if not self._has_room(sensitive):
|
|
212
|
+
self._dropped.add(1, {"reason": "queue_full", "sensitive": sensitive})
|
|
213
|
+
if sensitive:
|
|
214
|
+
logger.error("sensitive read record dropped: the read audit queue is full")
|
|
215
|
+
return False
|
|
216
|
+
self._queue.put_nowait(entry)
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
def reserve(self, sensitive: bool) -> ReadSlot | None:
|
|
220
|
+
"""Hold a place for the record of a read before answering it; None (counted) when the
|
|
221
|
+
queue has none. `sensitive`: the read showed or masked a sensitive value so far."""
|
|
222
|
+
self._ensure_running()
|
|
223
|
+
if not self._has_room(sensitive):
|
|
224
|
+
self._refused.add(1, {"sensitive": sensitive})
|
|
225
|
+
return None
|
|
226
|
+
self._reserved += 1
|
|
227
|
+
return ReadSlot(self)
|
|
228
|
+
|
|
229
|
+
def _ensure_running(self) -> None:
|
|
230
|
+
if self._task is None or self._task.done():
|
|
231
|
+
self._task = asyncio.get_running_loop().create_task(
|
|
232
|
+
self._run(), name="read-audit-writer"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def _next_batch(self) -> list[NewAuditEntry]:
|
|
236
|
+
"""Wait for a record, then take what arrives within the flush interval."""
|
|
237
|
+
batch = [await self._queue.get()]
|
|
238
|
+
loop = asyncio.get_running_loop()
|
|
239
|
+
deadline = loop.time() + self._settings.flush_interval_s
|
|
240
|
+
while len(batch) < self._settings.batch_size:
|
|
241
|
+
try:
|
|
242
|
+
batch.append(self._queue.get_nowait())
|
|
243
|
+
continue
|
|
244
|
+
except asyncio.QueueEmpty:
|
|
245
|
+
pass
|
|
246
|
+
remaining = deadline - loop.time()
|
|
247
|
+
if remaining <= 0:
|
|
248
|
+
break
|
|
249
|
+
try:
|
|
250
|
+
async with asyncio.timeout(remaining):
|
|
251
|
+
batch.append(await self._queue.get())
|
|
252
|
+
except TimeoutError:
|
|
253
|
+
break
|
|
254
|
+
return batch
|
|
255
|
+
|
|
256
|
+
async def _write(self, batch: Sequence[NewAuditEntry]) -> None:
|
|
257
|
+
if self.rejects:
|
|
258
|
+
await self._write_until_done(batch)
|
|
259
|
+
return
|
|
260
|
+
attempts = 2 if any(map(_sensitive, batch)) else 1 # sensitive reads get a retry
|
|
261
|
+
for attempt in range(attempts):
|
|
262
|
+
try:
|
|
263
|
+
async with asyncio.timeout(self._settings.write_timeout_s):
|
|
264
|
+
await self._sink(batch)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
self._write_failures.add(1)
|
|
267
|
+
if attempt + 1 < attempts:
|
|
268
|
+
await asyncio.sleep(self._settings.flush_interval_s)
|
|
269
|
+
continue
|
|
270
|
+
self._drop_batch(batch, exc)
|
|
271
|
+
return
|
|
272
|
+
self._recorded.add(len(batch))
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
async def _write_until_done(self, batch: Sequence[NewAuditEntry]) -> None:
|
|
276
|
+
"""Retry a failing batch every flush interval until it is written; past
|
|
277
|
+
`unready_after_failures` failures in a row the auditor reports itself unhealthy."""
|
|
278
|
+
while True:
|
|
279
|
+
try:
|
|
280
|
+
async with asyncio.timeout(self._settings.write_timeout_s):
|
|
281
|
+
await self._sink(batch)
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
self._write_failures.add(1)
|
|
284
|
+
self._failures += 1
|
|
285
|
+
logger.log(
|
|
286
|
+
logging.ERROR
|
|
287
|
+
if self._failures == self._settings.unready_after_failures
|
|
288
|
+
else logging.WARNING,
|
|
289
|
+
"read audit batch not written; retrying",
|
|
290
|
+
exc_info=not isinstance(exc, TimeoutError),
|
|
291
|
+
extra={
|
|
292
|
+
"records": len(batch),
|
|
293
|
+
"failures": self._failures,
|
|
294
|
+
"reason": type(exc).__name__,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
await asyncio.sleep(self._settings.flush_interval_s)
|
|
298
|
+
continue
|
|
299
|
+
self._failures = 0
|
|
300
|
+
self._recorded.add(len(batch))
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
def _drop_batch(self, batch: Sequence[NewAuditEntry], exc: Exception) -> None:
|
|
304
|
+
sensitive = sum(map(_sensitive, batch))
|
|
305
|
+
for flag, count in ((True, sensitive), (False, len(batch) - sensitive)):
|
|
306
|
+
if count:
|
|
307
|
+
self._dropped.add(count, {"reason": "write_failed", "sensitive": flag})
|
|
308
|
+
logger.error(
|
|
309
|
+
"read audit records dropped",
|
|
310
|
+
exc_info=not isinstance(exc, TimeoutError),
|
|
311
|
+
extra={"records": len(batch), "sensitive": sensitive, "reason": type(exc).__name__},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def _run(self) -> None:
|
|
315
|
+
while True:
|
|
316
|
+
batch = await self._next_batch()
|
|
317
|
+
self._in_flight = batch
|
|
318
|
+
await self._write(batch)
|
|
319
|
+
self._in_flight = ()
|
|
320
|
+
for _ in batch:
|
|
321
|
+
self._queue.task_done()
|
|
322
|
+
|
|
323
|
+
async def flush(self) -> None:
|
|
324
|
+
"""Wait until every queued record is written or dropped."""
|
|
325
|
+
if self._task is not None and not self._task.done():
|
|
326
|
+
await self._queue.join()
|
|
327
|
+
|
|
328
|
+
async def aclose(self) -> None:
|
|
329
|
+
"""Write what is queued (within one write timeout), then stop."""
|
|
330
|
+
with contextlib.suppress(TimeoutError):
|
|
331
|
+
async with asyncio.timeout(self._settings.write_timeout_s):
|
|
332
|
+
await self.flush()
|
|
333
|
+
task, self._task = self._task, None
|
|
334
|
+
if task is not None:
|
|
335
|
+
task.cancel()
|
|
336
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
337
|
+
await task
|
|
338
|
+
lost = [*self._in_flight]
|
|
339
|
+
self._in_flight = ()
|
|
340
|
+
while not self._queue.empty():
|
|
341
|
+
lost.append(self._queue.get_nowait())
|
|
342
|
+
for entry in lost:
|
|
343
|
+
self._dropped.add(1, {"reason": "shutdown", "sensitive": _sensitive(entry)})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ReadAuditCheck:
|
|
347
|
+
"""Readiness of the read audit: failed while batches of read records keep failing."""
|
|
348
|
+
|
|
349
|
+
name = "read_audit"
|
|
350
|
+
|
|
351
|
+
def __init__(self, auditor: ReadAuditor) -> None:
|
|
352
|
+
self._auditor = auditor
|
|
353
|
+
|
|
354
|
+
async def __call__(self) -> CheckResult:
|
|
355
|
+
if self._auditor.healthy:
|
|
356
|
+
return CheckResult(CheckStatus.OK)
|
|
357
|
+
return CheckResult(CheckStatus.FAILED, "read records cannot be written")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The auth components of one Keeper application, built from settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from opentelemetry.metrics import MeterProvider
|
|
12
|
+
|
|
13
|
+
from lorebase_keeper.auth.api_keys import ApiKeyCodec
|
|
14
|
+
from lorebase_keeper.auth.audit import AuditWriter
|
|
15
|
+
from lorebase_keeper.auth.config import AuthSettings
|
|
16
|
+
from lorebase_keeper.auth.oidc import JwksCache, OidcVerifier
|
|
17
|
+
from lorebase_keeper.auth.ports import AuthStore
|
|
18
|
+
from lorebase_keeper.auth.principals import Authenticator
|
|
19
|
+
from lorebase_keeper.auth.provider import ProviderClient
|
|
20
|
+
from lorebase_keeper.auth.rbac import Authorizer
|
|
21
|
+
from lorebase_keeper.auth.sessions import SessionManager
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("lorebase.keeper.auth")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AuthRuntime:
|
|
28
|
+
"""Authenticator, authorizer, key codec, audit writer, browser sessions and store of an
|
|
29
|
+
application."""
|
|
30
|
+
|
|
31
|
+
settings: AuthSettings
|
|
32
|
+
store: AuthStore | None
|
|
33
|
+
authenticator: Authenticator
|
|
34
|
+
authorizer: Authorizer
|
|
35
|
+
codec: ApiKeyCodec | None
|
|
36
|
+
audit_writer: AuditWriter | None
|
|
37
|
+
clock: Callable[[], datetime]
|
|
38
|
+
sessions: SessionManager | None = None
|
|
39
|
+
http: httpx.AsyncClient | None = None
|
|
40
|
+
owns_http: bool = False
|
|
41
|
+
|
|
42
|
+
async def aclose(self) -> None:
|
|
43
|
+
"""Release the HTTP client when this runtime created it."""
|
|
44
|
+
if self.http is not None and self.owns_http:
|
|
45
|
+
await self.http.aclose()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_runtime(
|
|
49
|
+
settings: AuthSettings,
|
|
50
|
+
*,
|
|
51
|
+
store: AuthStore | None,
|
|
52
|
+
http_client: httpx.AsyncClient | None = None,
|
|
53
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
54
|
+
meter_provider: MeterProvider | None = None,
|
|
55
|
+
) -> AuthRuntime:
|
|
56
|
+
"""Wire the auth components for `settings`.
|
|
57
|
+
|
|
58
|
+
`store` is required for every mode but `none`; with `none` it only enables the audit log.
|
|
59
|
+
`http_client` is used for identity provider requests (tests inject a mock transport); when
|
|
60
|
+
absent one is created with the configured timeout.
|
|
61
|
+
"""
|
|
62
|
+
if settings.mode == "none":
|
|
63
|
+
logger.warning(
|
|
64
|
+
"authentication is disabled (keeper.auth.mode: none): every request is accepted "
|
|
65
|
+
"as an administrator. Use this for local development only."
|
|
66
|
+
)
|
|
67
|
+
elif store is None:
|
|
68
|
+
raise ValueError(f"keeper.auth.mode {settings.mode!r} needs a database")
|
|
69
|
+
|
|
70
|
+
codec = ApiKeyCodec(settings.api_keys) if settings.uses_api_keys else None
|
|
71
|
+
http = http_client
|
|
72
|
+
owns_http = False
|
|
73
|
+
verifier: OidcVerifier | None = None
|
|
74
|
+
if settings.uses_oidc:
|
|
75
|
+
if http is None:
|
|
76
|
+
http = httpx.AsyncClient(timeout=settings.oidc.http_timeout_s)
|
|
77
|
+
owns_http = True
|
|
78
|
+
verifier = OidcVerifier(settings.oidc, JwksCache(settings.oidc, http))
|
|
79
|
+
sessions: SessionManager | None = None
|
|
80
|
+
if settings.sessions.enabled and verifier and http and store is not None:
|
|
81
|
+
sessions = SessionManager(
|
|
82
|
+
settings.sessions,
|
|
83
|
+
store=store,
|
|
84
|
+
provider=ProviderClient(settings.sessions, settings.oidc, http),
|
|
85
|
+
verifier=verifier,
|
|
86
|
+
clock=clock,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
audit_writer = (
|
|
90
|
+
AuditWriter(store, settings.audit, meter_provider)
|
|
91
|
+
if store is not None and settings.audit.enabled
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
return AuthRuntime(
|
|
95
|
+
settings=settings,
|
|
96
|
+
store=store,
|
|
97
|
+
authenticator=Authenticator(
|
|
98
|
+
settings,
|
|
99
|
+
store=store,
|
|
100
|
+
codec=codec,
|
|
101
|
+
oidc=verifier,
|
|
102
|
+
sessions=sessions,
|
|
103
|
+
clock=clock,
|
|
104
|
+
meter_provider=meter_provider,
|
|
105
|
+
),
|
|
106
|
+
authorizer=Authorizer(settings.authz, store=store),
|
|
107
|
+
codec=codec,
|
|
108
|
+
audit_writer=audit_writer,
|
|
109
|
+
clock=clock,
|
|
110
|
+
sessions=sessions,
|
|
111
|
+
http=http,
|
|
112
|
+
owns_http=owns_http,
|
|
113
|
+
)
|