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
lorebase_keeper/app.py ADDED
@@ -0,0 +1,373 @@
1
+ """Keeper application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import AsyncIterator, Callable, Sequence
7
+ from contextlib import asynccontextmanager
8
+ from datetime import UTC, datetime
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from fastapi import Depends, FastAPI
13
+ from starlette.middleware.cors import CORSMiddleware
14
+
15
+ from lorebase_keeper import __version__
16
+ from lorebase_keeper.auth.audit import AuditMiddleware
17
+ from lorebase_keeper.auth.config import AuditSettings
18
+ from lorebase_keeper.auth.ports import AuthStore
19
+ from lorebase_keeper.auth.read_audit import (
20
+ ReadAuditCheck,
21
+ ReadAuditor,
22
+ ReadSink,
23
+ database_sink,
24
+ )
25
+ from lorebase_keeper.auth.runtime import build_runtime
26
+ from lorebase_keeper.client_address import ClientAddresses
27
+ from lorebase_keeper.client_address import install as install_client_addresses
28
+ from lorebase_keeper.db import Database, create_database
29
+ from lorebase_keeper.errors import Problem, ProblemRenderer, install_error_handlers
30
+ from lorebase_keeper.health import router as health_router
31
+ from lorebase_keeper.middleware import (
32
+ BodyLimitMiddleware,
33
+ RequestContextMiddleware,
34
+ SecurityHeadersMiddleware,
35
+ )
36
+ from lorebase_keeper.ratelimit.enforce import (
37
+ EXPOSED_HEADERS,
38
+ RateLimitHeadersMiddleware,
39
+ build_limits,
40
+ enforce_limits,
41
+ )
42
+ from lorebase_keeper.readiness import (
43
+ CheckStatus,
44
+ MigrationHeads,
45
+ ReadinessCheck,
46
+ ReplicaCheck,
47
+ SearchBackendCheck,
48
+ default_checks,
49
+ packaged_heads,
50
+ run_checks,
51
+ )
52
+ from lorebase_keeper.reading.settings import search_settings
53
+ from lorebase_keeper.routes.admin import router as admin_router
54
+ from lorebase_keeper.routes.documents import document_upload_path
55
+ from lorebase_keeper.routes.domain_api import domain_routers
56
+ from lorebase_keeper.routes.sessions import router as sessions_router
57
+ from lorebase_keeper.routes.trace import router as trace_router
58
+ from lorebase_keeper.routes.whoami import router as whoami_router
59
+ from lorebase_keeper.settings import (
60
+ REQUIRED_CORS_REQUEST_HEADERS,
61
+ REQUIRED_CORS_RESPONSE_HEADERS,
62
+ KeeperSettings,
63
+ keeper_settings,
64
+ )
65
+ from lorebase_keeper.tracing.config import TraceSettings
66
+ from lorebase_keeper.tracing.middleware import TraceMiddleware
67
+ from lorebase_keeper.tracing.recorder import TraceRecorder, TraceSink
68
+ from lorebase_keeper.tracing.recorder import database_sink as trace_database_sink
69
+ from lorebase_platform.settings import ConfigError, Settings, load_settings
70
+ from lorebase_platform.telemetry import Telemetry
71
+ from lorebase_store.settings import store_settings
72
+
73
+ logger = logging.getLogger("lorebase.keeper")
74
+
75
+ _DEFAULT_RESPONSES: dict[int | str, dict[str, Any]] = {
76
+ "default": {
77
+ "model": Problem,
78
+ "description": "Problem details (RFC 9457), media type application/problem+json.",
79
+ }
80
+ }
81
+
82
+
83
+ def _merge_headers(*names: str) -> list[str]:
84
+ """Header names in order, each once (compared without case)."""
85
+ seen: dict[str, str] = {}
86
+ for name in names:
87
+ seen.setdefault(name.lower(), name)
88
+ return list(seen.values())
89
+
90
+
91
+ def cors_request_headers(keeper: KeeperSettings) -> list[str]:
92
+ """Request headers allowed cross-origin: the required ones, then `keeper.cors.allow_headers`.
93
+
94
+ The agent trace headers are allowed even with tracing off: clients send them regardless,
95
+ and a refused preflight would fail the whole request.
96
+ """
97
+ return _merge_headers(
98
+ *REQUIRED_CORS_REQUEST_HEADERS,
99
+ keeper.request_id_header,
100
+ *([keeper.auth.sessions.csrf_header] if keeper.auth.sessions.enabled else []),
101
+ keeper.trace.session_header,
102
+ keeper.trace.tool_header,
103
+ keeper.trace.opt_in_header,
104
+ keeper.trace.client_header,
105
+ *keeper.cors.allow_headers,
106
+ )
107
+
108
+
109
+ def cors_response_headers(keeper: KeeperSettings) -> list[str]:
110
+ """Response headers readable cross-origin: the required ones, then
111
+ `keeper.cors.expose_headers`."""
112
+ return _merge_headers(
113
+ *REQUIRED_CORS_RESPONSE_HEADERS,
114
+ keeper.request_id_header,
115
+ *EXPOSED_HEADERS,
116
+ *keeper.cors.expose_headers,
117
+ )
118
+
119
+
120
+ def create_app(
121
+ settings: Settings | None = None,
122
+ *,
123
+ telemetry: Telemetry | None = None,
124
+ readiness_checks: Sequence[ReadinessCheck] | None = None,
125
+ migration_heads: MigrationHeads | None = None,
126
+ auth_store: AuthStore | None = None,
127
+ http_client: httpx.AsyncClient | None = None,
128
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
129
+ read_audit_sink: ReadSink | None = None,
130
+ trace_sink: TraceSink | None = None,
131
+ ) -> FastAPI:
132
+ """Build the Keeper ASGI application.
133
+
134
+ `settings` default to `load_settings()`. `telemetry` supplies the providers used for
135
+ spans and metrics (none: the global OpenTelemetry API). `readiness_checks` replaces the
136
+ default checks, which probe the database named by the settings; `migration_heads` tells
137
+ that probe which Alembic revisions the code expects (default: the heads of the store's
138
+ migrations; a callable returning nothing skips the comparison).
139
+
140
+ Authentication needs a database: the `database` section (name from
141
+ `keeper.readiness.database_section`), or `auth_store` when given. With `keeper.auth.mode:
142
+ none` the database is optional and only enables the audit log. `http_client` serves
143
+ identity provider requests (default: a client built from the OIDC settings) and `clock` the
144
+ expiry checks. `read_audit_sink` replaces the database as the writer of read records
145
+ (`keeper.auth.audit.reads`) and `trace_sink` as the writer of agent trace steps
146
+ (`keeper.trace`).
147
+ """
148
+ settings = settings or load_settings()
149
+ keeper = keeper_settings(settings)
150
+ renderer = ProblemRenderer(keeper.problem_type_base)
151
+ checks = (
152
+ list(readiness_checks)
153
+ if readiness_checks is not None
154
+ else default_checks(
155
+ settings,
156
+ section=keeper.readiness.database_section,
157
+ dsn_key=keeper.readiness.database_dsn_key,
158
+ timeout_s=keeper.readiness.timeout_s,
159
+ expected_heads=migration_heads or packaged_heads,
160
+ )
161
+ )
162
+
163
+ database = (
164
+ create_database(
165
+ settings,
166
+ keeper.readiness.database_section,
167
+ meter_provider=telemetry.meter_provider if telemetry else None,
168
+ )
169
+ if auth_store is None
170
+ else None
171
+ )
172
+ replicas = database.reads.replicas if database and database.reads else None
173
+ if readiness_checks is None and database is not None and database.reads and replicas:
174
+ checks.append(
175
+ ReplicaCheck(
176
+ replicas,
177
+ timeout_s=database.reads.probe_timeout_s,
178
+ max_lag_ms=database.reads.max_lag_ms,
179
+ required=keeper.reads.prefer_replica and not database.reads.fallback_to_primary,
180
+ )
181
+ )
182
+ # search.backend: bm25 falls back to the default ranking while the database cannot serve
183
+ # it; /readyz reports that as degraded, and the start logs it.
184
+ search_check = None
185
+ if database is not None and search_settings(settings).backend == "bm25":
186
+ definer = store_settings(settings).reads.use_definer_functions
187
+ search_check = SearchBackendCheck(database.sessions, definer=definer)
188
+ if readiness_checks is None:
189
+ checks.append(search_check)
190
+ store = auth_store or (database.auth_store if database else None)
191
+ if keeper.auth.mode != "none" and store is None:
192
+ raise ConfigError(
193
+ [
194
+ f"keeper.auth.mode: {keeper.auth.mode!r} needs a database; configure the "
195
+ f"{keeper.readiness.database_section!r} section"
196
+ ]
197
+ )
198
+ auth = build_runtime(
199
+ keeper.auth,
200
+ store=store,
201
+ http_client=http_client,
202
+ clock=clock,
203
+ meter_provider=telemetry.meter_provider if telemetry else None,
204
+ )
205
+
206
+ limits = build_limits(
207
+ keeper.limits.rate,
208
+ keeper.limits.quota,
209
+ auth,
210
+ sessions=database.sessions if database is not None else None,
211
+ meter_provider=telemetry.meter_provider if telemetry else None,
212
+ )
213
+
214
+ reads = _read_auditor(
215
+ keeper.auth.audit, auth.audit_writer is not None, database, read_audit_sink, telemetry
216
+ )
217
+ if reads is not None and reads.rejects:
218
+ checks.append(ReadAuditCheck(reads))
219
+ traces = _trace_recorder(keeper.trace, database, trace_sink, telemetry)
220
+
221
+ @asynccontextmanager
222
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
223
+ if search_check is not None:
224
+ found = (await run_checks([search_check], keeper.readiness.timeout_s))[
225
+ search_check.name
226
+ ]
227
+ if found.status is not CheckStatus.OK:
228
+ logger.warning("search backend: %s", found.detail)
229
+ try:
230
+ yield
231
+ finally:
232
+ if reads is not None:
233
+ await reads.aclose()
234
+ if traces is not None:
235
+ await traces.aclose()
236
+ if limits is not None:
237
+ await limits.aclose()
238
+ await auth.aclose()
239
+ if database is not None:
240
+ await database.dispose()
241
+
242
+ docs = keeper.openapi.docs_enabled
243
+ app = FastAPI(
244
+ title=keeper.openapi.title,
245
+ description=keeper.openapi.description,
246
+ version=__version__,
247
+ license_info={"name": "Apache-2.0", "identifier": "Apache-2.0"},
248
+ openapi_url=f"{keeper.api_prefix}/openapi.json" if docs else None,
249
+ docs_url=f"{keeper.api_prefix}/docs" if docs else None,
250
+ redoc_url=None,
251
+ responses=_DEFAULT_RESPONSES,
252
+ dependencies=[Depends(enforce_limits)],
253
+ lifespan=lifespan,
254
+ )
255
+ app.state.settings = settings
256
+ app.state.keeper_settings = keeper
257
+ app.state.readiness_checks = checks
258
+ app.state.auth = auth
259
+ app.state.database = database
260
+ app.state.limits = limits
261
+ app.state.read_audit = reads
262
+ app.state.trace = traces
263
+ install_client_addresses(app.state, keeper.trusted_proxies)
264
+
265
+ install_error_handlers(app, renderer)
266
+ app.include_router(health_router)
267
+ app.include_router(whoami_router, prefix=keeper.api_prefix)
268
+ app.include_router(sessions_router, prefix=keeper.api_prefix)
269
+ app.include_router(admin_router, prefix=keeper.api_prefix)
270
+ for router in domain_routers(): # last: branch routes match hierarchical names
271
+ app.include_router(router, prefix=keeper.api_prefix)
272
+ app.include_router(trace_router, prefix=keeper.api_prefix)
273
+
274
+ # Innermost first: the last middleware added is the outermost one.
275
+ documents = keeper.documents
276
+ app.add_middleware(
277
+ BodyLimitMiddleware,
278
+ max_bytes=keeper.limits.max_request_body_bytes,
279
+ renderer=renderer,
280
+ overrides=[
281
+ (
282
+ "POST",
283
+ document_upload_path(keeper.api_prefix),
284
+ documents.max_bytes + documents.multipart_overhead_bytes,
285
+ )
286
+ ],
287
+ )
288
+ if traces is not None:
289
+ app.add_middleware(
290
+ TraceMiddleware,
291
+ recorder=traces,
292
+ api_prefix=keeper.api_prefix,
293
+ is_read=keeper.limits.rate.routes.only_reads,
294
+ )
295
+ if limits is not None:
296
+ app.add_middleware(RateLimitHeadersMiddleware)
297
+ if keeper.cors.allow_origins:
298
+ app.add_middleware(
299
+ CORSMiddleware,
300
+ allow_origins=list(keeper.cors.allow_origins),
301
+ allow_methods=list(keeper.cors.allow_methods),
302
+ allow_headers=cors_request_headers(keeper),
303
+ expose_headers=cors_response_headers(keeper),
304
+ allow_credentials=keeper.cors.allow_credentials,
305
+ max_age=keeper.cors.max_age_s,
306
+ )
307
+ if auth.audit_writer is not None:
308
+ app.add_middleware(
309
+ AuditMiddleware,
310
+ writer=auth.audit_writer,
311
+ settings=keeper.auth.audit,
312
+ api_prefix=keeper.api_prefix,
313
+ renderer=renderer,
314
+ reads=reads,
315
+ is_read=keeper.limits.rate.routes.only_reads,
316
+ )
317
+ app.add_middleware(
318
+ RequestContextMiddleware,
319
+ header=keeper.request_id_header,
320
+ renderer=renderer,
321
+ tracer_provider=telemetry.tracer_provider if telemetry else None,
322
+ meter_provider=telemetry.meter_provider if telemetry else None,
323
+ )
324
+ # Outermost, so that the answers built by the request context middleware carry them too.
325
+ if keeper.security_headers.enabled:
326
+ app.add_middleware(
327
+ SecurityHeadersMiddleware,
328
+ settings=keeper.security_headers,
329
+ proxies=ClientAddresses(keeper.trusted_proxies),
330
+ )
331
+ return app
332
+
333
+
334
+ def _read_auditor(
335
+ audit: AuditSettings,
336
+ writes_audit: bool,
337
+ database: Database | None,
338
+ sink: ReadSink | None,
339
+ telemetry: Telemetry | None,
340
+ ) -> ReadAuditor | None:
341
+ """The read auditor when `audit.reads` is on (it needs the audit log and a database)."""
342
+ if not audit.reads.enabled:
343
+ return None
344
+ if sink is None and database is not None:
345
+ sink = database_sink(database.sessions)
346
+ if not writes_audit or sink is None:
347
+ raise ConfigError(
348
+ [
349
+ "keeper.auth.audit.reads.enabled needs keeper.auth.audit.enabled and the "
350
+ "database section"
351
+ ]
352
+ )
353
+ return ReadAuditor(
354
+ audit.reads, sink, meter_provider=telemetry.meter_provider if telemetry else None
355
+ )
356
+
357
+
358
+ def _trace_recorder(
359
+ trace: TraceSettings,
360
+ database: Database | None,
361
+ sink: TraceSink | None,
362
+ telemetry: Telemetry | None,
363
+ ) -> TraceRecorder | None:
364
+ """The agent trace recorder when `keeper.trace.enabled` (it needs a database)."""
365
+ if not trace.enabled:
366
+ return None
367
+ if sink is None and database is not None:
368
+ sink = trace_database_sink(database.sessions, trace)
369
+ if sink is None:
370
+ raise ConfigError(["keeper.trace.enabled needs the database section"])
371
+ return TraceRecorder(
372
+ trace, sink, meter_provider=telemetry.meter_provider if telemetry else None
373
+ )
@@ -0,0 +1,4 @@
1
+ """Authentication, authorization and audit for the Keeper API.
2
+
3
+ Modules do not re-export each other: import what you need from the module that defines it.
4
+ """
@@ -0,0 +1,127 @@
1
+ """API key format, hashing and verification (design 13.2).
2
+
3
+ A key looks like `<token_prefix>_<prefix>_<secret>`: `prefix` is public and finds the stored
4
+ row, `secret` is random and shown once. Only `HMAC-SHA256(pepper, secret)` is stored.
5
+
6
+ Why not a slow password hash (scrypt, argon2): the secret is 256 random bits, so guessing it is
7
+ infeasible whatever the hash costs, while a memory-hard hash on every request would let anyone
8
+ exhaust the server with bad keys. The pepper keeps the stored values useless without the server
9
+ configuration.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import hmac
17
+ import re
18
+ import secrets
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from typing import TYPE_CHECKING
23
+
24
+ from lorebase_keeper.auth.config import ApiKeySettings
25
+ from lorebase_store.auth import ApiKeyRecord, PrefixTakenError
26
+
27
+ if TYPE_CHECKING:
28
+ from lorebase_keeper.auth.ports import ApiKeysPort
29
+
30
+ _PREFIX_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
31
+ _PART = re.compile(r"^[A-Za-z0-9_-]+$")
32
+
33
+ KEY_CREATION_ATTEMPTS = 5
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class NewApiKey:
38
+ """A freshly generated key. `token` is shown to the administrator once."""
39
+
40
+ token: str
41
+ prefix: str
42
+ key_hash: bytes
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ParsedKey:
47
+ """The parts of a presented key."""
48
+
49
+ prefix: str
50
+ secret: str
51
+
52
+
53
+ class ApiKeyCodec:
54
+ """Generates, parses and hashes keys according to the `api_keys` settings."""
55
+
56
+ def __init__(self, settings: ApiKeySettings) -> None:
57
+ pepper = settings.pepper
58
+ if pepper is None:
59
+ raise ValueError("api_keys.pepper is required")
60
+ self._settings = settings
61
+ self._pepper = pepper.get_secret_value().encode()
62
+ self._leading = f"{settings.token_prefix}_"
63
+
64
+ def looks_like_key(self, token: str) -> bool:
65
+ """Whether `token` starts like one of our keys (as opposed to a JWT)."""
66
+ return token.startswith(self._leading)
67
+
68
+ def generate(self) -> NewApiKey:
69
+ """A new random key."""
70
+ prefix = "".join(
71
+ secrets.choice(_PREFIX_ALPHABET) for _ in range(self._settings.prefix_length)
72
+ )
73
+ secret = base64.urlsafe_b64encode(secrets.token_bytes(self._settings.secret_bytes))
74
+ secret_text = secret.decode().rstrip("=").replace("_", "x")
75
+ return NewApiKey(
76
+ token=f"{self._leading}{prefix}_{secret_text}",
77
+ prefix=prefix,
78
+ key_hash=self.hash_secret(secret_text),
79
+ )
80
+
81
+ def parse(self, token: str) -> ParsedKey | None:
82
+ """Split a presented key; None when it is not shaped like one of ours."""
83
+ if not self.looks_like_key(token) or not _PART.match(token):
84
+ return None
85
+ _, _, rest = token.partition(self._leading)
86
+ prefix, separator, secret = rest.partition("_")
87
+ if not separator or len(prefix) != self._settings.prefix_length or not secret:
88
+ return None
89
+ return ParsedKey(prefix=prefix, secret=secret)
90
+
91
+ def hash_secret(self, secret: str) -> bytes:
92
+ """The value stored for a secret."""
93
+ return hmac.new(self._pepper, secret.encode(), hashlib.sha256).digest()
94
+
95
+ def matches(self, secret: str, stored_hash: bytes) -> bool:
96
+ """Constant-time comparison of a presented secret with a stored hash."""
97
+ return hmac.compare_digest(self.hash_secret(secret), stored_hash)
98
+
99
+
100
+ async def issue_api_key(
101
+ codec: ApiKeyCodec,
102
+ keys: ApiKeysPort,
103
+ principal_id: str,
104
+ *,
105
+ scopes: Sequence[str] = (),
106
+ expires_at: datetime | None = None,
107
+ allowed_networks: Sequence[str] = (),
108
+ ) -> tuple[str, ApiKeyRecord]:
109
+ """Store a new key for `principal_id`; returns the secret token (shown once) and the row.
110
+
111
+ A generated prefix that is already taken is drawn again, a few times at most.
112
+ """
113
+ for _ in range(KEY_CREATION_ATTEMPTS):
114
+ generated = codec.generate()
115
+ try:
116
+ key = await keys.add(
117
+ principal_id=principal_id,
118
+ prefix=generated.prefix,
119
+ key_hash=generated.key_hash,
120
+ scopes=scopes,
121
+ expires_at=expires_at,
122
+ allowed_networks=allowed_networks,
123
+ )
124
+ except PrefixTakenError:
125
+ continue
126
+ return generated.token, key
127
+ raise RuntimeError("could not find an unused API key prefix")