lorebase-store 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_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""The vault carried by a Domain bundle (design 13.5, `lorebase_store.bundle`).
|
|
2
|
+
|
|
3
|
+
A bundle exported with its vault values holds three sections:
|
|
4
|
+
|
|
5
|
+
- **entries**, one per token its Record objects reference: the Kind, Attribute and Record key
|
|
6
|
+
the vault row binds the token to, and either the opened value or the mark that it was erased.
|
|
7
|
+
A token without a row (imported earlier without its value) is exported as erased, bound to a
|
|
8
|
+
Record holding it. The values are plain text; only an explicit export writes them.
|
|
9
|
+
- **lineage**: the key changes of `vault_lineage`. A key change on an erased reference keeps
|
|
10
|
+
the token, so the Records before and after it both hold it; `KeyLinks` links their keys.
|
|
11
|
+
An entry carries the row's `moved_to` (the key a key change took the token over to), which
|
|
12
|
+
import accepts only along a key change of the bundle.
|
|
13
|
+
- **tombstones**: the fingerprints erasures recorded (`vault_tombstones`), with the token key
|
|
14
|
+
they were made under (`fingerprint_key`, `<token_key_id>/<scheme>`). They hold no value and
|
|
15
|
+
cannot be made again, so a target only takes them when it has that token key id. Legacy
|
|
16
|
+
tombstones without a `fingerprint_key` are exported as made under the current token key with
|
|
17
|
+
the first scheme (`resolved_legacy`); import refuses one that still has none.
|
|
18
|
+
|
|
19
|
+
`holders_problem` checks that the Records holding a token in a bundle hold it in the bound Kind
|
|
20
|
+
and Attribute, under the bound key or one linked to it. `restore_entries` seals values again
|
|
21
|
+
under the target's keys with the bundle's token as associated data and the target's fingerprint,
|
|
22
|
+
so they open, and erasure by value finds them, as if written there; a value whose fingerprint a
|
|
23
|
+
tombstone of the target holds is handled as `vault.erased_values` says (stored erased, refused,
|
|
24
|
+
or stored). Erased entries become erased rows. Tokens the target already holds are refused.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from collections import Counter, defaultdict
|
|
31
|
+
from collections.abc import AsyncIterator, Collection, Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, replace
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pydantic import JsonValue
|
|
37
|
+
from sqlalchemy import Table, select, update
|
|
38
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
39
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
40
|
+
|
|
41
|
+
from lorebase_core.canonical import from_hex
|
|
42
|
+
from lorebase_core.canonical import hex as to_hex
|
|
43
|
+
from lorebase_core.model import Attribute, looks_like_reference
|
|
44
|
+
from lorebase_core.model.vault import TOKEN_PATTERN
|
|
45
|
+
from lorebase_core.validate import check_values
|
|
46
|
+
from lorebase_store.models import metadata
|
|
47
|
+
from lorebase_store.vault.keys import VaultKeys
|
|
48
|
+
from lorebase_store.vault.settings import ErasedValues
|
|
49
|
+
from lorebase_store.vault.store import (
|
|
50
|
+
Lineage,
|
|
51
|
+
VaultRow,
|
|
52
|
+
insert_lineage,
|
|
53
|
+
insert_rows,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_VAULT: Table = metadata.tables["vault"]
|
|
57
|
+
_TOMBSTONES: Table = metadata.tables["vault_tombstones"]
|
|
58
|
+
_LINEAGE: Table = metadata.tables["vault_lineage"]
|
|
59
|
+
_CHUNK = 1000
|
|
60
|
+
_FINGERPRINT_BYTES = 32
|
|
61
|
+
_ENTRY_FIELDS = frozenset(
|
|
62
|
+
{"token", "kind", "attribute", "record_key", "value", "erased", "moved_to"}
|
|
63
|
+
)
|
|
64
|
+
LEGACY_SCHEME = 1
|
|
65
|
+
"""Fingerprint scheme of tombstones recorded before rows kept their `fingerprint_key`."""
|
|
66
|
+
_LINEAGE_FIELDS = frozenset({"kind", "record_key", "previous_key"})
|
|
67
|
+
_TOMBSTONE_FIELDS = frozenset({"kind", "attribute", "record_key", "fingerprint", "fingerprint_key"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class VaultTransferError(ValueError):
|
|
71
|
+
"""A vault section cannot be exported, is malformed, or cannot be stored."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class VaultEntry:
|
|
76
|
+
"""A token, its binding and its value (`erased`: no value). `moved_to`: the key a key
|
|
77
|
+
change took the token over to."""
|
|
78
|
+
|
|
79
|
+
token: str
|
|
80
|
+
kind_id: str
|
|
81
|
+
attribute_id: str
|
|
82
|
+
record_key: str
|
|
83
|
+
value: JsonValue = None
|
|
84
|
+
erased: bool = False
|
|
85
|
+
moved_to: str | None = None
|
|
86
|
+
|
|
87
|
+
def as_json(self) -> dict[str, Any]:
|
|
88
|
+
out: dict[str, Any] = {
|
|
89
|
+
"token": self.token,
|
|
90
|
+
"kind": self.kind_id,
|
|
91
|
+
"attribute": self.attribute_id,
|
|
92
|
+
"record_key": self.record_key,
|
|
93
|
+
}
|
|
94
|
+
if self.erased:
|
|
95
|
+
out["erased"] = True
|
|
96
|
+
else:
|
|
97
|
+
out["value"] = self.value
|
|
98
|
+
if self.moved_to is not None:
|
|
99
|
+
out["moved_to"] = self.moved_to
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
104
|
+
class Holder:
|
|
105
|
+
"""A Record holding a token in an Attribute, as a bundle's objects show it."""
|
|
106
|
+
|
|
107
|
+
kind_id: str
|
|
108
|
+
attribute_id: str
|
|
109
|
+
record_key: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class LineageEntry:
|
|
114
|
+
"""Record `record_key` took over the vaulted values of `previous_key` (same Kind)."""
|
|
115
|
+
|
|
116
|
+
kind_id: str
|
|
117
|
+
record_key: str
|
|
118
|
+
previous_key: str
|
|
119
|
+
|
|
120
|
+
def as_json(self) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"kind": self.kind_id,
|
|
123
|
+
"record_key": self.record_key,
|
|
124
|
+
"previous_key": self.previous_key,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class TombstoneEntry:
|
|
130
|
+
"""A fingerprint an erasure recorded, of one Record (`record_key`) or of the Kind."""
|
|
131
|
+
|
|
132
|
+
kind_id: str
|
|
133
|
+
attribute_id: str
|
|
134
|
+
record_key: str | None
|
|
135
|
+
fingerprint: bytes
|
|
136
|
+
fingerprint_key: str | None
|
|
137
|
+
|
|
138
|
+
def as_json(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"kind": self.kind_id,
|
|
141
|
+
"attribute": self.attribute_id,
|
|
142
|
+
"record_key": self.record_key,
|
|
143
|
+
"fingerprint": to_hex(self.fingerprint),
|
|
144
|
+
"fingerprint_key": self.fingerprint_key,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class KeyLinks:
|
|
149
|
+
"""Record keys of a Kind linked by key changes, transitively in both directions."""
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
self._parent: dict[tuple[str, str], tuple[str, str]] = {}
|
|
153
|
+
|
|
154
|
+
def _root(self, node: tuple[str, str]) -> tuple[str, str]:
|
|
155
|
+
while (parent := self._parent.get(node, node)) != node:
|
|
156
|
+
grand = self._parent.get(parent, parent)
|
|
157
|
+
self._parent[node] = grand
|
|
158
|
+
node = grand
|
|
159
|
+
return node
|
|
160
|
+
|
|
161
|
+
def link(self, entry: LineageEntry) -> None:
|
|
162
|
+
for key in (entry.record_key, entry.previous_key):
|
|
163
|
+
self._parent.setdefault((entry.kind_id, key), (entry.kind_id, key))
|
|
164
|
+
a = self._root((entry.kind_id, entry.record_key))
|
|
165
|
+
b = self._root((entry.kind_id, entry.previous_key))
|
|
166
|
+
if a != b:
|
|
167
|
+
self._parent[a] = b
|
|
168
|
+
|
|
169
|
+
def linked(self, kind_id: str, key: str, other: str) -> bool:
|
|
170
|
+
return key == other or self._root((kind_id, key)) == self._root((kind_id, other))
|
|
171
|
+
|
|
172
|
+
def oversized(self, limit: int) -> int:
|
|
173
|
+
"""Groups of linked keys with more than `limit` keys."""
|
|
174
|
+
sizes = Counter(self._root(node) for node in list(self._parent))
|
|
175
|
+
return sum(1 for size in sizes.values() if size > limit)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def holders_problem(
|
|
179
|
+
token: str,
|
|
180
|
+
kind_id: str,
|
|
181
|
+
attribute_id: str,
|
|
182
|
+
record_key: str,
|
|
183
|
+
holders: Collection[Holder],
|
|
184
|
+
links: KeyLinks,
|
|
185
|
+
) -> str | None:
|
|
186
|
+
"""Why the Records holding `token` do not fit its binding, or None: each must hold it in
|
|
187
|
+
the bound Kind and Attribute, under the bound key or a key linked to it."""
|
|
188
|
+
if not holders:
|
|
189
|
+
return f"no Record object of the bundle holds {token}"
|
|
190
|
+
for holder in sorted(holders):
|
|
191
|
+
if (holder.kind_id, holder.attribute_id) != (kind_id, attribute_id) or not links.linked(
|
|
192
|
+
kind_id, holder.record_key, record_key
|
|
193
|
+
):
|
|
194
|
+
return (
|
|
195
|
+
f"{token} is held by Record {holder.record_key!r} in Attribute "
|
|
196
|
+
f"{holder.attribute_id}, which is neither its binding nor linked to it"
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def read_entries(
|
|
202
|
+
session: AsyncSession, domain_id: str, tokens: Sequence[str], keys: VaultKeys
|
|
203
|
+
) -> dict[str, VaultEntry]:
|
|
204
|
+
"""The entries of the tokens that have a vault row, bound as the row is. A value sealed
|
|
205
|
+
under a key that is not configured cannot be exported."""
|
|
206
|
+
v = _VAULT
|
|
207
|
+
out: dict[str, VaultEntry] = {}
|
|
208
|
+
ordered = sorted(set(tokens))
|
|
209
|
+
for start in range(0, len(ordered), _CHUNK):
|
|
210
|
+
rows = await session.execute(
|
|
211
|
+
select(
|
|
212
|
+
v.c.id,
|
|
213
|
+
v.c.kind_id,
|
|
214
|
+
v.c.attribute_id,
|
|
215
|
+
v.c.record_key,
|
|
216
|
+
v.c.value_ciphertext,
|
|
217
|
+
v.c.key_id,
|
|
218
|
+
v.c.moved_to,
|
|
219
|
+
).where(v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK]))
|
|
220
|
+
)
|
|
221
|
+
for row in rows:
|
|
222
|
+
if row.value_ciphertext is None or row.key_id is None:
|
|
223
|
+
out[row.id] = VaultEntry(
|
|
224
|
+
row.id,
|
|
225
|
+
row.kind_id,
|
|
226
|
+
row.attribute_id,
|
|
227
|
+
row.record_key,
|
|
228
|
+
erased=True,
|
|
229
|
+
moved_to=row.moved_to,
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
opened = keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
|
|
233
|
+
if opened is None:
|
|
234
|
+
raise VaultTransferError(
|
|
235
|
+
f"the value of {row.id} is sealed under a key that is not configured "
|
|
236
|
+
"(vault.previous_keys)"
|
|
237
|
+
)
|
|
238
|
+
out[row.id] = VaultEntry(
|
|
239
|
+
row.id,
|
|
240
|
+
row.kind_id,
|
|
241
|
+
row.attribute_id,
|
|
242
|
+
row.record_key,
|
|
243
|
+
json.loads(opened),
|
|
244
|
+
moved_to=row.moved_to,
|
|
245
|
+
)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolved_legacy(tombstone: TombstoneEntry, token_key_id: str) -> TombstoneEntry:
|
|
250
|
+
"""A tombstone recorded before tombstones kept their `fingerprint_key` holds a fingerprint
|
|
251
|
+
of the first scheme under the token key of the installation: it is exported as made under
|
|
252
|
+
the current one (`<token_key_id>/1`), which the manifest says."""
|
|
253
|
+
if tombstone.fingerprint_key is not None:
|
|
254
|
+
return tombstone
|
|
255
|
+
return replace(tombstone, fingerprint_key=f"{token_key_id}/{LEGACY_SCHEME}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def unknown_entry(token: str, holders: Collection[Holder]) -> VaultEntry:
|
|
259
|
+
"""The entry of a token without a vault row: erased, bound to its first holder."""
|
|
260
|
+
first = min(holders)
|
|
261
|
+
return VaultEntry(token, first.kind_id, first.attribute_id, first.record_key, erased=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def lineage_of(session: AsyncSession, domain_id: str) -> list[LineageEntry]:
|
|
265
|
+
"""The Domain's key changes."""
|
|
266
|
+
lin = _LINEAGE
|
|
267
|
+
rows = await session.execute(
|
|
268
|
+
select(lin.c.kind_id, lin.c.record_key, lin.c.previous_key)
|
|
269
|
+
.where(lin.c.domain_id == domain_id)
|
|
270
|
+
.order_by(lin.c.kind_id, lin.c.record_key, lin.c.previous_key)
|
|
271
|
+
)
|
|
272
|
+
return [LineageEntry(r.kind_id, r.record_key, r.previous_key) for r in rows]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def tombstones_of(
|
|
276
|
+
session: AsyncSession, domain_id: str, batch: int
|
|
277
|
+
) -> AsyncIterator[list[TombstoneEntry]]:
|
|
278
|
+
"""The Domain's tombstones, `batch` at a time."""
|
|
279
|
+
t = _TOMBSTONES
|
|
280
|
+
after = 0
|
|
281
|
+
while True:
|
|
282
|
+
rows = (
|
|
283
|
+
await session.execute(
|
|
284
|
+
select(
|
|
285
|
+
t.c.seq,
|
|
286
|
+
t.c.kind_id,
|
|
287
|
+
t.c.attribute_id,
|
|
288
|
+
t.c.record_key,
|
|
289
|
+
t.c.fingerprint,
|
|
290
|
+
t.c.fingerprint_key,
|
|
291
|
+
)
|
|
292
|
+
.where(t.c.domain_id == domain_id, t.c.seq > after)
|
|
293
|
+
.order_by(t.c.seq)
|
|
294
|
+
.limit(batch)
|
|
295
|
+
)
|
|
296
|
+
).all()
|
|
297
|
+
if not rows:
|
|
298
|
+
return
|
|
299
|
+
yield [
|
|
300
|
+
TombstoneEntry(
|
|
301
|
+
r.kind_id, r.attribute_id, r.record_key, bytes(r.fingerprint), r.fingerprint_key
|
|
302
|
+
)
|
|
303
|
+
for r in rows
|
|
304
|
+
]
|
|
305
|
+
after = int(rows[-1].seq)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _strings(line: Mapping[str, Any], names: Sequence[str], label: str) -> list[str]:
|
|
309
|
+
found = [line.get(name) for name in names]
|
|
310
|
+
if not all(isinstance(part, str) and part for part in found):
|
|
311
|
+
raise VaultTransferError(f"{label}: {', '.join(names)} must be non-empty strings")
|
|
312
|
+
return [str(part) for part in found]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _fields(line: Mapping[str, Any], allowed: frozenset[str]) -> None:
|
|
316
|
+
unknown = set(line) - allowed
|
|
317
|
+
if unknown:
|
|
318
|
+
raise VaultTransferError(f"unexpected fields {sorted(unknown)}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def parse_entry(line: Mapping[str, Any]) -> VaultEntry:
|
|
322
|
+
"""A checked entry: a well-formed token, its binding as strings, and exactly one of a
|
|
323
|
+
value (never a reference) or the erased mark."""
|
|
324
|
+
_fields(line, _ENTRY_FIELDS)
|
|
325
|
+
token = line.get("token")
|
|
326
|
+
if not isinstance(token, str) or not TOKEN_PATTERN.fullmatch(token):
|
|
327
|
+
raise VaultTransferError(f"malformed token {token!r}")
|
|
328
|
+
kind_id, attribute_id, record_key = _strings(line, ("kind", "attribute", "record_key"), token)
|
|
329
|
+
erased = line.get("erased", False)
|
|
330
|
+
if erased is not True and erased is not False:
|
|
331
|
+
raise VaultTransferError(f"{token}: erased must be true when present")
|
|
332
|
+
has_value = "value" in line
|
|
333
|
+
if erased == has_value:
|
|
334
|
+
raise VaultTransferError(f"{token}: give either a value or erased")
|
|
335
|
+
value = line.get("value")
|
|
336
|
+
if has_value and (value is None or looks_like_reference(value)):
|
|
337
|
+
raise VaultTransferError(f"{token}: the value is empty or a vault reference")
|
|
338
|
+
moved_to = line.get("moved_to")
|
|
339
|
+
if moved_to is not None and (
|
|
340
|
+
not isinstance(moved_to, str) or not moved_to or moved_to == record_key
|
|
341
|
+
):
|
|
342
|
+
raise VaultTransferError(f"{token}: moved_to must be another Record key")
|
|
343
|
+
return VaultEntry(token, kind_id, attribute_id, record_key, value, erased, moved_to)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def parse_lineage(line: Mapping[str, Any]) -> LineageEntry:
|
|
347
|
+
"""A checked key change."""
|
|
348
|
+
_fields(line, _LINEAGE_FIELDS)
|
|
349
|
+
kind_id, record_key, previous_key = _strings(
|
|
350
|
+
line, ("kind", "record_key", "previous_key"), "key change"
|
|
351
|
+
)
|
|
352
|
+
if record_key == previous_key:
|
|
353
|
+
raise VaultTransferError(f"key change of {record_key!r} to itself")
|
|
354
|
+
return LineageEntry(kind_id, record_key, previous_key)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def parse_tombstone(line: Mapping[str, Any], token_key_ids: Collection[str]) -> TombstoneEntry:
|
|
358
|
+
"""A checked tombstone, made under one of `token_key_ids` (the target's token keys)."""
|
|
359
|
+
_fields(line, _TOMBSTONE_FIELDS)
|
|
360
|
+
kind_id, attribute_id = _strings(line, ("kind", "attribute"), "tombstone")
|
|
361
|
+
record_key = line.get("record_key")
|
|
362
|
+
if record_key is not None and (not isinstance(record_key, str) or not record_key):
|
|
363
|
+
raise VaultTransferError("tombstone: record_key must be a non-empty string or null")
|
|
364
|
+
raw = line.get("fingerprint")
|
|
365
|
+
try:
|
|
366
|
+
fingerprint = from_hex(raw) if isinstance(raw, str) else b""
|
|
367
|
+
except ValueError:
|
|
368
|
+
fingerprint = b""
|
|
369
|
+
if len(fingerprint) != _FINGERPRINT_BYTES:
|
|
370
|
+
raise VaultTransferError(f"tombstone: malformed fingerprint {raw!r}")
|
|
371
|
+
fingerprint_key = line.get("fingerprint_key")
|
|
372
|
+
if fingerprint_key is None:
|
|
373
|
+
raise VaultTransferError(
|
|
374
|
+
"a tombstone does not say which token key made it (a bundle of an earlier "
|
|
375
|
+
"release); export the Domain again, which records it"
|
|
376
|
+
)
|
|
377
|
+
key_id = fingerprint_key.partition("/")[0] if isinstance(fingerprint_key, str) else None
|
|
378
|
+
if key_id is None or "/" not in str(fingerprint_key) or key_id not in token_key_ids:
|
|
379
|
+
raise VaultTransferError(
|
|
380
|
+
f"a tombstone was made under token key {fingerprint_key!r}, which this "
|
|
381
|
+
"installation does not have (vault.token_key_id, vault.previous_token_keys); "
|
|
382
|
+
"configure that token key under the same id to import the bundle"
|
|
383
|
+
)
|
|
384
|
+
return TombstoneEntry(kind_id, attribute_id, record_key, fingerprint, fingerprint_key)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def value_problem(entry: VaultEntry, definitions: Sequence[Attribute]) -> str | None:
|
|
388
|
+
"""Why the value of `entry` fits none of the definitions its Attribute had in the bundle's
|
|
389
|
+
schemas (type, canonical form, constraints), or None."""
|
|
390
|
+
if not definitions:
|
|
391
|
+
return f"{entry.token}: the bundle's schemas have no vault Attribute {entry.attribute_id}"
|
|
392
|
+
issues: list[str] = []
|
|
393
|
+
for attribute in definitions:
|
|
394
|
+
found = check_values(
|
|
395
|
+
{entry.attribute_id: attribute},
|
|
396
|
+
{entry.attribute_id: entry.value},
|
|
397
|
+
("vault",),
|
|
398
|
+
kind=entry.kind_id,
|
|
399
|
+
key=entry.record_key,
|
|
400
|
+
)
|
|
401
|
+
if not found:
|
|
402
|
+
return None
|
|
403
|
+
issues.append(found[0].message)
|
|
404
|
+
return f"{entry.token}: {issues[0]}"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def existing_tokens(
|
|
408
|
+
session: AsyncSession, domain_id: str, tokens: Collection[str]
|
|
409
|
+
) -> set[str]:
|
|
410
|
+
"""The tokens among `tokens` that the Domain's vault already holds."""
|
|
411
|
+
v = _VAULT
|
|
412
|
+
ordered = sorted(tokens)
|
|
413
|
+
found: set[str] = set()
|
|
414
|
+
for start in range(0, len(ordered), _CHUNK):
|
|
415
|
+
rows = await session.execute(
|
|
416
|
+
select(v.c.id).where(
|
|
417
|
+
v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK])
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
found.update(rows.scalars())
|
|
421
|
+
return found
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
async def restore_lineage(
|
|
425
|
+
session: AsyncSession, domain_id: str, entries: Sequence[LineageEntry], *, at: datetime
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Store key changes."""
|
|
428
|
+
for start in range(0, len(entries), _CHUNK):
|
|
429
|
+
chunk = entries[start : start + _CHUNK]
|
|
430
|
+
await insert_lineage(
|
|
431
|
+
session,
|
|
432
|
+
domain_id,
|
|
433
|
+
[Lineage(e.kind_id, e.record_key, e.previous_key) for e in chunk],
|
|
434
|
+
at=at,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def restore_tombstones(
|
|
439
|
+
session: AsyncSession, domain_id: str, entries: Sequence[TombstoneEntry], *, at: datetime
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Store tombstones as they were made."""
|
|
442
|
+
if not entries:
|
|
443
|
+
return
|
|
444
|
+
await session.execute(
|
|
445
|
+
insert(_TOMBSTONES).values(
|
|
446
|
+
[
|
|
447
|
+
{
|
|
448
|
+
"domain_id": domain_id,
|
|
449
|
+
"kind_id": e.kind_id,
|
|
450
|
+
"attribute_id": e.attribute_id,
|
|
451
|
+
"record_key": e.record_key,
|
|
452
|
+
"fingerprint": e.fingerprint,
|
|
453
|
+
"fingerprint_key": e.fingerprint_key,
|
|
454
|
+
"created_at": at,
|
|
455
|
+
}
|
|
456
|
+
for e in entries
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def _tombstoned(
|
|
463
|
+
session: AsyncSession, domain_id: str, values: Sequence[VaultEntry], keys: VaultKeys
|
|
464
|
+
) -> set[str]:
|
|
465
|
+
"""Tokens of the values a tombstone of the Domain holds, for their Record or the Kind."""
|
|
466
|
+
fingerprints = {
|
|
467
|
+
e.token: keys.fingerprints(domain_id, e.kind_id, e.attribute_id, e.value) for e in values
|
|
468
|
+
}
|
|
469
|
+
wanted = sorted({f for found in fingerprints.values() for f in found})
|
|
470
|
+
t = _TOMBSTONES
|
|
471
|
+
erased: dict[bytes, list[tuple[str, str, str | None]]] = defaultdict(list)
|
|
472
|
+
for start in range(0, len(wanted), _CHUNK):
|
|
473
|
+
rows = await session.execute(
|
|
474
|
+
select(t.c.fingerprint, t.c.kind_id, t.c.attribute_id, t.c.record_key).where(
|
|
475
|
+
t.c.domain_id == domain_id, t.c.fingerprint.in_(wanted[start : start + _CHUNK])
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
for row in rows:
|
|
479
|
+
erased[bytes(row.fingerprint)].append((row.kind_id, row.attribute_id, row.record_key))
|
|
480
|
+
if not erased:
|
|
481
|
+
return set()
|
|
482
|
+
return {
|
|
483
|
+
e.token
|
|
484
|
+
for e in values
|
|
485
|
+
if any(
|
|
486
|
+
(kind_id, attribute_id) == (e.kind_id, e.attribute_id)
|
|
487
|
+
and record_key in (None, e.record_key)
|
|
488
|
+
for fingerprint in fingerprints[e.token]
|
|
489
|
+
for kind_id, attribute_id, record_key in erased.get(fingerprint, ())
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def restore_entries(
|
|
495
|
+
session: AsyncSession,
|
|
496
|
+
domain_id: str,
|
|
497
|
+
entries: Sequence[VaultEntry],
|
|
498
|
+
keys: VaultKeys | None,
|
|
499
|
+
*,
|
|
500
|
+
erased_values: ErasedValues,
|
|
501
|
+
at: datetime,
|
|
502
|
+
) -> int:
|
|
503
|
+
"""Store `entries` in the Domain's vault (module doc); the number of values stored erased
|
|
504
|
+
because a tombstone holds them."""
|
|
505
|
+
if not entries:
|
|
506
|
+
return 0
|
|
507
|
+
values = [e for e in entries if not e.erased]
|
|
508
|
+
if values and keys is None:
|
|
509
|
+
raise VaultTransferError(
|
|
510
|
+
"the bundle holds vault values but the vault is not configured "
|
|
511
|
+
"(vault.token_key, vault.encryption_key)"
|
|
512
|
+
)
|
|
513
|
+
taken = await existing_tokens(session, domain_id, [e.token for e in entries])
|
|
514
|
+
if taken:
|
|
515
|
+
raise VaultTransferError(
|
|
516
|
+
f"the vault already holds {len(taken)} of the bundle's tokens, e.g. {min(taken)}"
|
|
517
|
+
)
|
|
518
|
+
tombstoned: set[str] = set()
|
|
519
|
+
if values and erased_values is not ErasedValues.ALLOW:
|
|
520
|
+
assert keys is not None
|
|
521
|
+
tombstoned = await _tombstoned(session, domain_id, values, keys)
|
|
522
|
+
if tombstoned and erased_values is ErasedValues.REFUSE:
|
|
523
|
+
raise VaultTransferError(
|
|
524
|
+
f"{len(tombstoned)} values of the bundle were erased, e.g. {min(tombstoned)} "
|
|
525
|
+
"(vault.erased_values: refuse)"
|
|
526
|
+
)
|
|
527
|
+
rows: list[VaultRow] = []
|
|
528
|
+
for e in entries:
|
|
529
|
+
if e.erased or e.token in tombstoned:
|
|
530
|
+
rows.append(
|
|
531
|
+
VaultRow(
|
|
532
|
+
e.token, e.kind_id, e.attribute_id, e.record_key, None, None, None, erased=True
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
continue
|
|
536
|
+
assert keys is not None
|
|
537
|
+
rows.append(
|
|
538
|
+
VaultRow(
|
|
539
|
+
token=e.token,
|
|
540
|
+
kind_id=e.kind_id,
|
|
541
|
+
attribute_id=e.attribute_id,
|
|
542
|
+
record_key=e.record_key,
|
|
543
|
+
fingerprint=keys.fingerprint(domain_id, e.kind_id, e.attribute_id, e.value),
|
|
544
|
+
ciphertext=keys.seal(domain_id, e.token, e.value),
|
|
545
|
+
key_id=keys.key_id,
|
|
546
|
+
fingerprint_key=keys.fingerprint_key,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
await insert_rows(session, domain_id, rows, at=at)
|
|
550
|
+
for e in entries:
|
|
551
|
+
if e.moved_to is not None:
|
|
552
|
+
# Restored as exported: no chain to follow, every row carries its own moved_to.
|
|
553
|
+
await session.execute(
|
|
554
|
+
update(_VAULT)
|
|
555
|
+
.where(_VAULT.c.domain_id == domain_id, _VAULT.c.id == e.token)
|
|
556
|
+
.values(moved_to=e.moved_to)
|
|
557
|
+
)
|
|
558
|
+
return len(tombstoned)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Webhooks: secrets and signatures, management of a Domain's webhooks, and delivery from the
|
|
2
|
+
outbox (design 10.5)."""
|
|
3
|
+
|
|
4
|
+
from lorebase_store.webhooks.delivery import DeliveryReport, Sender, deliver_pending
|
|
5
|
+
from lorebase_store.webhooks.keys import SigningKeys, StoredKey
|
|
6
|
+
from lorebase_store.webhooks.repository import WebhookRepository, WebhookRow
|
|
7
|
+
from lorebase_store.webhooks.settings import WebhookSettings, webhook_settings
|
|
8
|
+
from lorebase_store.webhooks.signing import new_secret, signature, signing_key, verify
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DeliveryReport",
|
|
12
|
+
"Sender",
|
|
13
|
+
"SigningKeys",
|
|
14
|
+
"StoredKey",
|
|
15
|
+
"WebhookRepository",
|
|
16
|
+
"WebhookRow",
|
|
17
|
+
"WebhookSettings",
|
|
18
|
+
"deliver_pending",
|
|
19
|
+
"new_secret",
|
|
20
|
+
"signature",
|
|
21
|
+
"signing_key",
|
|
22
|
+
"verify",
|
|
23
|
+
"webhook_settings",
|
|
24
|
+
]
|