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,283 @@
|
|
|
1
|
+
"""Settings of rate limiting and daily quotas (`keeper.limits.rate`, `keeper.limits.quota`).
|
|
2
|
+
|
|
3
|
+
Every value has a documented default; deployments override it in the YAML file or with
|
|
4
|
+
`LOREBASE__KEEPER__LIMITS__RATE__...` variables. See `config/lorebase.example.yaml`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from lorebase_platform.settings import SectionModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RouteClass(StrEnum):
|
|
18
|
+
"""What a request costs, as far as limits are concerned."""
|
|
19
|
+
|
|
20
|
+
READS = "reads"
|
|
21
|
+
WRITES = "writes"
|
|
22
|
+
SEARCHES = "searches"
|
|
23
|
+
CONTEXT = "context"
|
|
24
|
+
ADMIN = "admin"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BucketSettings(SectionModel):
|
|
28
|
+
"""One token bucket: it refills at `requests_per_minute` and holds at most `burst` tokens."""
|
|
29
|
+
|
|
30
|
+
requests_per_minute: float = Field(gt=0, description="Sustained rate: tokens added per minute.")
|
|
31
|
+
burst: int = Field(ge=1, description="Bucket size: requests allowed back to back.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bucket(requests_per_minute: float, burst: int) -> BucketSettings:
|
|
35
|
+
return BucketSettings(requests_per_minute=requests_per_minute, burst=burst)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ClassOverrides(SectionModel):
|
|
39
|
+
"""Buckets of one Domain that replace the installation-wide ones; unset classes keep them."""
|
|
40
|
+
|
|
41
|
+
reads: BucketSettings | None = None
|
|
42
|
+
writes: BucketSettings | None = None
|
|
43
|
+
searches: BucketSettings | None = None
|
|
44
|
+
context: BucketSettings | None = None
|
|
45
|
+
admin: BucketSettings | None = None
|
|
46
|
+
|
|
47
|
+
def get(self, route_class: RouteClass) -> BucketSettings | None:
|
|
48
|
+
"""The override of a class, if any."""
|
|
49
|
+
found: BucketSettings | None = getattr(self, route_class.value)
|
|
50
|
+
return found
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RouteClassing(SectionModel):
|
|
54
|
+
"""How requests are sorted into classes. Anything not matched is `reads` for GET, HEAD and
|
|
55
|
+
OPTIONS and `writes` for every other method."""
|
|
56
|
+
|
|
57
|
+
admin_tags: tuple[str, ...] = Field(
|
|
58
|
+
default=("admin",), description="Routes with one of these OpenAPI tags are `admin`."
|
|
59
|
+
)
|
|
60
|
+
search_suffixes: tuple[str, ...] = Field(
|
|
61
|
+
default=("/search",), description="POST routes ending in one of these are `searches`."
|
|
62
|
+
)
|
|
63
|
+
context_suffixes: tuple[str, ...] = Field(
|
|
64
|
+
default=("/context",), description="POST routes ending in one of these are `context`."
|
|
65
|
+
)
|
|
66
|
+
read_post_suffixes: tuple[str, ...] = Field(
|
|
67
|
+
default=("/query", "/traverse", "/neighborhood"),
|
|
68
|
+
description="POST routes ending in one of these only read: they are `reads`.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def only_reads(self, method: str, route_path: str) -> bool:
|
|
72
|
+
"""Whether a request only reads: GET, HEAD, or a POST search, context or read route
|
|
73
|
+
(the reads recorded by `keeper.auth.audit.reads`)."""
|
|
74
|
+
if method in ("GET", "HEAD"):
|
|
75
|
+
return True
|
|
76
|
+
return method == "POST" and route_path.endswith(
|
|
77
|
+
(*self.search_suffixes, *self.context_suffixes, *self.read_post_suffixes)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AuthFailureSettings(BucketSettings):
|
|
82
|
+
"""Buckets of failed authentications: one per client address and, unless
|
|
83
|
+
`per_key_prefix_enabled` is off, one per API key prefix.
|
|
84
|
+
|
|
85
|
+
Only requests answered 401 after presenting a credential take a token. While either bucket
|
|
86
|
+
of a request is empty it is answered 429 before its credential is checked.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
requests_per_minute: float = Field(
|
|
90
|
+
default=30, gt=0, description="Failed attempts regained per minute."
|
|
91
|
+
)
|
|
92
|
+
burst: int = Field(default=30, ge=1, description="Failed attempts allowed back to back.")
|
|
93
|
+
ipv6_prefix_length: int = Field(
|
|
94
|
+
default=64,
|
|
95
|
+
ge=1,
|
|
96
|
+
le=128,
|
|
97
|
+
description="IPv6 clients share the bucket of their network of this length, since one "
|
|
98
|
+
"client usually holds a whole /64.",
|
|
99
|
+
)
|
|
100
|
+
per_key_prefix_enabled: bool = Field(
|
|
101
|
+
default=True,
|
|
102
|
+
description="Keep a bucket per presented API key prefix. Prefixes are not secret, so "
|
|
103
|
+
"whoever knows one can keep that key throttled; turn this off to count failures by "
|
|
104
|
+
"client address only (the per-address bucket is always on).",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ThrottleAuditSettings(SectionModel):
|
|
109
|
+
"""Audit entry for a principal that keeps being throttled."""
|
|
110
|
+
|
|
111
|
+
threshold: int = Field(
|
|
112
|
+
default=20,
|
|
113
|
+
ge=0,
|
|
114
|
+
description="Refusals within `window_s` that write one audit entry (`rate_limit."
|
|
115
|
+
"throttled`); the count then starts again. 0 disables.",
|
|
116
|
+
)
|
|
117
|
+
window_s: float = Field(default=300, gt=0, description="Length of the counting window.")
|
|
118
|
+
max_tracked: int = Field(default=10_000, ge=1, description="Principals counted at once.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PostgresRateSettings(SectionModel):
|
|
122
|
+
"""The shared backend (`backend: postgres`): buckets in the UNLOGGED `rate_limit_buckets`
|
|
123
|
+
table of the application database, leased to each Keeper a few tokens at a time."""
|
|
124
|
+
|
|
125
|
+
lease_tokens: int = Field(
|
|
126
|
+
default=10,
|
|
127
|
+
ge=1,
|
|
128
|
+
description="Tokens a Keeper takes from a shared bucket per round trip and then serves "
|
|
129
|
+
"from memory; at most a quarter of the bucket (at least 1). Larger leases mean fewer "
|
|
130
|
+
"queries; unused leased tokens are lost when the lease expires, which only makes the "
|
|
131
|
+
"limit stricter. Failed-authentication buckets always take one token at a time.",
|
|
132
|
+
)
|
|
133
|
+
lease_ttl_s: float = Field(
|
|
134
|
+
default=2.0,
|
|
135
|
+
gt=0,
|
|
136
|
+
description="Leased tokens not used within this time are dropped, so an idle Keeper "
|
|
137
|
+
"does not hold a principal's tokens back.",
|
|
138
|
+
)
|
|
139
|
+
timeout_s: float = Field(
|
|
140
|
+
default=0.25,
|
|
141
|
+
gt=0,
|
|
142
|
+
description="Time budget of one query to the shared buckets; slower counts as failed.",
|
|
143
|
+
)
|
|
144
|
+
on_error: Literal["local", "allow", "deny"] = Field(
|
|
145
|
+
default="local",
|
|
146
|
+
description="When the database fails or is slow: `local` enforces the limits in this "
|
|
147
|
+
"process meanwhile (as backend local), `allow` lets the request through, `deny` "
|
|
148
|
+
"answers 429. Failed-authentication buckets always fail closed (429).",
|
|
149
|
+
)
|
|
150
|
+
error_retry_after_s: int = Field(
|
|
151
|
+
default=1,
|
|
152
|
+
ge=1,
|
|
153
|
+
description="Retry-After of a request refused because the shared buckets could not be "
|
|
154
|
+
"read (`deny`, and failed-authentication buckets).",
|
|
155
|
+
)
|
|
156
|
+
check_cache_s: float = Field(
|
|
157
|
+
default=0.5,
|
|
158
|
+
ge=0,
|
|
159
|
+
description="How long a reading of a failed-authentication bucket serves the checks "
|
|
160
|
+
"made before each authentication (which take nothing). Failures themselves are always "
|
|
161
|
+
"written at once; with N Keepers a client may get up to (N-1) x this much of the "
|
|
162
|
+
"failure rate beyond the limit.",
|
|
163
|
+
)
|
|
164
|
+
auth_failures_timeout_s: float = Field(
|
|
165
|
+
default=1.0,
|
|
166
|
+
gt=0,
|
|
167
|
+
description="Time budget of a query of a failed-authentication bucket, waiting for a "
|
|
168
|
+
"pooled connection included. Past it the bucket fails closed.",
|
|
169
|
+
)
|
|
170
|
+
stale_reading_s: float = Field(
|
|
171
|
+
default=30.0,
|
|
172
|
+
ge=0,
|
|
173
|
+
description="While failed-authentication buckets cannot be read, a reading of the same "
|
|
174
|
+
"bucket at most this old still decides; only clients without one are refused (429).",
|
|
175
|
+
)
|
|
176
|
+
prune_interval_s: float = Field(
|
|
177
|
+
default=60.0,
|
|
178
|
+
gt=0,
|
|
179
|
+
description="How often each Keeper deletes rows of buckets that are full again.",
|
|
180
|
+
)
|
|
181
|
+
prune_batch: int = Field(default=1000, ge=1, description="Rows deleted per prune.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RateLimitSettings(SectionModel):
|
|
185
|
+
"""Token buckets per principal and request class (design 13.6)."""
|
|
186
|
+
|
|
187
|
+
enabled: bool = Field(default=True, description="Apply rate limits.")
|
|
188
|
+
backend: Literal["local", "memory", "postgres"] = Field(
|
|
189
|
+
default="local",
|
|
190
|
+
description="Where bucket state lives. `local` (`memory` is an alias) is per process: "
|
|
191
|
+
"with N Keeper processes a principal may use up to N times the limits. `postgres` "
|
|
192
|
+
"shares the buckets of every Keeper through the application database (see "
|
|
193
|
+
"`postgres`); use it with more than one Keeper.",
|
|
194
|
+
)
|
|
195
|
+
postgres: PostgresRateSettings = Field(default_factory=PostgresRateSettings)
|
|
196
|
+
max_buckets: int = Field(
|
|
197
|
+
default=100_000, ge=1, description="Buckets kept in memory; the least recently used go."
|
|
198
|
+
)
|
|
199
|
+
exempt_paths: tuple[str, ...] = Field(
|
|
200
|
+
default=("/healthz", "/readyz"),
|
|
201
|
+
description="Route paths (without the API prefix) that are never limited or counted.",
|
|
202
|
+
)
|
|
203
|
+
reads: BucketSettings = Field(default_factory=lambda: _bucket(1200, 200))
|
|
204
|
+
writes: BucketSettings = Field(default_factory=lambda: _bucket(300, 60))
|
|
205
|
+
searches: BucketSettings = Field(default_factory=lambda: _bucket(300, 60))
|
|
206
|
+
context: BucketSettings = Field(default_factory=lambda: _bucket(120, 30))
|
|
207
|
+
admin: BucketSettings = Field(default_factory=lambda: _bucket(120, 30))
|
|
208
|
+
auth_failures: AuthFailureSettings = Field(
|
|
209
|
+
default_factory=AuthFailureSettings,
|
|
210
|
+
description="Failed authentications per client address and per API key prefix.",
|
|
211
|
+
)
|
|
212
|
+
domains: dict[str, ClassOverrides] = Field(
|
|
213
|
+
default_factory=dict,
|
|
214
|
+
description="Per-Domain overrides by Domain id; requests in these Domains use their own "
|
|
215
|
+
"buckets (not shared with the principal's other Domains).",
|
|
216
|
+
)
|
|
217
|
+
routes: RouteClassing = Field(default_factory=RouteClassing)
|
|
218
|
+
throttle_audit: ThrottleAuditSettings = Field(default_factory=ThrottleAuditSettings)
|
|
219
|
+
|
|
220
|
+
def bucket(self, route_class: RouteClass, domain_id: str | None = None) -> BucketSettings:
|
|
221
|
+
"""The bucket for a class, from the Domain's overrides when it has one."""
|
|
222
|
+
override = self.domains.get(domain_id) if domain_id else None
|
|
223
|
+
found = override.get(route_class) if override else None
|
|
224
|
+
if found is not None:
|
|
225
|
+
return found
|
|
226
|
+
installation: BucketSettings = getattr(self, route_class.value)
|
|
227
|
+
return installation
|
|
228
|
+
|
|
229
|
+
def has_override(self, route_class: RouteClass, domain_id: str | None) -> bool:
|
|
230
|
+
"""Whether the Domain overrides the bucket of this class."""
|
|
231
|
+
override = self.domains.get(domain_id) if domain_id else None
|
|
232
|
+
return override is not None and override.get(route_class) is not None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class KeyQuota(SectionModel):
|
|
236
|
+
"""Quota of one API key; a member left out keeps the installation-wide value, `null`
|
|
237
|
+
removes the limit."""
|
|
238
|
+
|
|
239
|
+
requests_per_day: int | None = Field(default=None, ge=1)
|
|
240
|
+
context_tokens_per_day: int | None = Field(default=None, ge=1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class QuotaSettings(SectionModel):
|
|
244
|
+
"""Daily quotas per API key, counted in UTC days (design 13.6)."""
|
|
245
|
+
|
|
246
|
+
enabled: bool = Field(
|
|
247
|
+
default=True, description="Count the use of API keys. Needs a database; users are exempt."
|
|
248
|
+
)
|
|
249
|
+
requests_per_day: int | None = Field(
|
|
250
|
+
default=100_000, ge=1, description="Requests per key and day; null: unlimited."
|
|
251
|
+
)
|
|
252
|
+
context_tokens_per_day: int | None = Field(
|
|
253
|
+
default=2_000_000,
|
|
254
|
+
ge=1,
|
|
255
|
+
description="Estimated tokens of context packs per key and day; null: unlimited. A pack "
|
|
256
|
+
"that starts under the limit is delivered whole, so a day may end slightly above it.",
|
|
257
|
+
)
|
|
258
|
+
flush_interval_s: float = Field(
|
|
259
|
+
default=5,
|
|
260
|
+
gt=0,
|
|
261
|
+
description="How often a process writes the use it has counted. Several processes "
|
|
262
|
+
"enforce a quota on what they know, so a key may exceed it by the use of one interval.",
|
|
263
|
+
)
|
|
264
|
+
keys: dict[str, KeyQuota] = Field(
|
|
265
|
+
default_factory=dict,
|
|
266
|
+
description="Overrides by API key id, as listed by the Domain's API key endpoint.",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def requests_limit(self, key_id: str) -> int | None:
|
|
270
|
+
"""Requests per day allowed to a key."""
|
|
271
|
+
return self._limit(key_id, "requests_per_day")
|
|
272
|
+
|
|
273
|
+
def tokens_limit(self, key_id: str) -> int | None:
|
|
274
|
+
"""Context-pack tokens per day allowed to a key."""
|
|
275
|
+
return self._limit(key_id, "context_tokens_per_day")
|
|
276
|
+
|
|
277
|
+
def _limit(self, key_id: str, member: str) -> int | None:
|
|
278
|
+
override = self.keys.get(key_id)
|
|
279
|
+
if override is not None and member in override.model_fields_set:
|
|
280
|
+
value: int | None = getattr(override, member)
|
|
281
|
+
return value
|
|
282
|
+
default: int | None = getattr(self, member)
|
|
283
|
+
return default
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Enforcement of rate limits and quotas on requests.
|
|
2
|
+
|
|
3
|
+
`enforce_limits` is an application-wide dependency: for every route that is not exempt it
|
|
4
|
+
authenticates the caller (refusing clients whose failed authentications used up their
|
|
5
|
+
`auth_failures` buckets before their credential is checked), takes a token from the caller's
|
|
6
|
+
bucket for the request's class and, for API keys, counts the request against the daily quota.
|
|
7
|
+
Refusals are 429 problem details with `Retry-After`. `RateLimitHeadersMiddleware` adds the
|
|
8
|
+
`RateLimit-*` headers of the bucket to every response of a limited route, errors included.
|
|
9
|
+
Context-pack tokens are counted by the route that builds the pack (`check_context_quota`,
|
|
10
|
+
`charge_context_tokens`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from fastapi import Request, Response
|
|
21
|
+
from opentelemetry.metrics import MeterProvider
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
23
|
+
from starlette.datastructures import MutableHeaders
|
|
24
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
25
|
+
|
|
26
|
+
from lorebase_keeper.auth.audit import suppress
|
|
27
|
+
from lorebase_keeper.auth.deps import current_principal, get_auth
|
|
28
|
+
from lorebase_keeper.auth.principals import Principal
|
|
29
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
30
|
+
from lorebase_keeper.auth.sessions import PUBLIC_PATHS
|
|
31
|
+
from lorebase_keeper.client_address import client_address
|
|
32
|
+
from lorebase_keeper.errors import RATE_LIMITED, UNAUTHORIZED, ProblemError, ProblemType
|
|
33
|
+
from lorebase_keeper.ratelimit.bucket import Decision, InMemoryRateStore
|
|
34
|
+
from lorebase_keeper.ratelimit.config import QuotaSettings, RateLimitSettings, RouteClass
|
|
35
|
+
from lorebase_keeper.ratelimit.limiter import AUTH_FAILURES, RateLimiter, ThrottleWatch
|
|
36
|
+
from lorebase_keeper.ratelimit.quotas import QuotaKind, QuotaSnapshot, QuotaTracker
|
|
37
|
+
from lorebase_keeper.ratelimit.shared import PostgresRateStore
|
|
38
|
+
from lorebase_platform.settings import ConfigError
|
|
39
|
+
from lorebase_store.auth import NewAuditEntry
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("lorebase.keeper.limits")
|
|
42
|
+
|
|
43
|
+
QUOTA_EXCEEDED = ProblemType("quota-exceeded", 429, "Daily quota exceeded")
|
|
44
|
+
STATE_KEY = "rate_limit"
|
|
45
|
+
THROTTLED_ACTION = "rate_limit.throttled"
|
|
46
|
+
LIMIT_HEADER = "RateLimit-Limit"
|
|
47
|
+
REMAINING_HEADER = "RateLimit-Remaining"
|
|
48
|
+
RESET_HEADER = "RateLimit-Reset"
|
|
49
|
+
RETRY_HEADER = "Retry-After"
|
|
50
|
+
EXPOSED_HEADERS = (LIMIT_HEADER, REMAINING_HEADER, RESET_HEADER, RETRY_HEADER)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class LimitRuntime:
|
|
55
|
+
"""The limiting components of one application."""
|
|
56
|
+
|
|
57
|
+
rate: RateLimitSettings
|
|
58
|
+
quota: QuotaSettings
|
|
59
|
+
limiter: RateLimiter | None
|
|
60
|
+
quotas: QuotaTracker | None
|
|
61
|
+
watch: ThrottleWatch | None
|
|
62
|
+
auth: AuthRuntime
|
|
63
|
+
stores: tuple[PostgresRateStore, ...] = ()
|
|
64
|
+
|
|
65
|
+
def exempt(self, route_path: str) -> bool:
|
|
66
|
+
"""Whether the route (path template without the API prefix) is neither limited nor
|
|
67
|
+
counted."""
|
|
68
|
+
return route_path in self.rate.exempt_paths
|
|
69
|
+
|
|
70
|
+
async def aclose(self) -> None:
|
|
71
|
+
"""Write the counted use that is still in memory."""
|
|
72
|
+
if self.quotas is not None:
|
|
73
|
+
await self.quotas.aclose()
|
|
74
|
+
for store in self.stores:
|
|
75
|
+
await store.aclose()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _shared_stores(
|
|
79
|
+
rate: RateLimitSettings,
|
|
80
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
81
|
+
meter_provider: MeterProvider | None,
|
|
82
|
+
) -> tuple[PostgresRateStore, PostgresRateStore]:
|
|
83
|
+
"""The shared stores of principals' buckets and of failed authentications."""
|
|
84
|
+
principals = PostgresRateStore(
|
|
85
|
+
sessions,
|
|
86
|
+
rate.postgres,
|
|
87
|
+
max_buckets=rate.max_buckets,
|
|
88
|
+
fallback=InMemoryRateStore(rate.max_buckets),
|
|
89
|
+
prune=True,
|
|
90
|
+
label="principals",
|
|
91
|
+
meter_provider=meter_provider,
|
|
92
|
+
)
|
|
93
|
+
failures = PostgresRateStore(
|
|
94
|
+
sessions,
|
|
95
|
+
rate.postgres,
|
|
96
|
+
max_buckets=rate.max_buckets,
|
|
97
|
+
fail_closed=True,
|
|
98
|
+
lease_tokens=1,
|
|
99
|
+
label=AUTH_FAILURES,
|
|
100
|
+
meter_provider=meter_provider,
|
|
101
|
+
)
|
|
102
|
+
return principals, failures
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_limits(
|
|
106
|
+
rate: RateLimitSettings,
|
|
107
|
+
quota: QuotaSettings,
|
|
108
|
+
auth: AuthRuntime,
|
|
109
|
+
*,
|
|
110
|
+
sessions: async_sessionmaker[AsyncSession] | None = None,
|
|
111
|
+
meter_provider: MeterProvider | None = None,
|
|
112
|
+
) -> LimitRuntime | None:
|
|
113
|
+
"""The components for the settings; `None` when neither limit applies. The `postgres`
|
|
114
|
+
backend needs `sessions` on the application database."""
|
|
115
|
+
limiter: RateLimiter | None = None
|
|
116
|
+
stores: tuple[PostgresRateStore, ...] = ()
|
|
117
|
+
if rate.enabled and rate.backend == "postgres":
|
|
118
|
+
if sessions is None:
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
[
|
|
121
|
+
"keeper.limits.rate.backend: postgres needs a database; configure the database "
|
|
122
|
+
"section or use backend local"
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
stores = _shared_stores(rate, sessions, meter_provider)
|
|
126
|
+
limiter = RateLimiter(
|
|
127
|
+
rate, stores[0], failure_store=stores[1], meter_provider=meter_provider
|
|
128
|
+
)
|
|
129
|
+
elif rate.enabled:
|
|
130
|
+
limiter = RateLimiter(
|
|
131
|
+
rate, InMemoryRateStore(rate.max_buckets), meter_provider=meter_provider
|
|
132
|
+
)
|
|
133
|
+
quotas = (
|
|
134
|
+
QuotaTracker(quota, auth.store, clock=auth.clock, meter_provider=meter_provider)
|
|
135
|
+
if quota.enabled and auth.store is not None and auth.settings.uses_api_keys
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
if limiter is None and quotas is None:
|
|
139
|
+
return None
|
|
140
|
+
audit = rate.throttle_audit
|
|
141
|
+
watch = (
|
|
142
|
+
ThrottleWatch(audit.threshold, audit.window_s, audit.max_tracked)
|
|
143
|
+
if audit.threshold > 0
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
146
|
+
return LimitRuntime(rate, quota, limiter, quotas, watch, auth, stores)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def limits_of(request: Request) -> LimitRuntime | None:
|
|
150
|
+
"""The application's limiting components, if any limit is on."""
|
|
151
|
+
return cast("LimitRuntime | None", getattr(request.app.state, "limits", None))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def principal_label(principal: Principal) -> str:
|
|
155
|
+
"""The key of a principal's buckets: the API key id, else the principal id."""
|
|
156
|
+
return f"key:{principal.key_id}" if principal.key_id else f"sub:{principal.id}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def route_path(request: Request) -> str:
|
|
160
|
+
"""The route template of the request without the API prefix, whether the router reports
|
|
161
|
+
it with the prefix or not (as `exempt_paths` and `PUBLIC_PATHS` name routes)."""
|
|
162
|
+
template = getattr(request.scope.get("route"), "path", None)
|
|
163
|
+
if not isinstance(template, str):
|
|
164
|
+
template = str(request.scope["path"])
|
|
165
|
+
keeper = getattr(request.app.state, "keeper_settings", None)
|
|
166
|
+
prefix = str(getattr(keeper, "api_prefix", "") or "")
|
|
167
|
+
if prefix and (template == prefix or template.startswith(prefix + "/")):
|
|
168
|
+
return template[len(prefix) :] or "/"
|
|
169
|
+
return template
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _limit_by_address(request: Request, runtime: LimitRuntime, path: str) -> None:
|
|
173
|
+
"""Sign-in routes work without a credential: their buckets are per client address."""
|
|
174
|
+
if runtime.limiter is None:
|
|
175
|
+
return
|
|
176
|
+
address = client_address(request)
|
|
177
|
+
route_class = runtime.limiter.classify(request.method, path)
|
|
178
|
+
decision = await runtime.limiter.check(f"addr:{address or 'unknown'}", route_class, None)
|
|
179
|
+
request.scope.setdefault("state", {})[STATE_KEY] = decision
|
|
180
|
+
if not decision.allowed:
|
|
181
|
+
suppress(request)
|
|
182
|
+
raise ProblemError(
|
|
183
|
+
RATE_LIMITED,
|
|
184
|
+
f"Rate limit reached for {route_class.value} requests. "
|
|
185
|
+
f"Retry in {decision.retry_after_s} s.",
|
|
186
|
+
extensions={"class": route_class.value, "retry_after_s": decision.retry_after_s},
|
|
187
|
+
headers={RETRY_HEADER: str(decision.retry_after_s)},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def enforce_limits(request: Request) -> None:
|
|
192
|
+
"""Application-wide dependency; see the module documentation."""
|
|
193
|
+
runtime = limits_of(request)
|
|
194
|
+
if runtime is None:
|
|
195
|
+
return
|
|
196
|
+
route = request.scope.get("route")
|
|
197
|
+
path = route_path(request)
|
|
198
|
+
if runtime.exempt(path):
|
|
199
|
+
return
|
|
200
|
+
if path in PUBLIC_PATHS:
|
|
201
|
+
await _limit_by_address(request, runtime, path)
|
|
202
|
+
return
|
|
203
|
+
principal = await _authenticate(request, runtime)
|
|
204
|
+
if runtime.limiter is not None:
|
|
205
|
+
route_class = runtime.limiter.classify(
|
|
206
|
+
request.method, path, getattr(route, "tags", None) or ()
|
|
207
|
+
)
|
|
208
|
+
domain_id = request.path_params.get("domain_id")
|
|
209
|
+
decision = await runtime.limiter.check(principal_label(principal), route_class, domain_id)
|
|
210
|
+
request.scope.setdefault("state", {})[STATE_KEY] = decision
|
|
211
|
+
if not decision.allowed:
|
|
212
|
+
await _refuse(
|
|
213
|
+
request,
|
|
214
|
+
runtime,
|
|
215
|
+
principal,
|
|
216
|
+
reason=route_class.value,
|
|
217
|
+
problem=ProblemError(
|
|
218
|
+
RATE_LIMITED,
|
|
219
|
+
f"Rate limit reached for {route_class.value} requests. "
|
|
220
|
+
f"Retry in {decision.retry_after_s} s.",
|
|
221
|
+
extensions={
|
|
222
|
+
"class": route_class.value,
|
|
223
|
+
"retry_after_s": decision.retry_after_s,
|
|
224
|
+
},
|
|
225
|
+
headers={RETRY_HEADER: str(decision.retry_after_s)},
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
if runtime.quotas is not None and principal.key_id is not None:
|
|
229
|
+
refused, snapshot = await runtime.quotas.admit(principal.key_id)
|
|
230
|
+
if refused is not None:
|
|
231
|
+
await _refuse(
|
|
232
|
+
request,
|
|
233
|
+
runtime,
|
|
234
|
+
principal,
|
|
235
|
+
reason=f"quota:{refused}",
|
|
236
|
+
problem=quota_problem(refused, snapshot, runtime.auth),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _authenticate(request: Request, runtime: LimitRuntime) -> Principal:
|
|
241
|
+
"""Authenticate the request, unless its client address or API key prefix has used up its
|
|
242
|
+
failed attempts: then 429, before any lookup or signature check. A 401 takes one failed
|
|
243
|
+
attempt from both buckets; success takes nothing."""
|
|
244
|
+
auth = get_auth(request)
|
|
245
|
+
limiter = runtime.limiter
|
|
246
|
+
if limiter is None or not auth.authenticator.presents_credentials(request.headers):
|
|
247
|
+
return await current_principal(request, auth)
|
|
248
|
+
address = client_address(request)
|
|
249
|
+
prefix = auth.authenticator.key_prefix(request.headers)
|
|
250
|
+
keys = limiter.failure_keys(address, prefix)
|
|
251
|
+
refused = await limiter.failures_exhausted(keys)
|
|
252
|
+
if refused is not None:
|
|
253
|
+
suppress(request)
|
|
254
|
+
logger.debug(
|
|
255
|
+
"authentication throttled",
|
|
256
|
+
extra={"client_address": str(address) if address else None, "key_prefix": prefix},
|
|
257
|
+
)
|
|
258
|
+
raise ProblemError(
|
|
259
|
+
RATE_LIMITED,
|
|
260
|
+
f"Too many failed authentication attempts. Retry in {refused.retry_after_s} s.",
|
|
261
|
+
extensions={"class": AUTH_FAILURES, "retry_after_s": refused.retry_after_s},
|
|
262
|
+
headers={RETRY_HEADER: str(refused.retry_after_s)},
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
return await current_principal(request, auth)
|
|
266
|
+
except ProblemError as exc:
|
|
267
|
+
if exc.problem is UNAUTHORIZED:
|
|
268
|
+
await limiter.record_failure(keys)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def quota_problem(kind: QuotaKind, snapshot: QuotaSnapshot, auth: AuthRuntime) -> ProblemError:
|
|
273
|
+
"""429 for a used-up daily quota, with the time until it resets."""
|
|
274
|
+
wait = max(1, math.ceil((snapshot.resets_at - auth.clock()).total_seconds()))
|
|
275
|
+
limit = snapshot.requests_limit if kind == "requests" else snapshot.tokens_limit
|
|
276
|
+
return ProblemError(
|
|
277
|
+
QUOTA_EXCEEDED,
|
|
278
|
+
f"The daily quota of {limit} {kind.replace('_', ' ')} is used up. "
|
|
279
|
+
f"It resets at {snapshot.resets_at.isoformat()}.",
|
|
280
|
+
extensions={
|
|
281
|
+
"quota": kind,
|
|
282
|
+
"limit": limit,
|
|
283
|
+
"resets_at": snapshot.resets_at.isoformat(),
|
|
284
|
+
"retry_after_s": wait,
|
|
285
|
+
},
|
|
286
|
+
headers={RETRY_HEADER: str(wait)},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _refuse(
|
|
291
|
+
request: Request,
|
|
292
|
+
runtime: LimitRuntime,
|
|
293
|
+
principal: Principal,
|
|
294
|
+
*,
|
|
295
|
+
reason: str,
|
|
296
|
+
problem: ProblemError,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Report the refusal for the audit log and raise it."""
|
|
299
|
+
suppress(request)
|
|
300
|
+
if runtime.watch is not None and (count := runtime.watch.record(principal_label(principal))):
|
|
301
|
+
details: dict[str, Any] = {
|
|
302
|
+
"reason": reason,
|
|
303
|
+
"refusals": count,
|
|
304
|
+
"window_s": runtime.rate.throttle_audit.window_s,
|
|
305
|
+
"method": request.method,
|
|
306
|
+
}
|
|
307
|
+
if principal.key_id:
|
|
308
|
+
details["api_key_id"] = principal.key_id
|
|
309
|
+
domain_id = request.path_params.get("domain_id")
|
|
310
|
+
logger.warning("principal throttled repeatedly", extra={"principal": principal.id})
|
|
311
|
+
writer = runtime.auth.audit_writer
|
|
312
|
+
if writer is not None:
|
|
313
|
+
await writer.write(
|
|
314
|
+
NewAuditEntry(
|
|
315
|
+
actor=principal.id,
|
|
316
|
+
action=THROTTLED_ACTION,
|
|
317
|
+
domain_id=str(domain_id) if domain_id else None,
|
|
318
|
+
target=principal.key_id,
|
|
319
|
+
details=details,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
raise problem
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def check_context_quota(request: Request, principal: Principal) -> None:
|
|
326
|
+
"""429 when the API key has used up its daily context-pack tokens."""
|
|
327
|
+
runtime = limits_of(request)
|
|
328
|
+
if runtime is None or runtime.quotas is None or principal.key_id is None:
|
|
329
|
+
return
|
|
330
|
+
snapshot = await runtime.quotas.check_tokens(principal.key_id)
|
|
331
|
+
if snapshot is not None:
|
|
332
|
+
await _refuse(
|
|
333
|
+
request,
|
|
334
|
+
runtime,
|
|
335
|
+
principal,
|
|
336
|
+
reason="quota:context_tokens",
|
|
337
|
+
problem=quota_problem("context_tokens", snapshot, runtime.auth),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def charge_context_tokens(
|
|
342
|
+
request: Request, principal: Principal, response: Response, chars_per_token: float
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Count the tokens of a delivered context pack against the API key's daily quota."""
|
|
345
|
+
runtime = limits_of(request)
|
|
346
|
+
if runtime is None or runtime.quotas is None or principal.key_id is None:
|
|
347
|
+
return
|
|
348
|
+
if response.status_code != 200:
|
|
349
|
+
return
|
|
350
|
+
tokens = math.ceil(len(response.body) / chars_per_token)
|
|
351
|
+
await runtime.quotas.charge_tokens(principal.key_id, tokens)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class RateLimitHeadersMiddleware:
|
|
355
|
+
"""Adds `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the bucket
|
|
356
|
+
is full) to the responses of limited routes."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
359
|
+
self.app = app
|
|
360
|
+
|
|
361
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
362
|
+
if scope["type"] != "http":
|
|
363
|
+
await self.app(scope, receive, send)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
async def send_with_headers(message: Message) -> None:
|
|
367
|
+
if message["type"] == "http.response.start":
|
|
368
|
+
decision = (scope.get("state") or {}).get(STATE_KEY)
|
|
369
|
+
if isinstance(decision, Decision):
|
|
370
|
+
headers = MutableHeaders(scope=message)
|
|
371
|
+
headers[LIMIT_HEADER] = str(decision.limit)
|
|
372
|
+
headers[REMAINING_HEADER] = str(decision.remaining)
|
|
373
|
+
headers[RESET_HEADER] = str(decision.reset_s)
|
|
374
|
+
if not decision.allowed:
|
|
375
|
+
headers[RETRY_HEADER] = str(decision.retry_after_s)
|
|
376
|
+
await send(message)
|
|
377
|
+
|
|
378
|
+
await self.app(scope, receive, send_with_headers)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
__all__ = [
|
|
382
|
+
"EXPOSED_HEADERS",
|
|
383
|
+
"QUOTA_EXCEEDED",
|
|
384
|
+
"THROTTLED_ACTION",
|
|
385
|
+
"LimitRuntime",
|
|
386
|
+
"RateLimitHeadersMiddleware",
|
|
387
|
+
"RouteClass",
|
|
388
|
+
"build_limits",
|
|
389
|
+
"charge_context_tokens",
|
|
390
|
+
"check_context_quota",
|
|
391
|
+
"enforce_limits",
|
|
392
|
+
"limits_of",
|
|
393
|
+
]
|