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,987 @@
|
|
|
1
|
+
"""The `vault` table: storing values behind tokens, opening them, erasing them (design 13.5).
|
|
2
|
+
|
|
3
|
+
- `tokenize` runs in the commit pipeline: the plain values of vault Attributes in Record
|
|
4
|
+
upserts become references, and the rows to insert are returned. A reference already in an
|
|
5
|
+
upsert is accepted when it is the value the Record holds at the branch head, or when a vault
|
|
6
|
+
row binds it to the same Kind, Record key and Attribute. A reference another Record of the
|
|
7
|
+
Kind holds in the same Attribute is taken over when the same change set deletes that Record
|
|
8
|
+
and the taking Record does not exist at the head (a key change), once per reference: its
|
|
9
|
+
value is stored again under a token of the new key, and `vault_lineage` records that both
|
|
10
|
+
keys hold the same data subject's values. Any other reference is refused, so a writer cannot
|
|
11
|
+
copy another Record's value.
|
|
12
|
+
- In commits of the origins of `vault.tombstone_origins` (Source syncs, extraction, imports),
|
|
13
|
+
a plain value whose fingerprint an erasure recorded (`vault_tombstones`) is stored erased,
|
|
14
|
+
refused, or stored again, as `vault.erased_values` says. Other writers are people and agents
|
|
15
|
+
who choose the value: checking their writes would tell them whether a guessed value was
|
|
16
|
+
erased.
|
|
17
|
+
- `open_values` reads rows by token: the value, `erased`, or `unavailable` (the key that
|
|
18
|
+
sealed it is not configured).
|
|
19
|
+
- `erase_record` and `erase_matching` erase rows: the ciphertext, key id and fingerprint are
|
|
20
|
+
cleared and `erased_at` is set. The row stays, so the token keeps answering `erased`; the
|
|
21
|
+
fingerprints of the erased values go to `vault_tombstones`. Erasing an erased row changes
|
|
22
|
+
nothing. Erasure by record follows `vault_lineage` to every key the Record had; erasure by
|
|
23
|
+
value compares matching forms (`lorebase_core.types.matching`), so case variants of the value
|
|
24
|
+
are erased too.
|
|
25
|
+
|
|
26
|
+
Commits that store vault values hold the Domain's vault lock shared from the first vault read to
|
|
27
|
+
the end of their transaction, erasures hold it exclusively: an erasure waits for the commits in
|
|
28
|
+
flight, and a commit that starts during an erasure reads the vault after it. A value is thus
|
|
29
|
+
either written before the erasure (and erased by it) or after it (and finds its tombstone).
|
|
30
|
+
|
|
31
|
+
Every statement filters on `domain_id` and runs under the Domain's row-level security scope.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
from collections import Counter, defaultdict
|
|
38
|
+
from collections.abc import Collection, Iterable, Sequence
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from enum import StrEnum
|
|
42
|
+
from typing import Any, Protocol
|
|
43
|
+
|
|
44
|
+
from pydantic import JsonValue
|
|
45
|
+
from sqlalchemy import Table, func, select, text, update
|
|
46
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
47
|
+
from sqlalchemy.exc import DBAPIError
|
|
48
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
49
|
+
from sqlalchemy.orm import Session
|
|
50
|
+
|
|
51
|
+
from lorebase_core.model import Record, Schema, looks_like_reference, vault_reference, vault_token
|
|
52
|
+
from lorebase_core.validate import (
|
|
53
|
+
DataChange,
|
|
54
|
+
Issue,
|
|
55
|
+
IssueCode,
|
|
56
|
+
RecordDelete,
|
|
57
|
+
RecordUpsert,
|
|
58
|
+
check_values,
|
|
59
|
+
)
|
|
60
|
+
from lorebase_store.models import metadata
|
|
61
|
+
from lorebase_store.repositories.refs import LOCK_NOT_AVAILABLE, sqlstate
|
|
62
|
+
from lorebase_store.vault.keys import VaultKeys
|
|
63
|
+
from lorebase_store.vault.settings import ErasedValues
|
|
64
|
+
|
|
65
|
+
VAULT: Table = metadata.tables["vault"]
|
|
66
|
+
TOMBSTONES: Table = metadata.tables["vault_tombstones"]
|
|
67
|
+
LINEAGE: Table = metadata.tables["vault_lineage"]
|
|
68
|
+
|
|
69
|
+
MAX_GENERATIONS = 64
|
|
70
|
+
"""Erased generations of one value of one Record tried before a write is refused."""
|
|
71
|
+
|
|
72
|
+
MAX_LINKED_KEYS = 1000
|
|
73
|
+
"""Keys of one data subject above which an erasure by key is flagged for an administrator
|
|
74
|
+
(`Erasure.linked`); it still erases every one of them."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class VaultLockTimeoutError(Exception):
|
|
78
|
+
"""The Domain's vault lock was not acquired within the configured time."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_CHUNK = 1000
|
|
82
|
+
|
|
83
|
+
_LOCK = "SELECT {function}(hashtextextended('lorebase.vault/' || :domain, 0))"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RecordValues(Protocol):
|
|
87
|
+
"""The values a Record holds at the branch head (`MaterializedIndexView`)."""
|
|
88
|
+
|
|
89
|
+
def record_state(self, kind_id: str, key: str) -> Any: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class VaultRow:
|
|
94
|
+
"""A row to insert. An erased row (a write of an erased value, `vault.erased_values:
|
|
95
|
+
erase`) holds no ciphertext, key id or fingerprint."""
|
|
96
|
+
|
|
97
|
+
token: str
|
|
98
|
+
kind_id: str
|
|
99
|
+
attribute_id: str
|
|
100
|
+
record_key: str
|
|
101
|
+
fingerprint: bytes | None
|
|
102
|
+
ciphertext: bytes | None
|
|
103
|
+
key_id: str | None
|
|
104
|
+
fingerprint_key: str | None = None
|
|
105
|
+
erased: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class Lineage:
|
|
110
|
+
"""Record `record_key` took over vaulted values of `previous_key` (same Kind)."""
|
|
111
|
+
|
|
112
|
+
kind_id: str
|
|
113
|
+
record_key: str
|
|
114
|
+
previous_key: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class Move:
|
|
119
|
+
"""A key change took `token` over: its value moved from `previous_key` to `key`."""
|
|
120
|
+
|
|
121
|
+
token: str
|
|
122
|
+
kind_id: str
|
|
123
|
+
attribute_id: str
|
|
124
|
+
previous_key: str
|
|
125
|
+
key: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _moves(moved: dict[str, tuple[str, str, str, str]]) -> list[Move]:
|
|
129
|
+
return [Move(token, *found) for token, found in sorted(moved.items())]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class Tokenized:
|
|
134
|
+
changes: list[DataChange]
|
|
135
|
+
rows: list[VaultRow]
|
|
136
|
+
issues: list[Issue]
|
|
137
|
+
lineage: list[Lineage] = field(default_factory=list)
|
|
138
|
+
moved: list[Move] = field(default_factory=list)
|
|
139
|
+
"""Tokens a key change took over and where their value moved (`mark_moved`)."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True)
|
|
143
|
+
class _Binding:
|
|
144
|
+
kind_id: str
|
|
145
|
+
attribute_id: str
|
|
146
|
+
record_key: str
|
|
147
|
+
erased: bool
|
|
148
|
+
moved_to: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class _Plain:
|
|
153
|
+
index: int
|
|
154
|
+
kind_id: str
|
|
155
|
+
key: str
|
|
156
|
+
attribute_id: str
|
|
157
|
+
value: JsonValue
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class _Claim:
|
|
162
|
+
index: int
|
|
163
|
+
kind_id: str
|
|
164
|
+
key: str
|
|
165
|
+
attribute_id: str
|
|
166
|
+
token: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def vault_attributes(schema: Schema) -> dict[str, list[str]]:
|
|
170
|
+
"""Kind id -> ids of its vault Attributes."""
|
|
171
|
+
found: dict[str, list[str]] = {}
|
|
172
|
+
for kind in schema.kinds.values():
|
|
173
|
+
ids = [a.id for a in kind.attributes.values() if a.in_vault]
|
|
174
|
+
if ids:
|
|
175
|
+
found[kind.id] = ids
|
|
176
|
+
return found
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def lock_vault(sync: Session, domain_id: str, *, timeout_ms: int = 0) -> None:
|
|
180
|
+
"""Hold the Domain's vault lock shared until the transaction ends (module doc), waiting
|
|
181
|
+
at most `timeout_ms` (0: no limit); raises `VaultLockTimeoutError`, after which the
|
|
182
|
+
transaction must be rolled back."""
|
|
183
|
+
statement = text(_LOCK.format(function="pg_advisory_xact_lock_shared"))
|
|
184
|
+
if timeout_ms <= 0:
|
|
185
|
+
sync.execute(statement, {"domain": domain_id})
|
|
186
|
+
return
|
|
187
|
+
previous = sync.scalar(text("SELECT current_setting('lock_timeout')"))
|
|
188
|
+
sync.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": f"{timeout_ms}ms"})
|
|
189
|
+
try:
|
|
190
|
+
sync.execute(statement, {"domain": domain_id})
|
|
191
|
+
except DBAPIError as error:
|
|
192
|
+
if sqlstate(error) == LOCK_NOT_AVAILABLE:
|
|
193
|
+
raise VaultLockTimeoutError(
|
|
194
|
+
f"the vault of Domain {domain_id!r} stayed locked for {timeout_ms} ms"
|
|
195
|
+
) from error
|
|
196
|
+
raise
|
|
197
|
+
sync.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": str(previous)})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def lock_vault_exclusive(
|
|
201
|
+
session: AsyncSession, domain_id: str, *, timeout_ms: int = 0
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Hold the Domain's vault lock exclusively until the transaction ends (module doc),
|
|
204
|
+
waiting at most `timeout_ms` (0: no limit); raises `VaultLockTimeoutError`."""
|
|
205
|
+
statement = text(_LOCK.format(function="pg_advisory_xact_lock"))
|
|
206
|
+
if timeout_ms <= 0:
|
|
207
|
+
await session.execute(statement, {"domain": domain_id})
|
|
208
|
+
return
|
|
209
|
+
previous = await session.scalar(text("SELECT current_setting('lock_timeout')"))
|
|
210
|
+
await session.execute(
|
|
211
|
+
text("SELECT set_config('lock_timeout', :t, true)"), {"t": f"{timeout_ms}ms"}
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
await session.execute(statement, {"domain": domain_id})
|
|
215
|
+
except DBAPIError as error:
|
|
216
|
+
if sqlstate(error) == LOCK_NOT_AVAILABLE:
|
|
217
|
+
raise VaultLockTimeoutError(
|
|
218
|
+
f"the vault of Domain {domain_id!r} stayed locked for {timeout_ms} ms"
|
|
219
|
+
) from error
|
|
220
|
+
raise
|
|
221
|
+
await session.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": str(previous)})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def tokenize(
|
|
225
|
+
sync: Session,
|
|
226
|
+
domain_id: str,
|
|
227
|
+
schema: Schema,
|
|
228
|
+
changes: Sequence[DataChange],
|
|
229
|
+
keys: VaultKeys | None,
|
|
230
|
+
current: RecordValues,
|
|
231
|
+
*,
|
|
232
|
+
erased_values: ErasedValues = ErasedValues.ERASE,
|
|
233
|
+
tombstones: bool = True,
|
|
234
|
+
lock_timeout_ms: int = 0,
|
|
235
|
+
) -> Tokenized:
|
|
236
|
+
"""Replace the plain values of vault Attributes in `changes` by references (module doc).
|
|
237
|
+
|
|
238
|
+
Plain values are validated first (type, canonical form, constraints), as the core rules
|
|
239
|
+
only see the reference afterwards.
|
|
240
|
+
"""
|
|
241
|
+
vaulted = vault_attributes(schema)
|
|
242
|
+
if not vaulted:
|
|
243
|
+
return Tokenized(list(changes), [], [])
|
|
244
|
+
deleted = {(change.kind, change.key) for change in changes if isinstance(change, RecordDelete)}
|
|
245
|
+
plain: list[_Plain] = []
|
|
246
|
+
claimed: list[_Claim] = []
|
|
247
|
+
issues: list[Issue] = []
|
|
248
|
+
for index, upsert in enumerate(changes):
|
|
249
|
+
if not isinstance(upsert, RecordUpsert) or upsert.record.kind not in vaulted:
|
|
250
|
+
continue
|
|
251
|
+
record = upsert.record
|
|
252
|
+
kind = schema.kinds[record.kind]
|
|
253
|
+
held = None
|
|
254
|
+
for attribute_id in vaulted[record.kind]:
|
|
255
|
+
value = record.values.get(attribute_id)
|
|
256
|
+
if value is None:
|
|
257
|
+
continue
|
|
258
|
+
if looks_like_reference(value):
|
|
259
|
+
token = vault_token(value)
|
|
260
|
+
if token is None:
|
|
261
|
+
continue # malformed: the core rules refuse it
|
|
262
|
+
if held is None:
|
|
263
|
+
state = current.record_state(record.kind, record.key)
|
|
264
|
+
held = {} if state is None else state.values
|
|
265
|
+
if held.get(attribute_id) != value:
|
|
266
|
+
claimed.append(_Claim(index, record.kind, record.key, attribute_id, token))
|
|
267
|
+
continue
|
|
268
|
+
attribute = kind.attributes[attribute_id]
|
|
269
|
+
found = check_values(
|
|
270
|
+
{attribute_id: attribute},
|
|
271
|
+
{attribute_id: value},
|
|
272
|
+
("changes", index, "record"),
|
|
273
|
+
kind=record.kind,
|
|
274
|
+
key=record.key,
|
|
275
|
+
)
|
|
276
|
+
if found:
|
|
277
|
+
issues.extend(found)
|
|
278
|
+
else:
|
|
279
|
+
plain.append(_Plain(index, record.kind, record.key, attribute_id, value))
|
|
280
|
+
if not plain and not claimed:
|
|
281
|
+
return Tokenized(list(changes), [], issues)
|
|
282
|
+
lock_vault(sync, domain_id, timeout_ms=lock_timeout_ms)
|
|
283
|
+
taken, lineage, moved, claim_issues = _check_claims(
|
|
284
|
+
sync, domain_id, claimed, deleted, current, keys
|
|
285
|
+
)
|
|
286
|
+
issues.extend(claim_issues)
|
|
287
|
+
plain.extend(taken)
|
|
288
|
+
if plain and keys is None:
|
|
289
|
+
issues.extend(
|
|
290
|
+
_issue(
|
|
291
|
+
IssueCode.VALUE_VAULT_UNAVAILABLE,
|
|
292
|
+
item,
|
|
293
|
+
"the vault is not configured (vault.token_key, vault.encryption_key)",
|
|
294
|
+
)
|
|
295
|
+
for item in plain
|
|
296
|
+
)
|
|
297
|
+
if issues:
|
|
298
|
+
return Tokenized(list(changes), [], issues)
|
|
299
|
+
erased: set[_Plain] = set()
|
|
300
|
+
if keys is not None and plain and tombstones and erased_values is not ErasedValues.ALLOW:
|
|
301
|
+
erased = _tombstoned(sync, domain_id, plain, keys)
|
|
302
|
+
if erased and erased_values is ErasedValues.REFUSE:
|
|
303
|
+
return Tokenized(
|
|
304
|
+
list(changes),
|
|
305
|
+
[],
|
|
306
|
+
[
|
|
307
|
+
_issue(IssueCode.VALUE_VAULT_ERASED, item, "this value was erased")
|
|
308
|
+
for item in plain
|
|
309
|
+
if item in erased
|
|
310
|
+
],
|
|
311
|
+
)
|
|
312
|
+
assert keys is not None or not plain
|
|
313
|
+
tokens, rows, exhausted = _mint(sync, domain_id, plain, keys, erased) if keys else ({}, [], [])
|
|
314
|
+
issues.extend(
|
|
315
|
+
_issue(
|
|
316
|
+
IssueCode.VALUE_VAULT_UNAVAILABLE,
|
|
317
|
+
item,
|
|
318
|
+
f"this value was erased {MAX_GENERATIONS} times and cannot be stored again",
|
|
319
|
+
)
|
|
320
|
+
for item in exhausted
|
|
321
|
+
)
|
|
322
|
+
if issues:
|
|
323
|
+
return Tokenized(list(changes), [], issues)
|
|
324
|
+
replaced: dict[int, dict[str, JsonValue]] = defaultdict(dict)
|
|
325
|
+
for item in plain:
|
|
326
|
+
replaced[item.index][item.attribute_id] = vault_reference(tokens[item])
|
|
327
|
+
out = list(changes)
|
|
328
|
+
for index, values in replaced.items():
|
|
329
|
+
upsert = out[index]
|
|
330
|
+
assert isinstance(upsert, RecordUpsert)
|
|
331
|
+
updated: Record = upsert.record.model_copy(
|
|
332
|
+
update={"values": {**upsert.record.values, **values}}
|
|
333
|
+
)
|
|
334
|
+
out[index] = RecordUpsert(record=updated)
|
|
335
|
+
return Tokenized(out, rows, [], lineage, moved)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _issue(code: IssueCode, item: _Plain | _Claim, message: str) -> Issue:
|
|
339
|
+
return Issue(
|
|
340
|
+
code=code,
|
|
341
|
+
path=("changes", item.index, "record", "values", item.attribute_id),
|
|
342
|
+
message=message,
|
|
343
|
+
kind=item.kind_id,
|
|
344
|
+
key=item.key,
|
|
345
|
+
attribute=item.attribute_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _check_claims(
|
|
350
|
+
sync: Session,
|
|
351
|
+
domain_id: str,
|
|
352
|
+
claimed: Sequence[_Claim],
|
|
353
|
+
deleted: Collection[tuple[str, str]],
|
|
354
|
+
current: RecordValues,
|
|
355
|
+
keys: VaultKeys | None,
|
|
356
|
+
) -> tuple[list[_Plain], list[Lineage], list[Move], list[Issue]]:
|
|
357
|
+
"""References no vault row binds to the Record and Attribute claiming them: the values of
|
|
358
|
+
key changes to store again under the new key, the lineage they make, the tokens they move
|
|
359
|
+
and issues for the others. A token moved once (`moved_to`) is taken over by no other key
|
|
360
|
+
change and claimed again by no Record; its rows are locked, so two commits cannot move it
|
|
361
|
+
at the same time."""
|
|
362
|
+
if not claimed:
|
|
363
|
+
return [], [], [], []
|
|
364
|
+
bindings = _bindings(sync, domain_id, {claim.token for claim in claimed}, for_update=True)
|
|
365
|
+
claims_per_token = Counter(claim.token for claim in claimed)
|
|
366
|
+
taken: list[tuple[_Claim, str]] = []
|
|
367
|
+
lineage: dict[tuple[str, str, str], Lineage] = {}
|
|
368
|
+
moved: dict[str, tuple[str, str, str, str]] = {}
|
|
369
|
+
issues: list[Issue] = []
|
|
370
|
+
for claim in claimed:
|
|
371
|
+
found = bindings.get(claim.token)
|
|
372
|
+
if found is not None and (found.kind_id, found.attribute_id) == (
|
|
373
|
+
claim.kind_id,
|
|
374
|
+
claim.attribute_id,
|
|
375
|
+
):
|
|
376
|
+
# An erased token carries no value: it may be held and moved freely.
|
|
377
|
+
free = found.erased or found.moved_to is None
|
|
378
|
+
if found.record_key == claim.key and free:
|
|
379
|
+
continue
|
|
380
|
+
source = (
|
|
381
|
+
_moved(claim, found, deleted, current)
|
|
382
|
+
if found.record_key != claim.key
|
|
383
|
+
and (free or found.moved_to == claim.key) # the same move, on another branch
|
|
384
|
+
and claims_per_token[claim.token] == 1
|
|
385
|
+
else None
|
|
386
|
+
)
|
|
387
|
+
if source is not None:
|
|
388
|
+
lineage[(claim.kind_id, claim.key, source)] = Lineage(
|
|
389
|
+
claim.kind_id, claim.key, source
|
|
390
|
+
)
|
|
391
|
+
if not found.erased:
|
|
392
|
+
moved[claim.token] = (
|
|
393
|
+
claim.kind_id,
|
|
394
|
+
claim.attribute_id,
|
|
395
|
+
found.record_key,
|
|
396
|
+
claim.key,
|
|
397
|
+
)
|
|
398
|
+
taken.append((claim, found.record_key))
|
|
399
|
+
continue # an erased value moves as the erased reference it is
|
|
400
|
+
issues.append(
|
|
401
|
+
_issue(
|
|
402
|
+
IssueCode.VALUE_VAULT_REFERENCE,
|
|
403
|
+
claim,
|
|
404
|
+
"the vault reference does not belong to this Record and Attribute",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
if issues or not taken:
|
|
408
|
+
return [], list(lineage.values()), _moves(moved), issues
|
|
409
|
+
opened = _open_sync(sync, domain_id, [claim.token for claim, _ in taken], keys)
|
|
410
|
+
plain: list[_Plain] = []
|
|
411
|
+
for claim, _ in taken:
|
|
412
|
+
value = opened.get(claim.token)
|
|
413
|
+
if value is None:
|
|
414
|
+
issues.append(
|
|
415
|
+
_issue(
|
|
416
|
+
IssueCode.VALUE_VAULT_UNAVAILABLE,
|
|
417
|
+
claim,
|
|
418
|
+
"the value of the vault reference cannot be opened with the configured keys",
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
plain.append(_Plain(claim.index, claim.kind_id, claim.key, claim.attribute_id, value))
|
|
423
|
+
return plain, list(lineage.values()), _moves(moved), issues
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _moved(
|
|
427
|
+
claim: _Claim,
|
|
428
|
+
found: _Binding,
|
|
429
|
+
deleted: Collection[tuple[str, str]],
|
|
430
|
+
current: RecordValues,
|
|
431
|
+
) -> str | None:
|
|
432
|
+
"""The key of the Record the claim takes the reference over from: one the same change set
|
|
433
|
+
deletes, which holds it in that Attribute at the branch head, for a Record that does not
|
|
434
|
+
exist there yet. A live token comes from the Record its row binds it to; an erased one,
|
|
435
|
+
which a key change keeps as it is, from whichever deleted Record holds it."""
|
|
436
|
+
if current.record_state(claim.kind_id, claim.key) is not None:
|
|
437
|
+
return None
|
|
438
|
+
candidates = (
|
|
439
|
+
[key for kind_id, key in deleted if kind_id == claim.kind_id]
|
|
440
|
+
if found.erased
|
|
441
|
+
else [found.record_key]
|
|
442
|
+
if (claim.kind_id, found.record_key) in deleted
|
|
443
|
+
else []
|
|
444
|
+
)
|
|
445
|
+
for key in sorted(candidates):
|
|
446
|
+
state = current.record_state(claim.kind_id, key)
|
|
447
|
+
held = None if state is None else state.values.get(claim.attribute_id)
|
|
448
|
+
if vault_token(held) == claim.token:
|
|
449
|
+
return key
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _open_sync(
|
|
454
|
+
sync: Session, domain_id: str, tokens: Sequence[str], keys: VaultKeys | None
|
|
455
|
+
) -> dict[str, JsonValue]:
|
|
456
|
+
"""The values of live rows that open with `keys`."""
|
|
457
|
+
if keys is None:
|
|
458
|
+
return {}
|
|
459
|
+
v = VAULT
|
|
460
|
+
out: dict[str, JsonValue] = {}
|
|
461
|
+
ordered = sorted(set(tokens))
|
|
462
|
+
for start in range(0, len(ordered), _CHUNK):
|
|
463
|
+
rows = sync.execute(
|
|
464
|
+
select(v.c.id, v.c.value_ciphertext, v.c.key_id).where(
|
|
465
|
+
v.c.domain_id == domain_id,
|
|
466
|
+
v.c.id.in_(ordered[start : start + _CHUNK]),
|
|
467
|
+
v.c.value_ciphertext.is_not(None),
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
for row in rows:
|
|
471
|
+
opened = keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
|
|
472
|
+
if opened is not None:
|
|
473
|
+
out[row.id] = json.loads(opened)
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _tombstoned(
|
|
478
|
+
sync: Session, domain_id: str, plain: Sequence[_Plain], keys: VaultKeys
|
|
479
|
+
) -> set[_Plain]:
|
|
480
|
+
"""The plain values an erasure recorded, for their Record or for every Record of the
|
|
481
|
+
Kind."""
|
|
482
|
+
fingerprints = {
|
|
483
|
+
item: keys.fingerprints(domain_id, item.kind_id, item.attribute_id, item.value)
|
|
484
|
+
for item in plain
|
|
485
|
+
}
|
|
486
|
+
wanted = sorted({f for found in fingerprints.values() for f in found})
|
|
487
|
+
t = TOMBSTONES
|
|
488
|
+
erased: dict[bytes, list[tuple[str, str, str | None]]] = defaultdict(list)
|
|
489
|
+
for start in range(0, len(wanted), _CHUNK):
|
|
490
|
+
rows = sync.execute(
|
|
491
|
+
select(t.c.fingerprint, t.c.kind_id, t.c.attribute_id, t.c.record_key).where(
|
|
492
|
+
t.c.domain_id == domain_id, t.c.fingerprint.in_(wanted[start : start + _CHUNK])
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
for row in rows:
|
|
496
|
+
erased[bytes(row.fingerprint)].append((row.kind_id, row.attribute_id, row.record_key))
|
|
497
|
+
if not erased:
|
|
498
|
+
return set()
|
|
499
|
+
return {
|
|
500
|
+
item
|
|
501
|
+
for item, found in fingerprints.items()
|
|
502
|
+
if any(
|
|
503
|
+
kind_id == item.kind_id
|
|
504
|
+
and attribute_id == item.attribute_id
|
|
505
|
+
and record_key in (None, item.key)
|
|
506
|
+
for fingerprint in found
|
|
507
|
+
for kind_id, attribute_id, record_key in erased.get(fingerprint, ())
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _bindings(
|
|
513
|
+
sync: Session, domain_id: str, tokens: Collection[str], *, for_update: bool = False
|
|
514
|
+
) -> dict[str, _Binding]:
|
|
515
|
+
v = VAULT
|
|
516
|
+
found: dict[str, _Binding] = {}
|
|
517
|
+
ordered = sorted(tokens)
|
|
518
|
+
for start in range(0, len(ordered), _CHUNK):
|
|
519
|
+
query = (
|
|
520
|
+
select(
|
|
521
|
+
v.c.id, v.c.kind_id, v.c.attribute_id, v.c.record_key, v.c.erased_at, v.c.moved_to
|
|
522
|
+
)
|
|
523
|
+
.where(v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK]))
|
|
524
|
+
.order_by(v.c.id)
|
|
525
|
+
)
|
|
526
|
+
if for_update:
|
|
527
|
+
query = query.with_for_update()
|
|
528
|
+
for row in sync.execute(query):
|
|
529
|
+
found[row.id] = _Binding(
|
|
530
|
+
row.kind_id,
|
|
531
|
+
row.attribute_id,
|
|
532
|
+
row.record_key,
|
|
533
|
+
row.erased_at is not None,
|
|
534
|
+
row.moved_to,
|
|
535
|
+
)
|
|
536
|
+
return found
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@dataclass(slots=True)
|
|
540
|
+
class _Search:
|
|
541
|
+
"""Where the generation search of one value stands."""
|
|
542
|
+
|
|
543
|
+
generation: int = 0
|
|
544
|
+
free: int | None = None
|
|
545
|
+
"""Lowest generation whose token under the current key has no row."""
|
|
546
|
+
erased: str | None = None
|
|
547
|
+
"""An erased token of the value (any key), reused by a write of an erased value."""
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _mint(
|
|
551
|
+
sync: Session,
|
|
552
|
+
domain_id: str,
|
|
553
|
+
plain: Sequence[_Plain],
|
|
554
|
+
keys: VaultKeys,
|
|
555
|
+
erase: Collection[_Plain],
|
|
556
|
+
) -> tuple[dict[_Plain, str], list[VaultRow], list[_Plain]]:
|
|
557
|
+
"""Token of every plain value, the rows to insert and the values whose generations ran
|
|
558
|
+
out.
|
|
559
|
+
|
|
560
|
+
Generation by generation, the value's token under each configured token key is looked up:
|
|
561
|
+
a live row (under any key) is reused as it is. Rows of a key exist for consecutive
|
|
562
|
+
generations from 0, so a generation without a row under any key ends the search; the value
|
|
563
|
+
then gets the token of the lowest free generation under the current key. A value an erasure
|
|
564
|
+
recorded (`erase`) never reuses a live row: it reuses an erased token of its own, or gets a
|
|
565
|
+
new erased row.
|
|
566
|
+
"""
|
|
567
|
+
tokens: dict[_Plain, str] = {}
|
|
568
|
+
rows: dict[str, VaultRow] = {}
|
|
569
|
+
exhausted: list[_Plain] = []
|
|
570
|
+
pending = {item: _Search() for item in plain}
|
|
571
|
+
|
|
572
|
+
def mint(item: _Plain, search: _Search) -> None:
|
|
573
|
+
if item in erase and search.erased is not None:
|
|
574
|
+
tokens[item] = search.erased
|
|
575
|
+
return
|
|
576
|
+
if search.free is None:
|
|
577
|
+
exhausted.append(item)
|
|
578
|
+
return
|
|
579
|
+
token = keys.token(
|
|
580
|
+
domain_id, item.kind_id, item.key, item.attribute_id, item.value, search.free
|
|
581
|
+
)
|
|
582
|
+
tokens[item] = token
|
|
583
|
+
if token in rows:
|
|
584
|
+
return
|
|
585
|
+
if item in erase:
|
|
586
|
+
rows[token] = VaultRow(
|
|
587
|
+
token, item.kind_id, item.attribute_id, item.key, None, None, None, erased=True
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
rows[token] = VaultRow(
|
|
591
|
+
token=token,
|
|
592
|
+
kind_id=item.kind_id,
|
|
593
|
+
attribute_id=item.attribute_id,
|
|
594
|
+
record_key=item.key,
|
|
595
|
+
fingerprint=keys.fingerprint(domain_id, item.kind_id, item.attribute_id, item.value),
|
|
596
|
+
ciphertext=keys.seal(domain_id, token, item.value),
|
|
597
|
+
key_id=keys.key_id,
|
|
598
|
+
fingerprint_key=keys.fingerprint_key,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
while pending:
|
|
602
|
+
candidates = {
|
|
603
|
+
item: [
|
|
604
|
+
keys.token(
|
|
605
|
+
domain_id,
|
|
606
|
+
item.kind_id,
|
|
607
|
+
item.key,
|
|
608
|
+
item.attribute_id,
|
|
609
|
+
item.value,
|
|
610
|
+
search.generation,
|
|
611
|
+
),
|
|
612
|
+
*keys.earlier_tokens(
|
|
613
|
+
domain_id,
|
|
614
|
+
item.kind_id,
|
|
615
|
+
item.key,
|
|
616
|
+
item.attribute_id,
|
|
617
|
+
item.value,
|
|
618
|
+
search.generation,
|
|
619
|
+
),
|
|
620
|
+
]
|
|
621
|
+
for item, search in pending.items()
|
|
622
|
+
}
|
|
623
|
+
existing = _bindings(sync, domain_id, {t for found in candidates.values() for t in found})
|
|
624
|
+
retry: dict[_Plain, _Search] = {}
|
|
625
|
+
for item, found in candidates.items():
|
|
626
|
+
search = pending[item]
|
|
627
|
+
stored = [(token, existing[token]) for token in found if token in existing]
|
|
628
|
+
live = next((token for token, binding in stored if not binding.erased), None)
|
|
629
|
+
if live is not None and item not in erase:
|
|
630
|
+
tokens[item] = live
|
|
631
|
+
continue
|
|
632
|
+
if search.erased is None:
|
|
633
|
+
search.erased = next((token for token, b in stored if b.erased), None)
|
|
634
|
+
if search.free is None and found[0] not in existing:
|
|
635
|
+
search.free = search.generation
|
|
636
|
+
if not stored or search.generation + 1 >= MAX_GENERATIONS:
|
|
637
|
+
mint(item, search)
|
|
638
|
+
continue
|
|
639
|
+
search.generation += 1
|
|
640
|
+
retry[item] = search
|
|
641
|
+
pending = retry
|
|
642
|
+
return tokens, list(rows.values()), exhausted
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def insert_rows(
|
|
646
|
+
session: AsyncSession, domain_id: str, rows: Sequence[VaultRow], *, at: datetime
|
|
647
|
+
) -> None:
|
|
648
|
+
"""Insert rows minted by `tokenize`; a token stored meanwhile is kept as it is."""
|
|
649
|
+
if not rows:
|
|
650
|
+
return
|
|
651
|
+
for start in range(0, len(rows), 500):
|
|
652
|
+
chunk = rows[start : start + 500]
|
|
653
|
+
await session.execute(
|
|
654
|
+
insert(VAULT)
|
|
655
|
+
.values(
|
|
656
|
+
[
|
|
657
|
+
{
|
|
658
|
+
"domain_id": domain_id,
|
|
659
|
+
"id": row.token,
|
|
660
|
+
"kind_id": row.kind_id,
|
|
661
|
+
"attribute_id": row.attribute_id,
|
|
662
|
+
"record_key": row.record_key,
|
|
663
|
+
"fingerprint": row.fingerprint,
|
|
664
|
+
"fingerprint_key": row.fingerprint_key,
|
|
665
|
+
"value_ciphertext": row.ciphertext,
|
|
666
|
+
"key_id": row.key_id,
|
|
667
|
+
"created_at": at,
|
|
668
|
+
"erased_at": at if row.erased else None,
|
|
669
|
+
}
|
|
670
|
+
for row in chunk
|
|
671
|
+
]
|
|
672
|
+
)
|
|
673
|
+
.on_conflict_do_nothing(index_elements=["domain_id", "id"])
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
async def insert_lineage(
|
|
678
|
+
session: AsyncSession, domain_id: str, lineage: Sequence[Lineage], *, at: datetime
|
|
679
|
+
) -> None:
|
|
680
|
+
"""Record key changes found by `tokenize`."""
|
|
681
|
+
if not lineage:
|
|
682
|
+
return
|
|
683
|
+
await session.execute(
|
|
684
|
+
insert(LINEAGE)
|
|
685
|
+
.values(
|
|
686
|
+
[
|
|
687
|
+
{
|
|
688
|
+
"domain_id": domain_id,
|
|
689
|
+
"kind_id": item.kind_id,
|
|
690
|
+
"record_key": item.record_key,
|
|
691
|
+
"previous_key": item.previous_key,
|
|
692
|
+
"created_at": at,
|
|
693
|
+
}
|
|
694
|
+
for item in lineage
|
|
695
|
+
]
|
|
696
|
+
)
|
|
697
|
+
.on_conflict_do_nothing()
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class VaultState(StrEnum):
|
|
702
|
+
VALUE = "value"
|
|
703
|
+
ERASED = "erased"
|
|
704
|
+
UNAVAILABLE = "unavailable"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@dataclass(frozen=True, slots=True)
|
|
708
|
+
class VaultValue:
|
|
709
|
+
"""What a token stands for. `kind_id`/`attribute_id` are None for an unknown token (from a
|
|
710
|
+
bundle of another installation, say), which reads as erased."""
|
|
711
|
+
|
|
712
|
+
state: VaultState
|
|
713
|
+
kind_id: str | None = None
|
|
714
|
+
attribute_id: str | None = None
|
|
715
|
+
value: JsonValue = None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
async def open_values(
|
|
719
|
+
session: AsyncSession, domain_id: str, tokens: Iterable[str], keys: VaultKeys | None
|
|
720
|
+
) -> dict[str, VaultValue]:
|
|
721
|
+
"""The value of each token (see `VaultValue`)."""
|
|
722
|
+
wanted = sorted(set(tokens))
|
|
723
|
+
out: dict[str, VaultValue] = {t: VaultValue(VaultState.ERASED) for t in wanted}
|
|
724
|
+
v = VAULT
|
|
725
|
+
for start in range(0, len(wanted), _CHUNK):
|
|
726
|
+
rows = await session.execute(
|
|
727
|
+
select(v.c.id, v.c.kind_id, v.c.attribute_id, v.c.value_ciphertext, v.c.key_id).where(
|
|
728
|
+
v.c.domain_id == domain_id, v.c.id.in_(wanted[start : start + _CHUNK])
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
for row in rows:
|
|
732
|
+
if row.value_ciphertext is None or row.key_id is None:
|
|
733
|
+
out[row.id] = VaultValue(VaultState.ERASED, row.kind_id, row.attribute_id)
|
|
734
|
+
continue
|
|
735
|
+
opened = (
|
|
736
|
+
None
|
|
737
|
+
if keys is None
|
|
738
|
+
else keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
|
|
739
|
+
)
|
|
740
|
+
if opened is None:
|
|
741
|
+
out[row.id] = VaultValue(VaultState.UNAVAILABLE, row.kind_id, row.attribute_id)
|
|
742
|
+
else:
|
|
743
|
+
out[row.id] = VaultValue(
|
|
744
|
+
VaultState.VALUE, row.kind_id, row.attribute_id, json.loads(opened)
|
|
745
|
+
)
|
|
746
|
+
return out
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
async def linked_keys(session: AsyncSession, domain_id: str, kind_id: str, key: str) -> list[str]:
|
|
750
|
+
"""`key` and every key of the Kind linked to it by `vault_lineage`, in both directions, read
|
|
751
|
+
a batch of keys at a time (no limit: erasure never stops short)."""
|
|
752
|
+
seen = {key}
|
|
753
|
+
frontier = sorted(seen)
|
|
754
|
+
lin = LINEAGE
|
|
755
|
+
while frontier:
|
|
756
|
+
found: set[str] = set()
|
|
757
|
+
for start in range(0, len(frontier), _CHUNK):
|
|
758
|
+
batch = frontier[start : start + _CHUNK]
|
|
759
|
+
rows = await session.execute(
|
|
760
|
+
select(lin.c.record_key, lin.c.previous_key).where(
|
|
761
|
+
lin.c.domain_id == domain_id,
|
|
762
|
+
lin.c.kind_id == kind_id,
|
|
763
|
+
(lin.c.record_key.in_(batch)) | (lin.c.previous_key.in_(batch)),
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
found.update(k for row in rows for k in (row.record_key, row.previous_key))
|
|
767
|
+
frontier = sorted(found - seen)
|
|
768
|
+
seen |= found
|
|
769
|
+
return sorted(seen)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@dataclass(frozen=True, slots=True)
|
|
773
|
+
class Erasure:
|
|
774
|
+
"""What an erasure did: `erased` rows erased now; `unopenable` rows it looked at that are
|
|
775
|
+
sealed under a key no longer configured, whose fingerprints could not be made again (an
|
|
776
|
+
erasure by value may miss a case variant among them); `keys` Record keys it covered
|
|
777
|
+
(`linked`: more than `MAX_LINKED_KEYS`, which an administrator should look at: a data
|
|
778
|
+
subject is rarely renamed that often)."""
|
|
779
|
+
|
|
780
|
+
erased: int
|
|
781
|
+
unopenable: int = 0
|
|
782
|
+
keys: int = 1
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def linked(self) -> bool:
|
|
786
|
+
return self.keys > MAX_LINKED_KEYS
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
async def erase_record(
|
|
790
|
+
session: AsyncSession,
|
|
791
|
+
domain_id: str,
|
|
792
|
+
kind_id: str,
|
|
793
|
+
key: str,
|
|
794
|
+
attribute_ids: Collection[str] | None = None,
|
|
795
|
+
*,
|
|
796
|
+
keys: VaultKeys | None = None,
|
|
797
|
+
at: datetime | None = None,
|
|
798
|
+
lock_timeout_ms: int = 0,
|
|
799
|
+
) -> Erasure:
|
|
800
|
+
"""Erase every vaulted value a Record ever held (of `attribute_ids` only, when given), on
|
|
801
|
+
every branch and Revision and under every key it had (`vault_lineage`).
|
|
802
|
+
|
|
803
|
+
The Domain's vault lock is taken first (waiting at most `lock_timeout_ms`, else
|
|
804
|
+
`VaultLockTimeoutError`): the keys are read under it, so a key change committing meanwhile
|
|
805
|
+
is either seen or waits. With `keys`, the rows' fingerprints are made again under the
|
|
806
|
+
current keys before they are recorded as tombstones, so those match case variants written
|
|
807
|
+
later. Keys are erased a batch at a time. The costly part of that, after a key rotation,
|
|
808
|
+
is done before the lock is taken, so commits wait only for a second, cheap pass."""
|
|
809
|
+
if keys is not None:
|
|
810
|
+
for _, batch in _batches(await linked_keys(session, domain_id, kind_id, key)):
|
|
811
|
+
await _refresh(
|
|
812
|
+
session, keys, _record_condition(domain_id, kind_id, batch, attribute_ids)
|
|
813
|
+
)
|
|
814
|
+
await lock_vault_exclusive(session, domain_id, timeout_ms=lock_timeout_ms)
|
|
815
|
+
found = await linked_keys(session, domain_id, kind_id, key)
|
|
816
|
+
erased = unopenable = 0
|
|
817
|
+
for _, batch in _batches(found):
|
|
818
|
+
condition = _record_condition(domain_id, kind_id, batch, attribute_ids)
|
|
819
|
+
if keys is not None:
|
|
820
|
+
unopenable += await _refresh(session, keys, condition)
|
|
821
|
+
erased += await _erase(session, domain_id, condition, at)
|
|
822
|
+
return Erasure(erased, unopenable, len(found))
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
async def erase_matching(
|
|
826
|
+
session: AsyncSession,
|
|
827
|
+
domain_id: str,
|
|
828
|
+
kind_id: str,
|
|
829
|
+
attribute_id: str,
|
|
830
|
+
value: JsonValue,
|
|
831
|
+
keys: VaultKeys,
|
|
832
|
+
*,
|
|
833
|
+
at: datetime | None = None,
|
|
834
|
+
lock_timeout_ms: int = 0,
|
|
835
|
+
) -> Erasure:
|
|
836
|
+
"""Erase the canonical `value` and its case variants (same matching form) in every Record
|
|
837
|
+
of the Kind. Under the Domain's vault lock (taken first, see `erase_record`), rows whose
|
|
838
|
+
fingerprints were made under an earlier key or scheme are made again, so none that opens
|
|
839
|
+
is missed; the costly first pass runs before the lock is taken."""
|
|
840
|
+
v = VAULT
|
|
841
|
+
scope = [v.c.domain_id == domain_id, v.c.kind_id == kind_id, v.c.attribute_id == attribute_id]
|
|
842
|
+
await _refresh(session, keys, scope)
|
|
843
|
+
await lock_vault_exclusive(session, domain_id, timeout_ms=lock_timeout_ms)
|
|
844
|
+
unopenable = await _refresh(session, keys, scope)
|
|
845
|
+
await session.execute(
|
|
846
|
+
insert(TOMBSTONES).values(
|
|
847
|
+
domain_id=domain_id,
|
|
848
|
+
kind_id=kind_id,
|
|
849
|
+
attribute_id=attribute_id,
|
|
850
|
+
record_key=None,
|
|
851
|
+
fingerprint=keys.fingerprint(domain_id, kind_id, attribute_id, value),
|
|
852
|
+
fingerprint_key=keys.fingerprint_key,
|
|
853
|
+
created_at=at or func.now(),
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
fingerprints = keys.fingerprints(domain_id, kind_id, attribute_id, value)
|
|
857
|
+
erased = await _erase(session, domain_id, [*scope, v.c.fingerprint.in_(fingerprints)], at)
|
|
858
|
+
return Erasure(erased, unopenable)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
async def mark_moved(session: AsyncSession, domain_id: str, moved: Sequence[Move]) -> None:
|
|
862
|
+
"""Record the key changes `tokenize` found. Tokens moved earlier to the key a value moves
|
|
863
|
+
away from now point at its new key, so a chain of renames is followed to its end."""
|
|
864
|
+
v = VAULT
|
|
865
|
+
for move in moved:
|
|
866
|
+
await session.execute(
|
|
867
|
+
update(v)
|
|
868
|
+
.where(v.c.domain_id == domain_id, v.c.id == move.token)
|
|
869
|
+
.values(moved_to=move.key)
|
|
870
|
+
)
|
|
871
|
+
await session.execute(
|
|
872
|
+
update(v)
|
|
873
|
+
.where(
|
|
874
|
+
v.c.domain_id == domain_id,
|
|
875
|
+
v.c.kind_id == move.kind_id,
|
|
876
|
+
v.c.attribute_id == move.attribute_id,
|
|
877
|
+
v.c.moved_to == move.previous_key,
|
|
878
|
+
)
|
|
879
|
+
.values(moved_to=move.key)
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _batches(keys: Sequence[str]) -> list[tuple[int, Sequence[str]]]:
|
|
884
|
+
return [(start, keys[start : start + _CHUNK]) for start in range(0, len(keys), _CHUNK)]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _record_condition(
|
|
888
|
+
domain_id: str, kind_id: str, keys: Sequence[str], attribute_ids: Collection[str] | None
|
|
889
|
+
) -> list[Any]:
|
|
890
|
+
v = VAULT
|
|
891
|
+
condition = [v.c.domain_id == domain_id, v.c.kind_id == kind_id, v.c.record_key.in_(keys)]
|
|
892
|
+
if attribute_ids is not None:
|
|
893
|
+
condition.append(v.c.attribute_id.in_(sorted(attribute_ids)))
|
|
894
|
+
return condition
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
async def _refresh(session: AsyncSession, keys: VaultKeys, condition: list[Any]) -> int:
|
|
898
|
+
"""Make the outdated rows matching `condition` again in batches; the rows that do not
|
|
899
|
+
open."""
|
|
900
|
+
from lorebase_store.vault.rotation import refresh_rows
|
|
901
|
+
|
|
902
|
+
unopenable = 0
|
|
903
|
+
after: tuple[str, str] | None = None
|
|
904
|
+
while True:
|
|
905
|
+
report = await refresh_rows(session, keys, condition, limit=_CHUNK, after=after)
|
|
906
|
+
unopenable += report.unopenable
|
|
907
|
+
if report.rows < _CHUNK or report.after is None:
|
|
908
|
+
return unopenable
|
|
909
|
+
after = report.after
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
async def matching_keys(
|
|
913
|
+
session: AsyncSession,
|
|
914
|
+
domain_id: str,
|
|
915
|
+
kind_id: str,
|
|
916
|
+
attribute_id: str,
|
|
917
|
+
value: JsonValue,
|
|
918
|
+
keys: VaultKeys,
|
|
919
|
+
) -> list[str]:
|
|
920
|
+
"""Keys of the Records whose live vault rows hold the value or a case variant of it."""
|
|
921
|
+
v = VAULT
|
|
922
|
+
rows = await session.execute(
|
|
923
|
+
select(v.c.record_key)
|
|
924
|
+
.where(
|
|
925
|
+
v.c.domain_id == domain_id,
|
|
926
|
+
v.c.kind_id == kind_id,
|
|
927
|
+
v.c.attribute_id == attribute_id,
|
|
928
|
+
v.c.fingerprint.in_(keys.fingerprints(domain_id, kind_id, attribute_id, value)),
|
|
929
|
+
)
|
|
930
|
+
.distinct()
|
|
931
|
+
.order_by(v.c.record_key)
|
|
932
|
+
)
|
|
933
|
+
return list(rows.scalars())
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
async def _erase(
|
|
937
|
+
session: AsyncSession, domain_id: str, condition: list[Any], at: datetime | None
|
|
938
|
+
) -> int:
|
|
939
|
+
"""Erase the live rows matching `condition` and record their fingerprints."""
|
|
940
|
+
v = VAULT
|
|
941
|
+
erased = (
|
|
942
|
+
await session.execute(
|
|
943
|
+
select(
|
|
944
|
+
v.c.kind_id, v.c.attribute_id, v.c.record_key, v.c.fingerprint, v.c.fingerprint_key
|
|
945
|
+
)
|
|
946
|
+
.where(*condition, v.c.erased_at.is_(None))
|
|
947
|
+
.order_by(v.c.id)
|
|
948
|
+
.with_for_update()
|
|
949
|
+
)
|
|
950
|
+
).all()
|
|
951
|
+
tombstones = {
|
|
952
|
+
(row.kind_id, row.attribute_id, row.record_key, bytes(row.fingerprint)): row.fingerprint_key
|
|
953
|
+
for row in erased
|
|
954
|
+
if row.fingerprint is not None
|
|
955
|
+
}
|
|
956
|
+
if tombstones:
|
|
957
|
+
await session.execute(
|
|
958
|
+
insert(TOMBSTONES).values(
|
|
959
|
+
[
|
|
960
|
+
{
|
|
961
|
+
"domain_id": domain_id,
|
|
962
|
+
"kind_id": kind_id,
|
|
963
|
+
"attribute_id": attribute_id,
|
|
964
|
+
"record_key": record_key,
|
|
965
|
+
"fingerprint": fingerprint,
|
|
966
|
+
"fingerprint_key": fingerprint_key,
|
|
967
|
+
"created_at": at or func.now(),
|
|
968
|
+
}
|
|
969
|
+
for (kind_id, attribute_id, record_key, fingerprint), fingerprint_key in sorted(
|
|
970
|
+
tombstones.items(), key=lambda item: item[0]
|
|
971
|
+
)
|
|
972
|
+
]
|
|
973
|
+
)
|
|
974
|
+
)
|
|
975
|
+
result = await session.execute(
|
|
976
|
+
update(v)
|
|
977
|
+
.where(*condition, v.c.erased_at.is_(None))
|
|
978
|
+
.values(
|
|
979
|
+
value_ciphertext=None,
|
|
980
|
+
key_id=None,
|
|
981
|
+
fingerprint=None,
|
|
982
|
+
fingerprint_key=None,
|
|
983
|
+
erased_at=at or func.now(),
|
|
984
|
+
)
|
|
985
|
+
.returning(v.c.id)
|
|
986
|
+
)
|
|
987
|
+
return len(result.all())
|