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,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)