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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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
+ )