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