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,279 @@
|
|
|
1
|
+
"""Preview of a candidate Derivation on sample Records (design 5.8), without writing.
|
|
2
|
+
|
|
3
|
+
The candidate is a schema change set (`services.schema.ChangeSetInput`) naming the Derivation
|
|
4
|
+
to evaluate: the schema it produces is validated with the rules of schema changes
|
|
5
|
+
(`schema.expression_limits` included), then the Derivation is evaluated on a few Records of its
|
|
6
|
+
Kind at the branch head, from the stored and derived values of the branch, with the evaluator
|
|
7
|
+
the materializer uses (`lorebase_store.materialize.derived`). Nothing is stored.
|
|
8
|
+
|
|
9
|
+
What the caller may see follows Record reads: the Kinds the Derivation touches (its own, the
|
|
10
|
+
far Kind of an aggregate, the Kinds reached through `links.<relation>`) must be readable, and
|
|
11
|
+
the value is masked when the computed Attribute is sensitive in the schema read, in the default
|
|
12
|
+
branch's or in the candidate, which carries the tags of everything the Derivation may read
|
|
13
|
+
(`lorebase_core.expr.sensitivity`), unless the caller is cleared for each tag on the
|
|
14
|
+
Derivation's Kind and on every Kind the tag comes from (`tag_origins`: a value computed from a
|
|
15
|
+
sensitive Attribute of a far Kind tells about its Records). A masked value only says whether
|
|
16
|
+
it failed: no error code, no message.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from pydantic import JsonValue
|
|
25
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
26
|
+
|
|
27
|
+
from lorebase_core.expr import (
|
|
28
|
+
CompiledDerivation,
|
|
29
|
+
ExprLimits,
|
|
30
|
+
compile_derivation,
|
|
31
|
+
derivation_reads,
|
|
32
|
+
links_read,
|
|
33
|
+
)
|
|
34
|
+
from lorebase_core.expr.sensitivity import schema_tags, tag_origins
|
|
35
|
+
from lorebase_core.expr.visibility import element_ids
|
|
36
|
+
from lorebase_core.model import (
|
|
37
|
+
AggregateDerivation,
|
|
38
|
+
Attribute,
|
|
39
|
+
Derivation,
|
|
40
|
+
ExpressionDerivation,
|
|
41
|
+
Kind,
|
|
42
|
+
LinkDirection,
|
|
43
|
+
Schema,
|
|
44
|
+
Sensitivity,
|
|
45
|
+
)
|
|
46
|
+
from lorebase_core.validate import SchemaRules
|
|
47
|
+
from lorebase_keeper.auth.read_audit import note_sensitive
|
|
48
|
+
from lorebase_keeper.reading.points import ReadPoint
|
|
49
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
50
|
+
from lorebase_keeper.services.errors import Finding
|
|
51
|
+
from lorebase_keeper.services.schema import (
|
|
52
|
+
ChangeSetInput,
|
|
53
|
+
ChangeSetInvalidError,
|
|
54
|
+
static_issues,
|
|
55
|
+
target_schema,
|
|
56
|
+
)
|
|
57
|
+
from lorebase_store.materialize.derived import DerivedValues
|
|
58
|
+
from lorebase_store.repositories import MaterializedRepository
|
|
59
|
+
|
|
60
|
+
DERIVATION_NOT_FOUND = "derivation.not_found"
|
|
61
|
+
DERIVATION_UNSUPPORTED = "derivation.unsupported"
|
|
62
|
+
DERIVATION_ID_UNAVAILABLE = "derivation.id_unavailable"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CandidateInvalidError(Exception):
|
|
66
|
+
"""The candidate cannot be evaluated; `issues` locate why."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, issues: Sequence[Finding]) -> None:
|
|
69
|
+
super().__init__(f"{len(issues)} issue(s)")
|
|
70
|
+
self.issues = list(issues)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Candidate:
|
|
75
|
+
"""A validated candidate: the schema the change set asks for and the Derivation in it."""
|
|
76
|
+
|
|
77
|
+
schema: Schema
|
|
78
|
+
derivation: Derivation
|
|
79
|
+
kind: Kind
|
|
80
|
+
attribute: Attribute
|
|
81
|
+
compiled: CompiledDerivation
|
|
82
|
+
limits: ExprLimits
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def touched(self) -> frozenset[str]:
|
|
86
|
+
"""Ids of the Kinds the Derivation reads Records of, its own included."""
|
|
87
|
+
return touched_kinds(self.schema, self.derivation, self.compiled, self.limits)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class PreviewItem:
|
|
92
|
+
"""One sample Record: its key and title, and the value or the error masked for the caller.
|
|
93
|
+
|
|
94
|
+
A masked item carries the masked value (none with `keeper.reads.sensitivity.omit`) and
|
|
95
|
+
whether the value failed (`failed`), without the error code or message.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
key: str
|
|
99
|
+
title: str | None
|
|
100
|
+
value: JsonValue
|
|
101
|
+
masked: bool
|
|
102
|
+
failed: bool = False
|
|
103
|
+
error_code: str | None = None
|
|
104
|
+
error_message: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _issue(code: str, message: str, path: tuple[str | int, ...]) -> Finding:
|
|
108
|
+
return Finding(code, message, path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def candidate_of(
|
|
112
|
+
current: Schema, change: ChangeSetInput, name: str, rules: SchemaRules
|
|
113
|
+
) -> Candidate:
|
|
114
|
+
"""The Derivation `name` of the schema `change` asks for, once the schema is valid.
|
|
115
|
+
|
|
116
|
+
Raises `CandidateInvalidError` when the change set cannot be read, fails static validation
|
|
117
|
+
(the issues of a schema change) or `name` is not an `expression` or `aggregate` Derivation
|
|
118
|
+
of it. Path Derivations compose Links and compute no values.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
schema = target_schema(current, change)
|
|
122
|
+
except ChangeSetInvalidError as invalid:
|
|
123
|
+
raise CandidateInvalidError(invalid.issues) from None
|
|
124
|
+
issues = static_issues(schema, rules)
|
|
125
|
+
if issues:
|
|
126
|
+
raise CandidateInvalidError(issues)
|
|
127
|
+
derivation = schema.derivation_by_api_name(name)
|
|
128
|
+
if derivation is None:
|
|
129
|
+
raise CandidateInvalidError(
|
|
130
|
+
[
|
|
131
|
+
_issue(
|
|
132
|
+
DERIVATION_NOT_FOUND, f"No Derivation {name!r} in the schema.", ("derivations",)
|
|
133
|
+
)
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
definition = derivation.definition
|
|
137
|
+
if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
|
|
138
|
+
raise CandidateInvalidError(
|
|
139
|
+
[
|
|
140
|
+
_issue(
|
|
141
|
+
DERIVATION_UNSUPPORTED,
|
|
142
|
+
"Only expression and aggregate Derivations compute values; a path "
|
|
143
|
+
"Derivation composes Links.",
|
|
144
|
+
("derivations", name, "definition", "variant"),
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
kind = schema.kinds[definition.kind]
|
|
149
|
+
attribute = kind.attributes[definition.attribute]
|
|
150
|
+
limits = rules.expression_limits or ExprLimits()
|
|
151
|
+
compiled = compile_derivation(schema, derivation, limits)
|
|
152
|
+
return Candidate(schema, derivation, kind, attribute, compiled, limits)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def require_new_ids(candidate: Candidate, hidden: frozenset[str]) -> None:
|
|
156
|
+
"""Refuse a candidate built on a projected schema (`ReadPoint.hidden`) that reuses an id
|
|
157
|
+
the projection left out: Records and values stored under it are not the caller's to read
|
|
158
|
+
(design 13.3)."""
|
|
159
|
+
reused = sorted(element_ids(candidate.schema) & hidden)
|
|
160
|
+
if reused:
|
|
161
|
+
raise CandidateInvalidError(
|
|
162
|
+
[_issue(DERIVATION_ID_UNAVAILABLE, f"Id {reused[0]} cannot be used here.", ())]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def touched_kinds(
|
|
167
|
+
schema: Schema,
|
|
168
|
+
derivation: Derivation,
|
|
169
|
+
compiled: CompiledDerivation,
|
|
170
|
+
limits: ExprLimits | None = None,
|
|
171
|
+
) -> frozenset[str]:
|
|
172
|
+
"""Ids of the Kinds whose Records the Derivation reads: its own, the Kinds of the
|
|
173
|
+
Attributes it may read and the far Kinds of the Relations it follows (every Kind a path
|
|
174
|
+
Derivation crosses)."""
|
|
175
|
+
definition = derivation.definition
|
|
176
|
+
if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
|
|
177
|
+
return frozenset()
|
|
178
|
+
paths = schema.path_relations()
|
|
179
|
+
kinds = {definition.kind}
|
|
180
|
+
kinds.update(kind_id for kind_id, _ in derivation_reads(schema, derivation, limits))
|
|
181
|
+
followed: list[tuple[str, LinkDirection]] = []
|
|
182
|
+
if isinstance(definition, AggregateDerivation):
|
|
183
|
+
followed.append((definition.relation, definition.direction))
|
|
184
|
+
for program in (compiled.expression, compiled.filter):
|
|
185
|
+
if program is not None:
|
|
186
|
+
followed.extend((f.relation.id, f.direction) for f in links_read(program))
|
|
187
|
+
for relation_id, direction in followed:
|
|
188
|
+
relation = schema.link_relation(relation_id)
|
|
189
|
+
if relation is not None:
|
|
190
|
+
kinds.add(relation.far_endpoint(direction))
|
|
191
|
+
if relation_id in paths:
|
|
192
|
+
kinds.update(paths[relation_id].kinds)
|
|
193
|
+
return frozenset(kinds)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def kind_names(schema: Schema, kind_ids: frozenset[str]) -> list[str]:
|
|
197
|
+
"""Api names of Kinds, sorted."""
|
|
198
|
+
return sorted(schema.kinds[k].api_name for k in kind_ids if k in schema.kinds)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def preview(
|
|
202
|
+
session: AsyncSession,
|
|
203
|
+
runtime: ReadRuntime,
|
|
204
|
+
point: ReadPoint,
|
|
205
|
+
candidate: Candidate,
|
|
206
|
+
*,
|
|
207
|
+
keys: Sequence[str] | None,
|
|
208
|
+
limit: int,
|
|
209
|
+
) -> list[PreviewItem]:
|
|
210
|
+
"""Evaluate the candidate on `keys` (the first `limit` Records of its Kind by key when
|
|
211
|
+
`None`), at the branch head `point` names, in the read transaction of `session`.
|
|
212
|
+
|
|
213
|
+
The caller has checked the Kinds the candidate touches against the point's scope.
|
|
214
|
+
"""
|
|
215
|
+
store = runtime.store
|
|
216
|
+
repository = MaterializedRepository(
|
|
217
|
+
session,
|
|
218
|
+
point.domain.id,
|
|
219
|
+
point.branch,
|
|
220
|
+
writes=store.settings.writes,
|
|
221
|
+
layout=point.layout,
|
|
222
|
+
)
|
|
223
|
+
materialize = store.settings.materialize
|
|
224
|
+
values = DerivedValues(
|
|
225
|
+
repository,
|
|
226
|
+
candidate.schema,
|
|
227
|
+
limits=store.schema.expression_limits,
|
|
228
|
+
now=point.revision.created_at,
|
|
229
|
+
batch=materialize.read_batch_rows,
|
|
230
|
+
max_incremental=materialize.derivation_max_incremental_records,
|
|
231
|
+
fanout=store.settings.traverse.path_fanout,
|
|
232
|
+
)
|
|
233
|
+
masking = point.masking.over(point.schema, candidate.schema)
|
|
234
|
+
origins = tag_origins(
|
|
235
|
+
candidate.schema,
|
|
236
|
+
candidate.attribute.id,
|
|
237
|
+
_united(masking.reference, schema_tags(point.schema)),
|
|
238
|
+
candidate.limits,
|
|
239
|
+
)
|
|
240
|
+
masked = masking.hidden_with_origins(candidate.attribute, (candidate.kind.id,), origins)
|
|
241
|
+
items: list[PreviewItem] = []
|
|
242
|
+
for found in await values.preview(candidate.attribute.id, keys=keys, limit=limit):
|
|
243
|
+
title = masking.title(candidate.schema, candidate.kind.id, found.title)
|
|
244
|
+
if masked:
|
|
245
|
+
note_sensitive("masked")
|
|
246
|
+
items.append(
|
|
247
|
+
PreviewItem(
|
|
248
|
+
found.key,
|
|
249
|
+
title,
|
|
250
|
+
None if masking.settings.omit else masking.masked,
|
|
251
|
+
True,
|
|
252
|
+
found.error is not None,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
if masking.sensitive(candidate.attribute):
|
|
257
|
+
note_sensitive("shown")
|
|
258
|
+
error = found.error
|
|
259
|
+
items.append(
|
|
260
|
+
PreviewItem(
|
|
261
|
+
found.key,
|
|
262
|
+
title,
|
|
263
|
+
found.value,
|
|
264
|
+
False,
|
|
265
|
+
error is not None,
|
|
266
|
+
None if error is None else error.code.value,
|
|
267
|
+
None if error is None else error.describe(),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return items
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _united(*tag_maps: Mapping[str, frozenset[Sensitivity]]) -> dict[str, frozenset[Sensitivity]]:
|
|
274
|
+
"""Attribute id -> tags, united over `tag_maps`."""
|
|
275
|
+
united: dict[str, frozenset[Sensitivity]] = {}
|
|
276
|
+
for tags in tag_maps:
|
|
277
|
+
for attribute_id, found in tags.items():
|
|
278
|
+
united[attribute_id] = united.get(attribute_id, frozenset()) | found
|
|
279
|
+
return united
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Document intake by URL: the guarded fetch behind `POST /domains/{d}/documents` with a URL.
|
|
2
|
+
|
|
3
|
+
The URL needs a scheme in `keeper.documents.url_schemes` and a host in `url_hosts` (exact
|
|
4
|
+
match). The host is then resolved once and every address must be global unicast, the check of
|
|
5
|
+
webhook targets and HTTP JSON Sources (`lorebase_store.webhooks.delivery.check_target`), unless
|
|
6
|
+
`url_allow_private_targets`. The request connects to those checked addresses only, never to a
|
|
7
|
+
new lookup, so a DNS answer that changes after the check (rebinding) cannot reach an internal
|
|
8
|
+
service; the `Host` header and the TLS server name keep the host name. Redirects are never
|
|
9
|
+
followed, proxies from the environment are ignored and the body is read in bounded steps.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from lorebase_keeper.services.config import DocumentSettings
|
|
19
|
+
from lorebase_store.webhooks import WebhookSettings
|
|
20
|
+
from lorebase_store.webhooks.delivery import Resolver, check_target, resolve_host
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UrlRefusedError(ValueError):
|
|
24
|
+
"""The URL may not be fetched (scheme, host or the addresses it resolves to)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UrlUnavailableError(RuntimeError):
|
|
28
|
+
"""The URL could not be fetched now (resolution, connection or an answer other than 200)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DocumentTooLargeError(ValueError):
|
|
32
|
+
"""The body exceeds `keeper.documents.max_bytes`."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pin(url: httpx.URL, address: str) -> tuple[httpx.URL, dict[str, str], dict[str, Any]]:
|
|
36
|
+
"""`url` rewritten to connect to the checked `address`, with the `Host` header and request
|
|
37
|
+
extensions that keep the host name for the server and for TLS (as Scribe's webhook sender
|
|
38
|
+
and Source connectors do)."""
|
|
39
|
+
return (
|
|
40
|
+
url.copy_with(host=address.split("%", 1)[0]),
|
|
41
|
+
{"Host": url.netloc.decode("ascii")},
|
|
42
|
+
{"sni_hostname": url.host} if url.scheme == "https" else {},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def checked_addresses(
|
|
47
|
+
url: httpx.URL, settings: DocumentSettings, resolve: Resolver
|
|
48
|
+
) -> tuple[str, ...]:
|
|
49
|
+
"""Addresses a fetch of `url` may connect to; empty when private targets are allowed (the
|
|
50
|
+
client then resolves the host itself)."""
|
|
51
|
+
if url.scheme not in settings.url_schemes:
|
|
52
|
+
raise UrlRefusedError(f"scheme must be one of {', '.join(settings.url_schemes)}")
|
|
53
|
+
allowed = {host.lower() for host in settings.url_hosts}
|
|
54
|
+
if not url.host or url.host.lower() not in allowed:
|
|
55
|
+
raise UrlRefusedError("the host is not in keeper.documents.url_hosts")
|
|
56
|
+
checks = WebhookSettings(
|
|
57
|
+
allowed_schemes=settings.url_schemes,
|
|
58
|
+
allow_private_targets=settings.url_allow_private_targets,
|
|
59
|
+
)
|
|
60
|
+
target = await check_target(str(url), checks, resolve)
|
|
61
|
+
if target.problem is None:
|
|
62
|
+
return target.addresses
|
|
63
|
+
if target.problem.startswith("cannot resolve"):
|
|
64
|
+
raise UrlUnavailableError("the URL host cannot be resolved")
|
|
65
|
+
# `check_target` problems never contain the host name or its addresses.
|
|
66
|
+
raise UrlRefusedError(
|
|
67
|
+
f"the URL host {target.problem}, refused unless keeper.documents.url_allow_private_targets"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def fetch(
|
|
72
|
+
url: str,
|
|
73
|
+
settings: DocumentSettings,
|
|
74
|
+
*,
|
|
75
|
+
resolve: Resolver = resolve_host,
|
|
76
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
77
|
+
) -> bytes:
|
|
78
|
+
"""The body of a 200 answer to GET `url`, at most `settings.max_bytes` long."""
|
|
79
|
+
target = httpx.URL(url)
|
|
80
|
+
addresses = await checked_addresses(target, settings, resolve)
|
|
81
|
+
attempts = [pin(target, address) for address in addresses] or [(target, {}, {})]
|
|
82
|
+
async with httpx.AsyncClient(
|
|
83
|
+
follow_redirects=False,
|
|
84
|
+
timeout=settings.url_timeout_s,
|
|
85
|
+
trust_env=False,
|
|
86
|
+
transport=transport,
|
|
87
|
+
) as client:
|
|
88
|
+
failure: httpx.HTTPError | None = None
|
|
89
|
+
for pinned, headers, extensions in attempts:
|
|
90
|
+
try:
|
|
91
|
+
return await _get(client, pinned, headers, extensions, settings.max_bytes)
|
|
92
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as error:
|
|
93
|
+
failure = error # the next checked address, never a new lookup
|
|
94
|
+
except httpx.HTTPError as error:
|
|
95
|
+
raise UrlUnavailableError(f"the URL could not be fetched: {error}") from None
|
|
96
|
+
raise UrlUnavailableError(f"the URL could not be fetched: {failure}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _get(
|
|
100
|
+
client: httpx.AsyncClient,
|
|
101
|
+
url: httpx.URL,
|
|
102
|
+
headers: dict[str, str],
|
|
103
|
+
extensions: dict[str, Any],
|
|
104
|
+
limit: int,
|
|
105
|
+
) -> bytes:
|
|
106
|
+
data = bytearray()
|
|
107
|
+
async with client.stream("GET", url, headers=headers, extensions=extensions) as response:
|
|
108
|
+
if response.status_code != 200:
|
|
109
|
+
raise UrlUnavailableError(f"the URL answered {response.status_code}")
|
|
110
|
+
async for block in response.aiter_bytes():
|
|
111
|
+
data.extend(block)
|
|
112
|
+
if len(data) > limit:
|
|
113
|
+
raise DocumentTooLargeError(f"the document exceeds {limit} bytes")
|
|
114
|
+
return bytes(data)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Domain use cases: create (root Revision and default branch), read, update, delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import IntegrityError
|
|
8
|
+
|
|
9
|
+
from lorebase_core.model import Domain
|
|
10
|
+
from lorebase_keeper.auth.principals import Principal
|
|
11
|
+
from lorebase_keeper.context import StoreAccess, author_of, require_domain
|
|
12
|
+
from lorebase_keeper.errors import NOT_FOUND, ProblemError
|
|
13
|
+
from lorebase_keeper.services.config import DomainSettings
|
|
14
|
+
from lorebase_keeper.services.errors import ALREADY_EXISTS
|
|
15
|
+
from lorebase_store.auth import GrantRepository
|
|
16
|
+
from lorebase_store.commit import create_domain
|
|
17
|
+
from lorebase_store.repositories import DomainRepository, DomainRow, RefRepository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CreatedDomain:
|
|
22
|
+
"""A Domain and the head of its default branch; `replayed` when it already existed."""
|
|
23
|
+
|
|
24
|
+
domain: DomainRow
|
|
25
|
+
head: bytes
|
|
26
|
+
replayed: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def create(
|
|
30
|
+
store: StoreAccess,
|
|
31
|
+
domain: Domain,
|
|
32
|
+
principal: Principal,
|
|
33
|
+
settings: DomainSettings,
|
|
34
|
+
*,
|
|
35
|
+
idempotency_key: str | None,
|
|
36
|
+
) -> CreatedDomain:
|
|
37
|
+
"""Create a Domain: row, root Revision, protected default branch and the creator's grant.
|
|
38
|
+
|
|
39
|
+
With an idempotency key, creating a Domain that the same principal already created
|
|
40
|
+
returns it (`replayed`); otherwise an existing id is a conflict.
|
|
41
|
+
"""
|
|
42
|
+
async with store.sessions() as session:
|
|
43
|
+
existing = await DomainRepository(session).get(domain.id)
|
|
44
|
+
if existing is not None:
|
|
45
|
+
return await _existing(store, existing, principal, idempotency_key)
|
|
46
|
+
try:
|
|
47
|
+
async with store.sessions() as session, session.begin():
|
|
48
|
+
result = await create_domain(
|
|
49
|
+
session,
|
|
50
|
+
domain,
|
|
51
|
+
author=author_of(principal),
|
|
52
|
+
leaf_size=settings.leaf_size,
|
|
53
|
+
runtime=store.runtime,
|
|
54
|
+
clock=store.clock,
|
|
55
|
+
)
|
|
56
|
+
if not settings.protect_default_branch:
|
|
57
|
+
await RefRepository(session, domain.id).set_protected(domain.default_branch, False)
|
|
58
|
+
if settings.creator_role and principal.method != "none":
|
|
59
|
+
await GrantRepository(session).add(
|
|
60
|
+
domain_id=domain.id,
|
|
61
|
+
subject=principal.id,
|
|
62
|
+
role=settings.creator_role,
|
|
63
|
+
created_by=principal.id,
|
|
64
|
+
)
|
|
65
|
+
row = await DomainRepository(session).require(domain.id)
|
|
66
|
+
except IntegrityError:
|
|
67
|
+
async with store.sessions() as session:
|
|
68
|
+
found = await DomainRepository(session).get(domain.id)
|
|
69
|
+
if found is None:
|
|
70
|
+
raise
|
|
71
|
+
return await _existing(store, found, principal, idempotency_key)
|
|
72
|
+
return CreatedDomain(domain=row, head=result.revision_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _existing(
|
|
76
|
+
store: StoreAccess, row: DomainRow, principal: Principal, idempotency_key: str | None
|
|
77
|
+
) -> CreatedDomain:
|
|
78
|
+
if idempotency_key is None or row.created_by != principal.id:
|
|
79
|
+
raise ProblemError(ALREADY_EXISTS, f"Domain {row.id!r} already exists.")
|
|
80
|
+
async with store.sessions() as session, session.begin():
|
|
81
|
+
await require_domain(session, row.id)
|
|
82
|
+
ref = await RefRepository(session, row.id).require(row.default_branch)
|
|
83
|
+
return CreatedDomain(domain=row, head=ref.head, replayed=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def get(store: StoreAccess, domain_id: str) -> tuple[DomainRow, bytes | None]:
|
|
87
|
+
"""The Domain and the head of its default branch."""
|
|
88
|
+
async with store.sessions() as session, session.begin():
|
|
89
|
+
domain = await require_domain(session, domain_id)
|
|
90
|
+
ref = await RefRepository(session, domain_id).get(domain.default_branch)
|
|
91
|
+
return domain, None if ref is None else ref.head
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def page(store: StoreAccess, *, limit: int, after: str | None) -> list[DomainRow]:
|
|
95
|
+
"""Domains ordered by id."""
|
|
96
|
+
async with store.sessions() as session:
|
|
97
|
+
return await DomainRepository(session).list(limit=limit, after=after)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def update(
|
|
101
|
+
store: StoreAccess, domain_id: str, *, display_name: str | None, description: str | None
|
|
102
|
+
) -> DomainRow:
|
|
103
|
+
"""Change display metadata (not versioned: the Domain element of the schema is unchanged)."""
|
|
104
|
+
async with store.sessions() as session, session.begin():
|
|
105
|
+
await require_domain(session, domain_id)
|
|
106
|
+
row = await DomainRepository(session).update(
|
|
107
|
+
domain_id, display_name=display_name, description=description
|
|
108
|
+
)
|
|
109
|
+
if row is None: # pragma: no cover - deleted concurrently
|
|
110
|
+
raise ProblemError(NOT_FOUND, f"Domain {domain_id!r} not found.")
|
|
111
|
+
return row
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def delete(store: StoreAccess, domain_id: str) -> None:
|
|
115
|
+
"""Delete a Domain and everything scoped to it. The audit log keeps its entries."""
|
|
116
|
+
async with store.sessions() as session, session.begin():
|
|
117
|
+
await require_domain(session, domain_id)
|
|
118
|
+
await DomainRepository(session).delete(domain_id)
|