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,452 @@
1
+ """Browser sessions: Keeper as backend-for-frontend of the web app (design 13.2).
2
+
3
+ Sign-in runs the authorization code flow with PKCE. `begin_login` returns the provider URL and
4
+ an encrypted, short-lived cookie holding state, nonce, PKCE verifier and return path;
5
+ `finish_login` checks the callback against it (state, then the ID token's signature, issuer,
6
+ audience and nonce) and `create` stores a session. The browser only ever holds an opaque,
7
+ random session id in an `HttpOnly` cookie; the database holds its SHA-256, the user's
8
+ principal and provider groups, and the provider's refresh and ID tokens encrypted with a key
9
+ derived from `sessions.encryption_key`.
10
+
11
+ Each request with the cookie is resolved by `resolve`: unknown, idle or expired sessions are
12
+ refused (and deleted); when the provider's access token has expired, one request uses the
13
+ refresh token, and a refusal ends the session. Unsafe methods must pass `check_request`: a
14
+ CSRF token (an HMAC of the session id, given to the app by `GET /auth/session`) in a header,
15
+ an acceptable `Origin` and no cross-site `Sec-Fetch-Site`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import binascii
22
+ import hashlib
23
+ import hmac
24
+ import json
25
+ import logging
26
+ import re
27
+ from collections.abc import Callable, Mapping
28
+ from dataclasses import dataclass, replace
29
+ from datetime import UTC, datetime, timedelta
30
+ from typing import Literal
31
+
32
+ from starlette.requests import cookie_parser
33
+
34
+ from lorebase_keeper.auth.config import SessionSettings, is_local_path
35
+ from lorebase_keeper.auth.keyring import Keyring
36
+ from lorebase_keeper.auth.oidc import (
37
+ InvalidTokenError,
38
+ OidcClaims,
39
+ OidcVerifier,
40
+ ProviderUnavailableError,
41
+ )
42
+ from lorebase_keeper.auth.ports import AuthStore
43
+ from lorebase_keeper.auth.provider import (
44
+ ProviderClient,
45
+ TokenRefusedError,
46
+ TokenResponse,
47
+ pkce_pair,
48
+ random_token,
49
+ )
50
+ from lorebase_keeper.errors import FORBIDDEN, ProblemError
51
+ from lorebase_store.auth import SessionRecord
52
+
53
+ logger = logging.getLogger("lorebase.keeper.auth.sessions")
54
+
55
+ LoginErrorCode = Literal[
56
+ "login_expired", "login_failed", "access_denied", "no_account", "provider_unavailable"
57
+ ]
58
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
59
+ _ALLOWED_FETCH_SITES = frozenset({"same-origin", "same-site", "none"})
60
+ _SESSION_ID = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
61
+ _LOGIN_PURPOSE = "login-state"
62
+ _TOKENS_PURPOSE = "session-tokens"
63
+ _CSRF_PURPOSE = "csrf"
64
+ _MAX_PARAM_LENGTH = 4096
65
+
66
+ LOGIN_PATH = "/auth/login"
67
+ CALLBACK_PATH = "/auth/callback"
68
+ LOGOUT_PATH = "/auth/logout"
69
+ PUBLIC_PATHS = frozenset({LOGIN_PATH, CALLBACK_PATH, LOGOUT_PATH})
70
+ """Routes (without the API prefix) that work without a valid credential: the limits dependency
71
+ does not authenticate them, so a stale cookie never blocks signing in or out."""
72
+
73
+
74
+ class LoginError(Exception):
75
+ """A sign-in that cannot complete. `code` is shown to the user; the message is for logs."""
76
+
77
+ def __init__(self, code: LoginErrorCode, message: str) -> None:
78
+ super().__init__(message)
79
+ self.code: LoginErrorCode = code
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LoginStart:
84
+ """Where to send the browser, and the cookie that must come back with the callback."""
85
+
86
+ url: str
87
+ state_cookie: str
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class LoginResult:
92
+ """A verified sign-in: the user's claims, the provider's tokens, the return path and the
93
+ nonce the ID token was bound to (refreshed ID tokens must repeat it if they carry one)."""
94
+
95
+ claims: OidcClaims
96
+ tokens: TokenResponse
97
+ return_to: str
98
+ nonce: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class NewSession:
103
+ """A session just created: the id goes into the cookie, never into storage."""
104
+
105
+ session_id: str
106
+ record: SessionRecord
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SignOut:
111
+ """An ended session: whose it was and the provider's logout URL (None when unknown)."""
112
+
113
+ principal_id: str | None
114
+ logout_url: str | None
115
+
116
+
117
+ def digest(session_id: str) -> bytes:
118
+ """The stored form of a session id."""
119
+ return hashlib.sha256(session_id.encode("ascii")).digest()
120
+
121
+
122
+ def _b64encode(raw: bytes) -> str:
123
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
124
+
125
+
126
+ def _b64decode(text: str) -> bytes | None:
127
+ try:
128
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
129
+ except (binascii.Error, ValueError):
130
+ return None
131
+
132
+
133
+ def cookie_value(headers: Mapping[str, str], name: str) -> str | None:
134
+ """The value of cookie `name` in the request headers (lower-case names)."""
135
+ header = headers.get("cookie")
136
+ if not header:
137
+ return None
138
+ return cookie_parser(header).get(name) or None
139
+
140
+
141
+ class SessionManager:
142
+ """Sign-in, session lookup and refresh, CSRF checks and sign-out."""
143
+
144
+ def __init__(
145
+ self,
146
+ settings: SessionSettings,
147
+ *,
148
+ store: AuthStore,
149
+ provider: ProviderClient,
150
+ verifier: OidcVerifier,
151
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
152
+ ) -> None:
153
+ key = settings.encryption_key
154
+ if key is None:
155
+ raise ValueError("sessions need an encryption key")
156
+ self.settings = settings
157
+ self._store = store
158
+ self._provider = provider
159
+ self._verifier = verifier
160
+ self._keys = Keyring(key.get_secret_value())
161
+ self._clock = clock
162
+ self._origins = settings.trusted_origins()
163
+
164
+ # Sign-in -------------------------------------------------------------------------------
165
+
166
+ async def begin_login(self, return_to: str) -> LoginStart:
167
+ """Start a sign-in that returns to `return_to` (a local path, checked by the caller).
168
+
169
+ `ProviderUnavailableError` when the provider metadata cannot be read.
170
+ """
171
+ metadata = await self._provider.metadata()
172
+ state, nonce = random_token(), random_token()
173
+ verifier, challenge = pkce_pair()
174
+ expires = self._clock() + timedelta(seconds=self.settings.login_timeout_s)
175
+ payload = json.dumps(
176
+ {"s": state, "n": nonce, "v": verifier, "r": return_to, "e": expires.timestamp()},
177
+ separators=(",", ":"),
178
+ ).encode()
179
+ return LoginStart(
180
+ url=self._provider.authorization_url(
181
+ metadata, state=state, nonce=nonce, challenge=challenge
182
+ ),
183
+ state_cookie=_b64encode(self._keys.seal(_LOGIN_PURPOSE, payload)),
184
+ )
185
+
186
+ async def finish_login(
187
+ self, params: Mapping[str, str], state_cookie: str | None
188
+ ) -> LoginResult:
189
+ """Verify a callback (`params` is its query) against the sign-in state cookie and redeem
190
+ the code. `LoginError` names what went wrong."""
191
+ pending = self._open_login_state(state_cookie)
192
+ if pending is None:
193
+ raise LoginError("login_expired", "sign-in state cookie missing, altered or expired")
194
+ error = params.get("error")
195
+ if error is not None:
196
+ code: LoginErrorCode = "access_denied" if error == "access_denied" else "login_failed"
197
+ raise LoginError(code, f"provider returned error {error[:64]!r}")
198
+ state, code_param = params.get("state", ""), params.get("code", "")
199
+ if not state or not hmac.compare_digest(state.encode(), pending["s"].encode()):
200
+ raise LoginError("login_failed", "state does not match the sign-in state cookie")
201
+ if not code_param or len(code_param) > _MAX_PARAM_LENGTH:
202
+ raise LoginError("login_failed", "callback without a usable code")
203
+ try:
204
+ tokens = await self._provider.exchange_code(code_param, pending["v"])
205
+ except TokenRefusedError as exc:
206
+ raise LoginError("login_failed", str(exc)) from None
207
+ except ProviderUnavailableError as exc:
208
+ raise LoginError("provider_unavailable", str(exc)) from None
209
+ if tokens.id_token is None:
210
+ raise LoginError("login_failed", "token response without an id_token")
211
+ try:
212
+ claims = await self._verifier.verify_id_token(
213
+ tokens.id_token, client_id=self._provider.client_id, nonce=pending["n"]
214
+ )
215
+ except InvalidTokenError as exc:
216
+ raise LoginError("login_failed", f"id_token rejected: {exc}") from None
217
+ except ProviderUnavailableError as exc:
218
+ raise LoginError("provider_unavailable", str(exc)) from None
219
+ return LoginResult(claims=claims, tokens=tokens, return_to=pending["r"], nonce=pending["n"])
220
+
221
+ def _open_login_state(self, cookie: str | None) -> dict[str, str] | None:
222
+ if not cookie or len(cookie) > _MAX_PARAM_LENGTH:
223
+ return None
224
+ sealed = _b64decode(cookie)
225
+ plain = self._keys.open(_LOGIN_PURPOSE, sealed) if sealed else None
226
+ if plain is None:
227
+ return None
228
+ try:
229
+ data = json.loads(plain)
230
+ except ValueError:
231
+ return None
232
+ fields = ("s", "n", "v", "r")
233
+ if not isinstance(data, dict) or not all(isinstance(data.get(f), str) for f in fields):
234
+ return None
235
+ expires = data.get("e")
236
+ if not isinstance(expires, int | float) or expires <= self._clock().timestamp():
237
+ return None
238
+ if not is_local_path(data["r"]):
239
+ return None
240
+ return {name: data[name] for name in fields}
241
+
242
+ # Sessions ------------------------------------------------------------------------------
243
+
244
+ async def create(
245
+ self, principal_id: str, login: LoginResult, *, replaces: str | None = None
246
+ ) -> NewSession:
247
+ """Store a new session for a verified sign-in, ending the session it `replaces` (the
248
+ cookie the browser sent, if any: a new id on every sign-in prevents fixation)."""
249
+ settings = self.settings
250
+ now = self._clock()
251
+ session_id = random_token()
252
+ refresh_token = login.tokens.refresh_token if settings.refresh_tokens else None
253
+ sealed = self._seal_tokens(refresh_token, login.tokens.id_token, login.nonce)
254
+ async with self._store.unit() as unit:
255
+ if replaces and _SESSION_ID.match(replaces):
256
+ await unit.sessions.delete(digest(replaces))
257
+ if settings.purge_batch:
258
+ await unit.sessions.purge_expired(
259
+ now,
260
+ now - timedelta(seconds=settings.idle_timeout_s),
261
+ limit=settings.purge_batch,
262
+ )
263
+ record = await unit.sessions.add(
264
+ id_hash=digest(session_id),
265
+ principal_id=principal_id,
266
+ subject=login.claims.subject,
267
+ external_groups=login.claims.groups,
268
+ tokens=sealed,
269
+ created_at=now,
270
+ expires_at=now + timedelta(seconds=settings.absolute_timeout_s),
271
+ refresh_at=self._next_refresh(now, login.tokens) if refresh_token else None,
272
+ )
273
+ return NewSession(session_id=session_id, record=record)
274
+
275
+ async def resolve(self, session_id: str) -> SessionRecord | None:
276
+ """The live session with this id, refreshed at the provider when due; None when it is
277
+ unknown, idle too long, past its absolute lifetime or refused by the provider."""
278
+ if not _SESSION_ID.match(session_id):
279
+ return None
280
+ settings = self.settings
281
+ key = digest(session_id)
282
+ now = self._clock()
283
+ async with self._store.unit() as unit:
284
+ record = await unit.sessions.get(key)
285
+ if record is None:
286
+ return None
287
+ if self._expired(record, now):
288
+ await unit.sessions.delete(key)
289
+ return None
290
+ touch = timedelta(seconds=settings.touch_interval_s)
291
+ if now - record.last_seen_at >= touch:
292
+ await unit.sessions.touch(key, now, min_interval=touch)
293
+ record = replace(record, last_seen_at=now)
294
+ if settings.refresh_tokens and record.refresh_at is not None and record.refresh_at <= now:
295
+ return await self._refresh(record, now)
296
+ return record
297
+
298
+ def _expired(self, record: SessionRecord, now: datetime) -> bool:
299
+ idle = timedelta(seconds=self.settings.idle_timeout_s)
300
+ return record.expires_at <= now or record.last_seen_at + idle <= now
301
+
302
+ async def _refresh(self, record: SessionRecord, now: datetime) -> SessionRecord | None:
303
+ """Refresh the provider tokens, if this request wins the lease; others go on with the
304
+ session as it is."""
305
+ settings = self.settings
306
+ key = record.id_hash
307
+ lease = now + timedelta(seconds=settings.refresh_lease_s)
308
+ async with self._store.unit() as unit:
309
+ if not await unit.sessions.claim_refresh(key, now, lease):
310
+ return record
311
+ tokens = self._open_tokens(record.tokens) or {}
312
+ refresh_token = tokens.get("refresh_token")
313
+ if refresh_token is None:
314
+ # Sealed under another key (it changed) or nothing to refresh with: no provider
315
+ # check is possible any more, so the session ends.
316
+ logger.info(
317
+ "session ended: no usable refresh token", extra={"principal": record.principal_id}
318
+ )
319
+ await self._delete(key)
320
+ return None
321
+ try:
322
+ response = await self._provider.refresh(refresh_token)
323
+ claims = (
324
+ await self._verifier.verify_id_token(
325
+ response.id_token,
326
+ client_id=self._provider.client_id,
327
+ nonce=tokens.get("nonce"),
328
+ nonce_required=False,
329
+ )
330
+ if response.id_token
331
+ else None
332
+ )
333
+ except (TokenRefusedError, InvalidTokenError) as exc:
334
+ logger.info(
335
+ "session ended: refresh refused (%s)", exc, extra={"principal": record.principal_id}
336
+ )
337
+ await self._delete(key)
338
+ return None
339
+ except ProviderUnavailableError:
340
+ logger.warning(
341
+ "session refresh postponed: provider unavailable",
342
+ extra={"principal": record.principal_id},
343
+ )
344
+ return record
345
+ if claims is not None and claims.subject != record.subject:
346
+ logger.warning(
347
+ "session ended: refreshed id_token names another subject",
348
+ extra={"principal": record.principal_id},
349
+ )
350
+ await self._delete(key)
351
+ return None
352
+ groups = claims.groups if claims is not None else record.external_groups
353
+ sealed = self._seal_tokens(
354
+ response.refresh_token or refresh_token,
355
+ response.id_token or tokens.get("id_token"),
356
+ tokens.get("nonce"),
357
+ )
358
+ refresh_at = self._next_refresh(now, response)
359
+ async with self._store.unit() as unit:
360
+ stored = await unit.sessions.update_tokens(
361
+ key, tokens=sealed, external_groups=groups, refresh_at=refresh_at
362
+ )
363
+ if not stored:
364
+ return None
365
+ return replace(record, tokens=sealed, external_groups=tuple(groups), refresh_at=refresh_at)
366
+
367
+ def _next_refresh(self, now: datetime, tokens: TokenResponse) -> datetime:
368
+ settings = self.settings
369
+ interval = max(
370
+ settings.refresh_min_interval_s,
371
+ tokens.expires_in or settings.refresh_default_interval_s,
372
+ )
373
+ return now + timedelta(seconds=interval)
374
+
375
+ def _seal_tokens(
376
+ self, refresh_token: str | None, id_token: str | None, nonce: str | None
377
+ ) -> bytes | None:
378
+ pairs = (("refresh_token", refresh_token), ("id_token", id_token), ("nonce", nonce))
379
+ values = {name: value for name, value in pairs if value}
380
+ if not values:
381
+ return None
382
+ return self._keys.seal(_TOKENS_PURPOSE, json.dumps(values).encode())
383
+
384
+ def _open_tokens(self, sealed: bytes | None) -> dict[str, str] | None:
385
+ plain = self._keys.open(_TOKENS_PURPOSE, sealed) if sealed else None
386
+ if plain is None:
387
+ return None
388
+ try:
389
+ data = json.loads(plain)
390
+ except ValueError:
391
+ return None
392
+ if not isinstance(data, dict):
393
+ return None
394
+ return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
395
+
396
+ async def _delete(self, key: bytes) -> None:
397
+ async with self._store.unit() as unit:
398
+ await unit.sessions.delete(key)
399
+
400
+ async def end(self, session_id: str) -> SignOut:
401
+ """Sign out: delete the session and give the provider's logout URL (with the ID token
402
+ as hint); nothing when the session is unknown."""
403
+ if not _SESSION_ID.match(session_id):
404
+ return SignOut(None, None)
405
+ key = digest(session_id)
406
+ async with self._store.unit() as unit:
407
+ record = await unit.sessions.get(key)
408
+ if record is None:
409
+ return SignOut(None, None)
410
+ await unit.sessions.delete(key)
411
+ tokens = self._open_tokens(record.tokens) or {}
412
+ try:
413
+ metadata = await self._provider.metadata()
414
+ except ProviderUnavailableError:
415
+ return SignOut(record.principal_id, None)
416
+ url = self._provider.end_session_url(metadata, tokens.get("id_token"))
417
+ return SignOut(record.principal_id, url)
418
+
419
+ # CSRF ----------------------------------------------------------------------------------
420
+
421
+ def csrf_token(self, session_id: str) -> str:
422
+ """The CSRF token of a session: an HMAC of its id, so nothing more is stored."""
423
+ return _b64encode(self._keys.mac(_CSRF_PURPOSE, session_id.encode("ascii")))
424
+
425
+ def check_request(self, session_id: str, method: str, headers: Mapping[str, str]) -> None:
426
+ """Refuse (403, `reason: csrf`) an unsafe request authenticated by the session cookie
427
+ unless it carries the session's CSRF token, comes from a trusted origin when it names
428
+ one, and is not marked cross-site by the browser."""
429
+ if method.upper() in SAFE_METHODS:
430
+ return
431
+ if not _SESSION_ID.match(session_id):
432
+ raise _csrf_refusal("malformed session id")
433
+ site = headers.get("sec-fetch-site")
434
+ if site is not None and site.lower() not in _ALLOWED_FETCH_SITES:
435
+ raise _csrf_refusal("cross-site request")
436
+ origin = headers.get("origin")
437
+ if origin is not None and origin.lower() not in self._origins:
438
+ raise _csrf_refusal("untrusted origin")
439
+ token = headers.get(self.settings.csrf_header.lower())
440
+ expected = self.csrf_token(session_id)
441
+ if not token or not hmac.compare_digest(token.encode(), expected.encode()):
442
+ raise _csrf_refusal("missing or wrong token")
443
+
444
+
445
+ def _csrf_refusal(reason: str) -> ProblemError:
446
+ logger.info("csrf check failed: %s", reason)
447
+ return ProblemError(
448
+ FORBIDDEN,
449
+ "The request was refused by the CSRF check of its session: send the token of "
450
+ "GET /auth/session in the configured header from the web app's origin.",
451
+ extensions={"reason": "csrf"},
452
+ )
@@ -0,0 +1,151 @@
1
+ """First installation administrator and service principals, written with the database settings.
2
+
3
+ In `api_key` mode no request is accepted without a key, and only an administrator can issue
4
+ one, so the first key comes from the database side: `python -m lorebase_keeper bootstrap`
5
+ (design 13.2). It reads the same settings as Keeper (database, `keeper.auth`, the pepper).
6
+
7
+ - `bootstrap` creates the service principal named in `keeper.auth.installation_admins` (the
8
+ first entry unless `--principal` names another one) and a key for it. It refuses when an
9
+ installation administrator already holds a usable key, unless `force` is given; then one more
10
+ key is added and the existing ones stay valid. The new token is returned once and never stored.
11
+ - `create_service_principal` adds a service principal with a chosen id, so that a schema can
12
+ name it in Operation permissions before any key exists. The API creates service principals
13
+ only with generated ids.
14
+
15
+ Both write an audit entry on the installation-wide chain.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timedelta
22
+
23
+ from lorebase_keeper.auth.api_keys import ApiKeyCodec, issue_api_key
24
+ from lorebase_keeper.auth.config import ApiKeySettings, AuthSettings
25
+ from lorebase_keeper.auth.ports import AuthStore
26
+ from lorebase_store.auth import NewAuditEntry
27
+ from lorebase_store.models.enums import PrincipalType
28
+
29
+ BOOTSTRAP_ACTOR = "system:bootstrap"
30
+ BOOTSTRAP_ACTION = "apikey.bootstrap"
31
+ PRINCIPAL_ACTION = "principal.create"
32
+ DEFAULT_ADMIN_NAME = "installation administrator"
33
+
34
+
35
+ class BootstrapError(Exception):
36
+ """The request cannot be carried out; the message says why and what to change."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BootstrapKey:
41
+ """The key created by `bootstrap`. `token` exists only here."""
42
+
43
+ principal_id: str
44
+ principal_created: bool
45
+ key_id: str
46
+ prefix: str
47
+ token: str
48
+ expires_at: datetime | None
49
+
50
+
51
+ def bootstrap_expiry(settings: ApiKeySettings, now: datetime) -> datetime | None:
52
+ """Expiry of a bootstrap key: the default lifetime of new keys (within `max_ttl_days`)."""
53
+ days = settings.default_ttl_days
54
+ return None if days is None else now + timedelta(days=days)
55
+
56
+
57
+ async def bootstrap(
58
+ store: AuthStore,
59
+ settings: AuthSettings,
60
+ *,
61
+ now: datetime,
62
+ principal_id: str | None = None,
63
+ display_name: str = DEFAULT_ADMIN_NAME,
64
+ force: bool = False,
65
+ ) -> BootstrapKey:
66
+ """Create the first installation administrator key (see the module documentation)."""
67
+ if not settings.uses_api_keys:
68
+ raise BootstrapError(
69
+ f"keeper.auth.mode is {settings.mode!r}: API keys are not accepted, so there is no "
70
+ "key to bootstrap (use api_key or api_key+oidc)"
71
+ )
72
+ admins = settings.installation_admins
73
+ if not admins:
74
+ raise BootstrapError(
75
+ "keeper.auth.installation_admins is empty: name the administrator principal id there"
76
+ )
77
+ target = principal_id or admins[0]
78
+ if target not in admins:
79
+ raise BootstrapError(
80
+ f"{target!r} is not listed in keeper.auth.installation_admins ({', '.join(admins)})"
81
+ )
82
+ codec = ApiKeyCodec(settings.api_keys)
83
+ expires_at = bootstrap_expiry(settings.api_keys, now)
84
+ async with store.unit() as unit:
85
+ usable = [k for k in await unit.api_keys.list_for_principals(admins) if k.is_usable(now)]
86
+ if usable and not force:
87
+ held = ", ".join(f"{k.principal_id} ({k.prefix}...)" for k in usable)
88
+ raise BootstrapError(
89
+ f"an installation administrator already holds a usable API key: {held}. "
90
+ "Use that key to issue others, or pass --force to add one more"
91
+ )
92
+ principal = await unit.principals.get(target)
93
+ created = principal is None
94
+ if principal is None:
95
+ principal = await unit.principals.add(
96
+ PrincipalType.SERVICE, display_name, principal_id=target
97
+ )
98
+ elif principal.type is not PrincipalType.SERVICE:
99
+ raise BootstrapError(
100
+ f"{target!r} is a {principal.type.value} principal; API keys belong to service "
101
+ "principals"
102
+ )
103
+ token, key = await issue_api_key(codec, unit.api_keys, principal.id, expires_at=expires_at)
104
+ await unit.audit.append(
105
+ NewAuditEntry(
106
+ actor=BOOTSTRAP_ACTOR,
107
+ action=BOOTSTRAP_ACTION,
108
+ target=key.id,
109
+ details={
110
+ "principal_id": principal.id,
111
+ "principal_created": created,
112
+ "prefix": key.prefix,
113
+ "forced": bool(usable),
114
+ "expires_at": expires_at.isoformat() if expires_at else None,
115
+ },
116
+ at=now,
117
+ )
118
+ )
119
+ return BootstrapKey(
120
+ principal_id=principal.id,
121
+ principal_created=created,
122
+ key_id=key.id,
123
+ prefix=key.prefix,
124
+ token=token,
125
+ expires_at=expires_at,
126
+ )
127
+
128
+
129
+ async def create_service_principal(
130
+ store: AuthStore, principal_id: str, display_name: str, *, now: datetime
131
+ ) -> bool:
132
+ """Add a service principal with this id; false when it already exists as one."""
133
+ async with store.unit() as unit:
134
+ existing = await unit.principals.get(principal_id)
135
+ if existing is not None:
136
+ if existing.type is not PrincipalType.SERVICE:
137
+ raise BootstrapError(
138
+ f"{principal_id!r} already exists as a {existing.type.value} principal"
139
+ )
140
+ return False
141
+ await unit.principals.add(PrincipalType.SERVICE, display_name, principal_id=principal_id)
142
+ await unit.audit.append(
143
+ NewAuditEntry(
144
+ actor=BOOTSTRAP_ACTOR,
145
+ action=PRINCIPAL_ACTION,
146
+ target=principal_id,
147
+ details={"type": PrincipalType.SERVICE.value, "display_name": display_name},
148
+ at=now,
149
+ )
150
+ )
151
+ return True