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
|
@@ -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
|