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,618 @@
1
+ """The `keeper.auth` settings section: authentication, authorization and audit.
2
+
3
+ Every value has a documented default; deployments override it in the YAML file or with
4
+ `LOREBASE__KEEPER__AUTH__...` variables. See `config/lorebase.example.yaml`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import re
11
+ from typing import Any, Literal, Self
12
+ from urllib.parse import urlsplit
13
+
14
+ from pydantic import Field, field_validator, model_validator
15
+
16
+ from lorebase_keeper.auth.permissions import BUILTIN_ROLES, permission_names
17
+ from lorebase_platform.secrets import SecretValue
18
+ from lorebase_platform.settings import SectionModel
19
+
20
+ AuthMode = Literal["none", "api_key", "oidc", "api_key+oidc"]
21
+
22
+ ASYMMETRIC_ALGORITHMS = frozenset(
23
+ {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"}
24
+ )
25
+ """Signature algorithms accepted for tokens. Shared-secret algorithms are excluded on purpose:
26
+ a provider's keys are public, so accepting HS* would let anyone sign a token."""
27
+
28
+ MIN_PEPPER_LENGTH = 32
29
+ _TOKEN_PREFIX = re.compile(r"^[a-z][a-z0-9]{1,15}$")
30
+ _ROLE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
31
+
32
+
33
+ def is_loopback(host: str) -> bool:
34
+ """Whether a host name or address only reaches this machine."""
35
+ if host.lower() == "localhost":
36
+ return True
37
+ try:
38
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
39
+ except ValueError:
40
+ return False
41
+
42
+
43
+ def insecure_url_problem(url: str, allow_insecure: bool) -> str | None:
44
+ """Why `url` may not be used to reach an identity provider, or None when it may.
45
+
46
+ Signing keys fetched over plain HTTP can be replaced in transit, so provider URLs must use
47
+ https; http is accepted for loopback hosts or when `allow_insecure` is set.
48
+ """
49
+ parts = urlsplit(url)
50
+ if parts.scheme not in ("http", "https") or not parts.hostname:
51
+ return "must be an absolute https URL"
52
+ if parts.scheme == "http" and not allow_insecure and not is_loopback(parts.hostname):
53
+ return "must use https (http is accepted for loopback hosts or with oidc.allow_insecure)"
54
+ return None
55
+
56
+
57
+ MAX_LOCAL_PATH_LENGTH = 2048
58
+
59
+
60
+ def is_local_path(value: str) -> bool:
61
+ """Whether `value` is a path on the current origin, safe to redirect to.
62
+
63
+ It must start with a single `/` and hold no backslash, control character, scheme or
64
+ authority: `//evil.example`, `/\\evil.example` and `https://...` are refused, since browsers
65
+ read them as other origins.
66
+ """
67
+ if not value.startswith("/") or value.startswith("//") or len(value) > MAX_LOCAL_PATH_LENGTH:
68
+ return False
69
+ if "\\" in value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
70
+ return False
71
+ parts = urlsplit(value)
72
+ return not parts.scheme and not parts.netloc
73
+
74
+
75
+ class ApiKeySettings(SectionModel):
76
+ """API keys for agents and automation (design 13.2)."""
77
+
78
+ token_prefix: str = Field(
79
+ default="lbk",
80
+ description="First part of every key: <token_prefix>_<prefix>_<secret>. Changing it "
81
+ "invalidates keys already issued.",
82
+ )
83
+ header: str = Field(
84
+ default="X-Api-Key",
85
+ min_length=1,
86
+ description="Header that may carry a key instead of `Authorization: Bearer`.",
87
+ )
88
+ pepper: SecretValue | None = Field(
89
+ default=None,
90
+ description="Server-side key for the HMAC-SHA256 stored for each key secret; required "
91
+ "when API keys are enabled, at least 32 characters, e.g. secret://env/LOREBASE_PEPPER. "
92
+ "Changing it invalidates every issued key.",
93
+ )
94
+ prefix_length: int = Field(
95
+ default=10, ge=6, le=32, description="Characters of the public lookup prefix."
96
+ )
97
+ secret_bytes: int = Field(
98
+ default=32, ge=24, le=128, description="Random bytes of the secret part."
99
+ )
100
+ default_ttl_days: int | None = Field(
101
+ default=365,
102
+ ge=1,
103
+ description="Lifetime of new keys when the request gives none; null: keys do not expire.",
104
+ )
105
+ max_ttl_days: int | None = Field(
106
+ default=None,
107
+ ge=1,
108
+ description="Longest lifetime an administrator may request; null: no limit. When set, "
109
+ "keys that never expire cannot be created.",
110
+ )
111
+ last_used_interval_s: float = Field(
112
+ default=60,
113
+ ge=0,
114
+ description="A key's last-used time is written at most this often; 0 writes on every use.",
115
+ )
116
+ max_allowed_networks: int = Field(
117
+ default=32,
118
+ ge=1,
119
+ description="Most networks an administrator may list in a key's `allowed_networks`.",
120
+ )
121
+
122
+ @field_validator("token_prefix")
123
+ @classmethod
124
+ def _token_prefix_shape(cls, value: str) -> str:
125
+ if not _TOKEN_PREFIX.match(value):
126
+ raise ValueError("must be 2 to 16 lowercase letters or digits, starting with a letter")
127
+ return value
128
+
129
+ @model_validator(mode="after")
130
+ def _default_within_max(self) -> Self:
131
+ if self.max_ttl_days is not None and (
132
+ self.default_ttl_days is None or self.default_ttl_days > self.max_ttl_days
133
+ ):
134
+ raise ValueError("default_ttl_days must be set and not exceed max_ttl_days")
135
+ return self
136
+
137
+
138
+ class OidcSettings(SectionModel):
139
+ """Bearer token validation against any OpenID Connect provider (design 13.2)."""
140
+
141
+ issuer: str | None = Field(
142
+ default=None, description="Expected `iss`; also the base of the discovery document."
143
+ )
144
+ audience: tuple[str, ...] = Field(
145
+ default=(),
146
+ description="Accepted `aud` values (a single string is accepted); a token must carry "
147
+ "at least one.",
148
+ )
149
+ jwks_uri: str | None = Field(
150
+ default=None,
151
+ description="Signing keys of the provider. Null: taken from "
152
+ "<issuer>/.well-known/openid-configuration.",
153
+ )
154
+ algorithms: tuple[str, ...] = Field(
155
+ default=("RS256", "ES256"), min_length=1, description="Accepted signature algorithms."
156
+ )
157
+ clock_skew_s: float = Field(
158
+ default=60, ge=0, le=600, description="Tolerance for exp, nbf and iat."
159
+ )
160
+ jwks_cache_ttl_s: float = Field(
161
+ default=300, gt=0, description="How long fetched signing keys are used before a refresh."
162
+ )
163
+ jwks_min_refresh_s: float = Field(
164
+ default=30,
165
+ ge=0,
166
+ description="Shortest time between refreshes triggered by an unknown key id.",
167
+ )
168
+ http_timeout_s: float = Field(default=5, gt=0, description="Timeout of provider requests.")
169
+ subject_claim: str = Field(default="sub", min_length=1)
170
+ name_claims: tuple[str, ...] = Field(
171
+ default=("name", "preferred_username", "email"),
172
+ description="Claims tried in order for the display name; the subject is the fallback.",
173
+ )
174
+ groups_claim: str = Field(
175
+ default="groups", min_length=1, description="Claim listing the provider groups."
176
+ )
177
+ auto_provision: bool = Field(
178
+ default=True,
179
+ description="Create the user principal on the first valid token. When false, only "
180
+ "existing principals can sign in.",
181
+ )
182
+ principal_cache_ttl_s: float = Field(
183
+ default=60, ge=0, description="How long a token subject's principal and groups are reused."
184
+ )
185
+ principal_cache_max_entries: int = Field(default=4096, ge=1)
186
+ allow_insecure: bool = Field(
187
+ default=False,
188
+ description="Accept http `issuer` and `jwks_uri` values (configured or discovered) for "
189
+ "hosts other than loopback. Signing keys fetched over http can be replaced in transit; "
190
+ "for development only.",
191
+ )
192
+
193
+ @field_validator("audience", mode="before")
194
+ @classmethod
195
+ def _audience_may_be_a_string(cls, value: Any) -> Any:
196
+ return (value,) if isinstance(value, str) else value
197
+
198
+ @field_validator("algorithms")
199
+ @classmethod
200
+ def _asymmetric_only(cls, value: tuple[str, ...]) -> tuple[str, ...]:
201
+ unsupported = sorted(set(value) - ASYMMETRIC_ALGORITHMS)
202
+ if unsupported:
203
+ raise ValueError(
204
+ f"unsupported {unsupported}; use asymmetric algorithms: "
205
+ f"{', '.join(sorted(ASYMMETRIC_ALGORITHMS))}"
206
+ )
207
+ return value
208
+
209
+ @model_validator(mode="after")
210
+ def _secure_provider_urls(self) -> Self:
211
+ for name, url in (("issuer", self.issuer), ("jwks_uri", self.jwks_uri)):
212
+ problem = None if url is None else insecure_url_problem(url, self.allow_insecure)
213
+ if problem:
214
+ raise ValueError(f"oidc.{name} {url!r} {problem}")
215
+ return self
216
+
217
+
218
+ TokenEndpointAuth = Literal["client_secret_basic", "client_secret_post", "none"]
219
+ MIN_SESSION_KEY_LENGTH = 32
220
+ _COOKIE_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
221
+ _HEADER_NAME = re.compile(r"^[A-Za-z0-9-]{1,64}$")
222
+
223
+
224
+ def url_origin(url: str) -> str:
225
+ """`scheme://host[:port]` of an absolute URL, lower-cased as browsers send it."""
226
+ parts = urlsplit(url)
227
+ return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
228
+
229
+
230
+ class SessionSettings(SectionModel):
231
+ """Sign-in of the web app: Keeper as backend-for-frontend (design 13.2).
232
+
233
+ Keeper runs the OpenID Connect authorization code flow with PKCE against the provider of
234
+ `keeper.auth.oidc`, keeps the provider tokens server-side and gives the browser an opaque
235
+ session cookie. Needs an `oidc` auth mode and a database.
236
+ """
237
+
238
+ enabled: bool = Field(
239
+ default=False,
240
+ description="Offer browser sign-in (<api_prefix>/auth/login) and accept session cookies.",
241
+ )
242
+ discovery_url: str | None = Field(
243
+ default=None,
244
+ description="Provider metadata. Null: <oidc.issuer>/.well-known/openid-configuration. "
245
+ "The document's issuer must equal oidc.issuer.",
246
+ )
247
+ client_id: str | None = Field(
248
+ default=None, description="Client id registered at the provider; REQUIRED when enabled."
249
+ )
250
+ client_secret: SecretValue | None = Field(
251
+ default=None,
252
+ description="Client secret, e.g. secret://env/LOREBASE_OIDC_CLIENT_SECRET; required "
253
+ "unless token_endpoint_auth_method is none (a public client relying on PKCE alone).",
254
+ )
255
+ token_endpoint_auth_method: TokenEndpointAuth = Field(
256
+ default="client_secret_basic",
257
+ description="How Keeper authenticates at the token endpoint: client_secret_basic | "
258
+ "client_secret_post | none.",
259
+ )
260
+ scopes: tuple[str, ...] = Field(
261
+ default=("openid", "profile", "email"),
262
+ min_length=1,
263
+ description="Requested scopes; must include openid. Add offline_access when the "
264
+ "provider issues refresh tokens only with it.",
265
+ )
266
+ redirect_uri: str | None = Field(
267
+ default=None,
268
+ description="Callback registered at the provider, sent unchanged (exact match): the "
269
+ "public URL of <api_prefix>/auth/callback, e.g. "
270
+ "https://lorebase.example.com/api/v1/auth/callback. REQUIRED when enabled.",
271
+ )
272
+ app_url: str | None = Field(
273
+ default=None,
274
+ description="Public URL of the web app, e.g. https://lorebase.example.com/. Sign-in "
275
+ "returns to a path on its origin; REQUIRED when enabled.",
276
+ )
277
+ post_logout_redirect_uri: str | None = Field(
278
+ default=None,
279
+ description="Where the provider sends the browser after sign-out (registered at the "
280
+ "provider). Null: app_url.",
281
+ )
282
+ error_path: str = Field(
283
+ default="/login",
284
+ description="Path on the app's origin that receives failed sign-ins as ?error=<code>.",
285
+ )
286
+ encryption_key: SecretValue | None = Field(
287
+ default=None,
288
+ description="Key (at least 32 characters) that encrypts the sign-in state cookie and the "
289
+ "provider tokens stored with a session, e.g. secret://env/LOREBASE_SESSION_KEY. "
290
+ "REQUIRED when enabled. Changing it voids sign-ins in progress and ends each session "
291
+ "at its next token refresh.",
292
+ )
293
+ cookie_name: str = Field(
294
+ default="lorebase_session",
295
+ description="Session cookie name; with cookie_secure the browser gets __Host-<name>.",
296
+ )
297
+ login_cookie_name: str = Field(
298
+ default="lorebase_login",
299
+ description="Short-lived cookie holding the encrypted state, nonce and PKCE verifier "
300
+ "of a sign-in in progress; __Host- prefixed with cookie_secure.",
301
+ )
302
+ cookie_secure: bool = Field(
303
+ default=True,
304
+ description="Mark cookies Secure and use the __Host- prefix. false only for plain http "
305
+ "development on loopback.",
306
+ )
307
+ same_site: Literal["lax", "strict"] = Field(
308
+ default="lax", description="SameSite attribute of the session cookie."
309
+ )
310
+ login_timeout_s: int = Field(
311
+ default=600, ge=30, le=3600, description="Time allowed to finish a sign-in."
312
+ )
313
+ idle_timeout_s: int = Field(default=3600, ge=60, description="A session unused this long ends.")
314
+ absolute_timeout_s: int = Field(
315
+ default=43200, ge=60, description="A session ends this long after sign-in, used or not."
316
+ )
317
+ touch_interval_s: float = Field(
318
+ default=60,
319
+ ge=0,
320
+ description="A session's last activity is written at most this often; the idle timeout "
321
+ "is exact to within this interval.",
322
+ )
323
+ refresh_tokens: bool = Field(
324
+ default=True,
325
+ description="Keep the provider's refresh token and use it when the access token expires: "
326
+ "a refused refresh (account disabled, consent revoked) ends the session and updated "
327
+ "group claims apply. false: the session lives by its timeouts only.",
328
+ )
329
+ refresh_default_interval_s: int = Field(
330
+ default=300,
331
+ ge=1,
332
+ description="Time to the next refresh when the token response gives no expires_in.",
333
+ )
334
+ refresh_min_interval_s: int = Field(
335
+ default=60, ge=1, description="Shortest time between two refreshes of a session."
336
+ )
337
+ refresh_lease_s: int = Field(
338
+ default=30,
339
+ ge=1,
340
+ description="One request refreshes a session at a time; after a failure the next attempt "
341
+ "waits this long. Other requests proceed with the session meanwhile.",
342
+ )
343
+ metadata_cache_ttl_s: float = Field(
344
+ default=3600, gt=0, description="How long the provider metadata document is reused."
345
+ )
346
+ csrf_header: str = Field(
347
+ default="X-CSRF-Token",
348
+ description="Header carrying the session's CSRF token on POST, PUT, PATCH and DELETE.",
349
+ )
350
+ allowed_origins: tuple[str, ...] = Field(
351
+ default=(),
352
+ description="Origins accepted in the Origin header of unsafe requests besides those of "
353
+ "app_url and redirect_uri.",
354
+ )
355
+ purge_batch: int = Field(
356
+ default=100,
357
+ ge=0,
358
+ description="Expired sessions deleted at each sign-in; 0 leaves them in the table.",
359
+ )
360
+
361
+ @field_validator("cookie_name", "login_cookie_name")
362
+ @classmethod
363
+ def _cookie_name_shape(cls, value: str) -> str:
364
+ if not _COOKIE_NAME.match(value) or value.startswith(("__Host-", "__Secure-")):
365
+ raise ValueError("must be 1 to 64 letters, digits, '_' or '-', without a prefix")
366
+ return value
367
+
368
+ @field_validator("csrf_header")
369
+ @classmethod
370
+ def _header_shape(cls, value: str) -> str:
371
+ if not _HEADER_NAME.match(value):
372
+ raise ValueError("must be a header name of letters, digits and '-'")
373
+ return value
374
+
375
+ @field_validator("error_path")
376
+ @classmethod
377
+ def _path_shape(cls, value: str) -> str:
378
+ if not is_local_path(value):
379
+ raise ValueError("must be a path starting with a single '/'")
380
+ return value
381
+
382
+ @field_validator("allowed_origins")
383
+ @classmethod
384
+ def _origins_shape(cls, value: tuple[str, ...]) -> tuple[str, ...]:
385
+ for origin in value:
386
+ parts = urlsplit(origin)
387
+ if parts.scheme not in ("http", "https") or not parts.netloc or parts.path:
388
+ raise ValueError(f"{origin!r} is not an origin (scheme://host[:port])")
389
+ return tuple(url_origin(origin) for origin in value)
390
+
391
+ @property
392
+ def secure_cookie_name(self) -> str:
393
+ """Name of the session cookie as sent to the browser."""
394
+ return f"__Host-{self.cookie_name}" if self.cookie_secure else self.cookie_name
395
+
396
+ @property
397
+ def secure_login_cookie_name(self) -> str:
398
+ """Name of the sign-in state cookie as sent to the browser."""
399
+ return f"__Host-{self.login_cookie_name}" if self.cookie_secure else self.login_cookie_name
400
+
401
+ def trusted_origins(self) -> frozenset[str]:
402
+ """Origins accepted in the Origin header of unsafe cookie-authenticated requests."""
403
+ origins = set(self.allowed_origins)
404
+ for url in (self.app_url, self.redirect_uri):
405
+ if url:
406
+ origins.add(url_origin(url))
407
+ return frozenset(origins)
408
+
409
+ def problems(self, allow_insecure: bool) -> list[str]:
410
+ """What keeps an enabled configuration from working (empty when it is usable)."""
411
+ problems = [
412
+ f"sessions.{name} is required when sessions are enabled"
413
+ for name in ("client_id", "redirect_uri", "app_url")
414
+ if not getattr(self, name)
415
+ ]
416
+ key = self.encryption_key
417
+ if key is None or len(key.get_secret_value()) < MIN_SESSION_KEY_LENGTH:
418
+ problems.append(
419
+ "sessions.encryption_key is required when sessions are enabled "
420
+ f"(at least {MIN_SESSION_KEY_LENGTH} characters)"
421
+ )
422
+ if self.token_endpoint_auth_method != "none" and self.client_secret is None:
423
+ problems.append(
424
+ "sessions.client_secret is required with token_endpoint_auth_method "
425
+ f"{self.token_endpoint_auth_method!r}"
426
+ )
427
+ if "openid" not in self.scopes:
428
+ problems.append("sessions.scopes must include openid")
429
+ if self.idle_timeout_s > self.absolute_timeout_s:
430
+ problems.append("sessions.idle_timeout_s cannot exceed absolute_timeout_s")
431
+ for name in ("discovery_url", "redirect_uri", "app_url", "post_logout_redirect_uri"):
432
+ url = getattr(self, name)
433
+ problem = None if url is None else insecure_url_problem(url, allow_insecure)
434
+ if problem:
435
+ problems.append(f"sessions.{name} {url!r} {problem}")
436
+ return problems
437
+
438
+
439
+ class AuthzSettings(SectionModel):
440
+ """Authorization: grant caching and extra roles."""
441
+
442
+ grants_cache_ttl_s: float = Field(
443
+ default=15,
444
+ ge=0,
445
+ description="How long a principal's grants in a Domain are reused. Grant changes made "
446
+ "through another Keeper process take effect after at most this long; 0 disables.",
447
+ )
448
+ grants_cache_max_entries: int = Field(default=2048, ge=1)
449
+ invisible_kinds: bool = Field(
450
+ default=True,
451
+ description="Kinds a caller's Kind-narrowed grants leave out are invisible to it, not "
452
+ "just refused: the schema it reads omits them and what tells about them, and requests "
453
+ "naming them answer like unknown names (404 or 422, never 403). False refuses them "
454
+ "with 403 naming the Kind and refuses the schema to such callers.",
455
+ )
456
+ custom_roles: dict[str, tuple[str, ...]] = Field(
457
+ default_factory=dict,
458
+ description="Extra roles: name -> permissions (see docs for the names). Built-in roles "
459
+ "cannot be redefined. Example: {contributor: [schema.read, data.read, branch.create]}.",
460
+ )
461
+
462
+ @field_validator("custom_roles")
463
+ @classmethod
464
+ def _valid_roles(cls, roles: dict[str, tuple[str, ...]]) -> dict[str, tuple[str, ...]]:
465
+ known = permission_names()
466
+ for name, permissions in roles.items():
467
+ if not _ROLE_NAME.match(name):
468
+ raise ValueError(f"role name {name!r} must match {_ROLE_NAME.pattern}")
469
+ if name in BUILTIN_ROLES:
470
+ raise ValueError(f"role {name!r} is built in and cannot be redefined")
471
+ unknown = sorted(set(permissions) - known)
472
+ if unknown:
473
+ raise ValueError(f"role {name!r} has unknown permissions {unknown}")
474
+ return roles
475
+
476
+
477
+ class ReadAuditSettings(SectionModel):
478
+ """Audit of reads (design 13.6): one record per successful read, sampled and filtered,
479
+ written in batches to the read stream of the audit log (`audit_read_log`, chains of its
480
+ own), off the request path.
481
+
482
+ A read that showed or masked a value of a sensitive Attribute (`pii`, `confidential`,
483
+ `restricted`, vault values included) is always recorded, whatever the sampling and the
484
+ filters. Records hold who, which route and Domain, the Kind when the route names one and
485
+ the outcome; never query strings, bodies or values.
486
+ """
487
+
488
+ enabled: bool = Field(default=False, description="Record reads. Needs the `database` section.")
489
+ sample_rate: float = Field(
490
+ default=1.0,
491
+ ge=0,
492
+ le=1,
493
+ description="Share of the reads that pass the filters recorded (0 to 1). Sensitive "
494
+ "reads are always recorded.",
495
+ )
496
+ principal_types: tuple[Literal["user", "service", "api_key"], ...] = Field(
497
+ default=(),
498
+ description="Record only reads by these principal types; empty records every type.",
499
+ )
500
+ kinds: tuple[str, ...] = Field(
501
+ default=(),
502
+ description="Record only reads of these Kinds (api names, as routes name them); reads "
503
+ "of routes that name no Kind are then left out. Empty records every read.",
504
+ )
505
+ queue_size: int = Field(
506
+ default=10_000,
507
+ ge=1,
508
+ description="Records waiting to be written; past it new records are dropped and "
509
+ "counted in `lorebase.audit.reads.dropped` (reason `queue_full`).",
510
+ )
511
+ batch_size: int = Field(
512
+ default=500, ge=1, description="Records written per transaction at most."
513
+ )
514
+ flush_interval_s: float = Field(
515
+ default=1.0,
516
+ gt=0,
517
+ description="Longest wait before records in the queue are written.",
518
+ )
519
+ write_timeout_s: float = Field(
520
+ default=5.0,
521
+ gt=0,
522
+ description="Time budget of one batch; with `on_overflow: drop` a batch that fails is "
523
+ "dropped and counted (reason `write_failed`).",
524
+ )
525
+ on_overflow: Literal["drop", "reject"] = Field(
526
+ default="drop",
527
+ description="What happens when a record cannot be kept. `drop`: the read is served and "
528
+ "the record dropped and counted. `reject`: a read is answered only once its record "
529
+ "holds a place in the queue (else 503 with `Retry-After`), and a batch that fails is "
530
+ "kept and retried, never dropped; for deployments where the read stream is proof.",
531
+ )
532
+ retry_after_s: int = Field(
533
+ default=1,
534
+ ge=1,
535
+ description="Retry-After of a read refused because the queue is full "
536
+ "(`on_overflow: reject`).",
537
+ )
538
+ unready_after_failures: int = Field(
539
+ default=3,
540
+ ge=1,
541
+ description="Failed attempts in a row of one batch after which readiness reports the "
542
+ "read audit as failed until a batch is written again (`on_overflow: reject`).",
543
+ )
544
+
545
+
546
+ class AuditSettings(SectionModel):
547
+ """Audit log of mutations and denied requests (design 10.4), and optionally of reads."""
548
+
549
+ enabled: bool = Field(
550
+ default=True,
551
+ description="Write the audit log. Takes effect when a database is configured.",
552
+ )
553
+ record_denied: bool = Field(
554
+ default=True,
555
+ description="Also record requests rejected with 401 or 403.",
556
+ )
557
+ write_timeout_s: float = Field(
558
+ default=2.0,
559
+ gt=0,
560
+ description="Time budget of one audit write. A failed write is logged and counted, "
561
+ "never surfaced to the client.",
562
+ )
563
+ reads: ReadAuditSettings = Field(default_factory=ReadAuditSettings)
564
+
565
+
566
+ class AuthSettings(SectionModel):
567
+ """Authentication and authorization of the API."""
568
+
569
+ mode: AuthMode = Field(
570
+ default="none",
571
+ description="none | api_key | oidc | api_key+oidc. `none` accepts every request as an "
572
+ "administrator and is for local development only.",
573
+ )
574
+ allow_insecure: bool = Field(
575
+ default=False,
576
+ description="Allow mode `none` on a bind address other than loopback (`keeper.host`). "
577
+ "Without it such a configuration is refused at start.",
578
+ )
579
+ installation_admins: tuple[str, ...] = Field(
580
+ default=(),
581
+ description="Principals allowed to administer the installation (list principals). "
582
+ "Each entry is a principal id, an identity provider subject or a group id; it grants "
583
+ "no access to Domain data.",
584
+ )
585
+ api_keys: ApiKeySettings = Field(default_factory=ApiKeySettings)
586
+ oidc: OidcSettings = Field(default_factory=OidcSettings)
587
+ authz: AuthzSettings = Field(default_factory=AuthzSettings)
588
+ audit: AuditSettings = Field(default_factory=AuditSettings)
589
+ sessions: SessionSettings = Field(default_factory=SessionSettings)
590
+
591
+ @property
592
+ def uses_api_keys(self) -> bool:
593
+ """API keys are accepted."""
594
+ return self.mode in ("api_key", "api_key+oidc")
595
+
596
+ @property
597
+ def uses_oidc(self) -> bool:
598
+ """OIDC bearer tokens are accepted."""
599
+ return self.mode in ("oidc", "api_key+oidc")
600
+
601
+ @model_validator(mode="after")
602
+ def _mode_requirements(self) -> Self:
603
+ if self.uses_api_keys:
604
+ pepper = self.api_keys.pepper
605
+ if pepper is None or len(pepper.get_secret_value()) < MIN_PEPPER_LENGTH:
606
+ raise ValueError(
607
+ f"api_keys.pepper is required with mode {self.mode!r} "
608
+ f"(at least {MIN_PEPPER_LENGTH} characters)"
609
+ )
610
+ if self.uses_oidc and not (self.oidc.issuer and self.oidc.audience):
611
+ raise ValueError(f"oidc.issuer and oidc.audience are required with mode {self.mode!r}")
612
+ if self.sessions.enabled:
613
+ if not self.uses_oidc:
614
+ raise ValueError(f"sessions need mode oidc or api_key+oidc, not {self.mode!r}")
615
+ problems = self.sessions.problems(self.oidc.allow_insecure)
616
+ if problems:
617
+ raise ValueError("; ".join(problems))
618
+ return self