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,216 @@
|
|
|
1
|
+
"""Tokens, fingerprints and encryption of vault values (design 13.5).
|
|
2
|
+
|
|
3
|
+
- **Token** of a value: `vlt_` + the first 128 bits of HMAC-SHA256 (key derived from
|
|
4
|
+
`vault.token_key`) over the Domain, Kind, Record key, Attribute, generation and the value's
|
|
5
|
+
canonical JSON. The same value of the same Attribute of the same Record gets the same token,
|
|
6
|
+
so writing it again changes nothing and merges compare tokens as they would compare values.
|
|
7
|
+
Two Records holding the same value get different tokens: erasing one data subject never
|
|
8
|
+
erases another's value. The generation starts at 0 and moves on only when the token of a
|
|
9
|
+
lower one was erased, so writing an erased value again stores it under a new token and the
|
|
10
|
+
Revisions that held the erased one keep reading `erased`.
|
|
11
|
+
- **Fingerprint** of a value: HMAC-SHA256 over the Domain, Kind, Attribute and the value's
|
|
12
|
+
matching form (`lorebase_core.types.matching`: case, compatibility characters and white space
|
|
13
|
+
ignored), without the Record key; erasure by value finds the rows of every Record holding it,
|
|
14
|
+
whichever way it was written. Rows written before matching forms existed hold the fingerprint
|
|
15
|
+
of the canonical value (scheme 1); the rotation job moves them to the current scheme.
|
|
16
|
+
- **Encryption**: AES-256-GCM under a key derived from `vault.encryption_key` (or one of
|
|
17
|
+
`previous_keys`, by the row's `key_id`), with the Domain and token as associated data, so a
|
|
18
|
+
ciphertext copied to another row does not open.
|
|
19
|
+
|
|
20
|
+
Both keys rotate. A new `encryption_key` seals new values; `lorebase_store.vault.rotation`
|
|
21
|
+
seals the stored ones again. A new `token_key` (with its own `token_key_id`, the old one kept in
|
|
22
|
+
`previous_token_keys`) mints new tokens: a value written again first looks for the token it got
|
|
23
|
+
under each earlier key, so unchanged values stay unchanged, and fingerprints are matched under
|
|
24
|
+
every configured token key until the rotation job has made them again under the current one.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
|
|
31
|
+
from pydantic import JsonValue
|
|
32
|
+
|
|
33
|
+
from lorebase_core.canonical import jcs_bytes
|
|
34
|
+
from lorebase_core.model.vault import TOKEN_PREFIX
|
|
35
|
+
from lorebase_core.types import matching_form
|
|
36
|
+
from lorebase_store.keyring import Keyring
|
|
37
|
+
from lorebase_store.vault.settings import VaultSettings
|
|
38
|
+
|
|
39
|
+
NAMESPACE = "vault"
|
|
40
|
+
_TOKEN = "token"
|
|
41
|
+
_FINGERPRINT = "fingerprint"
|
|
42
|
+
_MATCHING = "fingerprint/2"
|
|
43
|
+
FINGERPRINT_SCHEME = 2
|
|
44
|
+
"""Current fingerprint scheme: HMAC of the matching form. Scheme 1 hashed the canonical value."""
|
|
45
|
+
_VALUE = "value"
|
|
46
|
+
_TOKEN_BYTES = 16
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def framed(*parts: str | bytes) -> bytes:
|
|
50
|
+
"""Length-prefixed concatenation: no two part lists give the same bytes."""
|
|
51
|
+
out = bytearray()
|
|
52
|
+
for part in parts:
|
|
53
|
+
data = part.encode() if isinstance(part, str) else part
|
|
54
|
+
out += len(data).to_bytes(4, "big") + data
|
|
55
|
+
return bytes(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VaultKeys:
|
|
59
|
+
"""Derives tokens and fingerprints and seals values under the configured keys."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
token_key: str,
|
|
64
|
+
key_id: str,
|
|
65
|
+
current: str,
|
|
66
|
+
previous: Mapping[str, str],
|
|
67
|
+
*,
|
|
68
|
+
token_key_id: str = "1",
|
|
69
|
+
previous_token_keys: Mapping[str, str] | None = None,
|
|
70
|
+
):
|
|
71
|
+
self._token_key_id = token_key_id
|
|
72
|
+
self._token_keys = {token_key_id: Keyring(token_key, namespace=NAMESPACE)}
|
|
73
|
+
for previous_id, secret in (previous_token_keys or {}).items():
|
|
74
|
+
self._token_keys[previous_id] = Keyring(secret, namespace=NAMESPACE)
|
|
75
|
+
self._tokens = self._token_keys[token_key_id]
|
|
76
|
+
self._key_id = key_id
|
|
77
|
+
self._keyrings = {key_id: Keyring(current, namespace=NAMESPACE)}
|
|
78
|
+
for previous_id, secret in previous.items():
|
|
79
|
+
self._keyrings[previous_id] = Keyring(secret, namespace=NAMESPACE)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_settings(cls, settings: VaultSettings) -> VaultKeys | None:
|
|
83
|
+
"""Keys of the settings; None when the vault is not configured."""
|
|
84
|
+
if settings.token_key is None or settings.encryption_key is None:
|
|
85
|
+
return None
|
|
86
|
+
return cls(
|
|
87
|
+
settings.token_key.get_secret_value(),
|
|
88
|
+
settings.key_id,
|
|
89
|
+
settings.encryption_key.get_secret_value(),
|
|
90
|
+
{k: v.get_secret_value() for k, v in settings.previous_keys.items()},
|
|
91
|
+
token_key_id=settings.token_key_id,
|
|
92
|
+
previous_token_keys={
|
|
93
|
+
k: v.get_secret_value() for k, v in settings.previous_token_keys.items()
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def key_id(self) -> str:
|
|
99
|
+
"""Id of the key new values are sealed under."""
|
|
100
|
+
return self._key_id
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def key_ids(self) -> frozenset[str]:
|
|
104
|
+
"""Ids of every configured encryption key."""
|
|
105
|
+
return frozenset(self._keyrings)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def token_key_id(self) -> str:
|
|
109
|
+
"""Id of the key new tokens and fingerprints are made under."""
|
|
110
|
+
return self._token_key_id
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def token_key_ids(self) -> frozenset[str]:
|
|
114
|
+
"""Ids of every configured token key."""
|
|
115
|
+
return frozenset(self._token_keys)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def fingerprint_key(self) -> str:
|
|
119
|
+
"""What a row stores of how its fingerprint was made: `<token_key_id>/<scheme>`."""
|
|
120
|
+
return f"{self._token_key_id}/{FINGERPRINT_SCHEME}"
|
|
121
|
+
|
|
122
|
+
def token(
|
|
123
|
+
self,
|
|
124
|
+
domain_id: str,
|
|
125
|
+
kind_id: str,
|
|
126
|
+
key: str,
|
|
127
|
+
attribute_id: str,
|
|
128
|
+
value: JsonValue,
|
|
129
|
+
generation: int = 0,
|
|
130
|
+
) -> str:
|
|
131
|
+
"""The token of a value under the current token key."""
|
|
132
|
+
return _token(self._tokens, domain_id, kind_id, key, attribute_id, value, generation)
|
|
133
|
+
|
|
134
|
+
def earlier_tokens(
|
|
135
|
+
self,
|
|
136
|
+
domain_id: str,
|
|
137
|
+
kind_id: str,
|
|
138
|
+
key: str,
|
|
139
|
+
attribute_id: str,
|
|
140
|
+
value: JsonValue,
|
|
141
|
+
generation: int = 0,
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""The tokens the value had under the previous token keys."""
|
|
144
|
+
return [
|
|
145
|
+
_token(keyring, domain_id, kind_id, key, attribute_id, value, generation)
|
|
146
|
+
for key_id, keyring in self._token_keys.items()
|
|
147
|
+
if key_id != self._token_key_id
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def fingerprint(
|
|
151
|
+
self, domain_id: str, kind_id: str, attribute_id: str, value: JsonValue
|
|
152
|
+
) -> bytes:
|
|
153
|
+
"""Fingerprint of a canonical value under the current token key and scheme."""
|
|
154
|
+
return _matching(self._tokens, domain_id, kind_id, attribute_id, value)
|
|
155
|
+
|
|
156
|
+
def fingerprints(
|
|
157
|
+
self, domain_id: str, kind_id: str, attribute_id: str, value: JsonValue
|
|
158
|
+
) -> list[bytes]:
|
|
159
|
+
"""Every fingerprint a row holding the value (or a case variant of it, for rows of the
|
|
160
|
+
current scheme) may carry: each configured token key, current and earlier scheme."""
|
|
161
|
+
found: list[bytes] = []
|
|
162
|
+
for keyring in self._token_keys.values():
|
|
163
|
+
found.append(_matching(keyring, domain_id, kind_id, attribute_id, value))
|
|
164
|
+
found.append(
|
|
165
|
+
keyring.mac(
|
|
166
|
+
_FINGERPRINT, framed(domain_id, kind_id, attribute_id, jcs_bytes(value))
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return found
|
|
170
|
+
|
|
171
|
+
def digest_keyring(self) -> Keyring:
|
|
172
|
+
"""The keyring of the keyed digests when no `vault.digest_key` is set."""
|
|
173
|
+
return self._tokens
|
|
174
|
+
|
|
175
|
+
def earlier_digest_keyrings(self) -> tuple[Keyring, ...]:
|
|
176
|
+
"""The keyrings of the previous token keys, which made earlier digests."""
|
|
177
|
+
return tuple(
|
|
178
|
+
keyring for key_id, keyring in self._token_keys.items() if key_id != self._token_key_id
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def seal(self, domain_id: str, token: str, value: JsonValue) -> bytes:
|
|
182
|
+
return self._keyrings[self._key_id].seal(
|
|
183
|
+
_VALUE, jcs_bytes(value), context=framed(domain_id, token)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def open(self, domain_id: str, token: str, key_id: str, sealed: bytes) -> bytes | None:
|
|
187
|
+
"""Canonical JSON of the value; None when the key id is not configured or the
|
|
188
|
+
ciphertext does not open."""
|
|
189
|
+
keyring = self._keyrings.get(key_id)
|
|
190
|
+
if keyring is None:
|
|
191
|
+
return None
|
|
192
|
+
return keyring.open(_VALUE, sealed, context=framed(domain_id, token))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _token(
|
|
196
|
+
keyring: Keyring,
|
|
197
|
+
domain_id: str,
|
|
198
|
+
kind_id: str,
|
|
199
|
+
key: str,
|
|
200
|
+
attribute_id: str,
|
|
201
|
+
value: JsonValue,
|
|
202
|
+
generation: int,
|
|
203
|
+
) -> str:
|
|
204
|
+
message = framed(domain_id, kind_id, key, attribute_id, str(generation), jcs_bytes(value))
|
|
205
|
+
return TOKEN_PREFIX + keyring.mac(_TOKEN, message)[:_TOKEN_BYTES].hex()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _matching(
|
|
209
|
+
keyring: Keyring,
|
|
210
|
+
domain_id: str,
|
|
211
|
+
kind_id: str,
|
|
212
|
+
attribute_id: str,
|
|
213
|
+
value: JsonValue,
|
|
214
|
+
) -> bytes:
|
|
215
|
+
form = jcs_bytes(matching_form(value))
|
|
216
|
+
return keyring.mac(_MATCHING, framed(domain_id, kind_id, attribute_id, form))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Which texts hold a vaulted value, without opening any (extraction evidence, design 9.2).
|
|
2
|
+
|
|
3
|
+
Word sequences of a text are encoded as the Attribute's type canonicalizes values and
|
|
4
|
+
fingerprinted like stored values (`VaultKeys.fingerprints`: the matching form, under every
|
|
5
|
+
configured token key and fingerprint scheme, so case, compatibility characters and white space
|
|
6
|
+
do not matter); a text matches when a live vault row of that Kind and Attribute has one of the
|
|
7
|
+
fingerprints, whichever Record holds it. Nothing is decrypted. A value written differently from
|
|
8
|
+
its canonical form in the text (a date spelled out, a reordered name) is not found.
|
|
9
|
+
|
|
10
|
+
The check fails closed: a text that exceeds its limits counts as a match.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from sqlalchemy import select
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
|
|
23
|
+
from lorebase_core.model import Schema
|
|
24
|
+
from lorebase_core.types import AttributeType, EncodingError, encode_value
|
|
25
|
+
from lorebase_store.vault.keys import VaultKeys
|
|
26
|
+
from lorebase_store.vault.store import VAULT, vault_attributes
|
|
27
|
+
|
|
28
|
+
_UNQUOTED_TYPES = frozenset(
|
|
29
|
+
{AttributeType.JSON, AttributeType.ARRAY, AttributeType.GEO_POINT, AttributeType.BOOLEAN}
|
|
30
|
+
)
|
|
31
|
+
_WORD = re.compile(r"\S+")
|
|
32
|
+
_EDGE = " \t\r\n.,;:!?()[]{}<>\"'`*_|"
|
|
33
|
+
_POSSESSIVE = re.compile(r"['\N{RIGHT SINGLE QUOTATION MARK}]s$", re.IGNORECASE)
|
|
34
|
+
_LOOKUP_BATCH = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class QuoteLimits:
|
|
39
|
+
"""Bounds of one `quoted_values` call; a text that exceeds one is reported as a match."""
|
|
40
|
+
|
|
41
|
+
max_words: int
|
|
42
|
+
"""Longest word sequence of a text compared with the stored values."""
|
|
43
|
+
max_text_words: int
|
|
44
|
+
"""A text with more words is not checked."""
|
|
45
|
+
max_text_chars: int
|
|
46
|
+
"""A text with more characters is not checked."""
|
|
47
|
+
max_candidates: int
|
|
48
|
+
"""Distinct word sequences of all the texts together."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _candidates(text: str, max_words: int) -> set[str]:
|
|
52
|
+
"""Word sequences of `text` (at most `max_words` words, single-spaced), as written, with
|
|
53
|
+
the punctuation at their edges stripped, and without a closing possessive."""
|
|
54
|
+
words = _WORD.findall(text)
|
|
55
|
+
found: set[str] = set()
|
|
56
|
+
for start in range(len(words)):
|
|
57
|
+
for end in range(start + 1, min(len(words), start + max_words) + 1):
|
|
58
|
+
candidate = " ".join(words[start:end])
|
|
59
|
+
found.add(candidate)
|
|
60
|
+
stripped = candidate.strip(_EDGE)
|
|
61
|
+
if stripped:
|
|
62
|
+
found.add(stripped)
|
|
63
|
+
bare = _POSSESSIVE.sub("", stripped)
|
|
64
|
+
if bare:
|
|
65
|
+
found.add(bare)
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def has_vault_values(session: AsyncSession, domain_id: str) -> bool:
|
|
70
|
+
"""Whether the Domain has a live vault row."""
|
|
71
|
+
v = VAULT
|
|
72
|
+
found = await session.scalar(
|
|
73
|
+
select(v.c.id).where(v.c.domain_id == domain_id, v.c.erased_at.is_(None)).limit(1)
|
|
74
|
+
)
|
|
75
|
+
return found is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def quoted_values(
|
|
79
|
+
session: AsyncSession,
|
|
80
|
+
keys: VaultKeys,
|
|
81
|
+
domain_id: str,
|
|
82
|
+
schema: Schema,
|
|
83
|
+
texts: Sequence[str],
|
|
84
|
+
limits: QuoteLimits,
|
|
85
|
+
) -> set[int]:
|
|
86
|
+
"""Indexes of the `texts` to withhold: those that hold a live vault value of any vault
|
|
87
|
+
Attribute of the schema (the whole Domain), and those that could not be checked within
|
|
88
|
+
`limits`."""
|
|
89
|
+
vaulted = vault_attributes(schema)
|
|
90
|
+
hits: set[int] = set()
|
|
91
|
+
owners: dict[str, set[int]] = defaultdict(set)
|
|
92
|
+
for index, text in enumerate(texts):
|
|
93
|
+
if len(text) > limits.max_text_chars or len(text.split()) > limits.max_text_words:
|
|
94
|
+
hits.add(index)
|
|
95
|
+
continue
|
|
96
|
+
found = _candidates(text, limits.max_words)
|
|
97
|
+
if len(owners) + len(found - owners.keys()) > limits.max_candidates:
|
|
98
|
+
hits.update(range(index, len(texts)))
|
|
99
|
+
break
|
|
100
|
+
for candidate in found:
|
|
101
|
+
owners[candidate].add(index)
|
|
102
|
+
if not vaulted or not owners:
|
|
103
|
+
return hits
|
|
104
|
+
v = VAULT
|
|
105
|
+
for kind_id, attribute_ids in vaulted.items():
|
|
106
|
+
for attribute_id in attribute_ids:
|
|
107
|
+
spec = schema.kinds[kind_id].attributes[attribute_id].type
|
|
108
|
+
if spec.name in _UNQUOTED_TYPES:
|
|
109
|
+
continue
|
|
110
|
+
by_fingerprint: dict[bytes, set[int]] = defaultdict(set)
|
|
111
|
+
for candidate, indexes in owners.items():
|
|
112
|
+
try:
|
|
113
|
+
canonical = encode_value(spec, candidate)
|
|
114
|
+
except EncodingError:
|
|
115
|
+
continue
|
|
116
|
+
for fingerprint in keys.fingerprints(domain_id, kind_id, attribute_id, canonical):
|
|
117
|
+
by_fingerprint[fingerprint] |= indexes
|
|
118
|
+
wanted = list(by_fingerprint)
|
|
119
|
+
for offset in range(0, len(wanted), _LOOKUP_BATCH):
|
|
120
|
+
rows = await session.scalars(
|
|
121
|
+
select(v.c.fingerprint)
|
|
122
|
+
.where(
|
|
123
|
+
v.c.domain_id == domain_id,
|
|
124
|
+
v.c.kind_id == kind_id,
|
|
125
|
+
v.c.attribute_id == attribute_id,
|
|
126
|
+
v.c.erased_at.is_(None),
|
|
127
|
+
v.c.fingerprint.in_(wanted[offset : offset + _LOOKUP_BATCH]),
|
|
128
|
+
)
|
|
129
|
+
.distinct()
|
|
130
|
+
)
|
|
131
|
+
for fingerprint in rows:
|
|
132
|
+
hits |= by_fingerprint.get(bytes(fingerprint), set())
|
|
133
|
+
return hits
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Vault key rotation (design 13.5): rows sealed or fingerprinted under earlier keys are made
|
|
2
|
+
again under the current ones.
|
|
3
|
+
|
|
4
|
+
A row is outdated when its value is sealed under another key than `vault.key_id`, or its
|
|
5
|
+
fingerprint was made under another token key or scheme than the current ones
|
|
6
|
+
(`VaultKeys.fingerprint_key`). `rotate` walks the live rows of every Domain in `(domain_id, id)`
|
|
7
|
+
order, `vault.rekey_batch` rows per transaction, opens each outdated one, seals it again under
|
|
8
|
+
the current key with the same token (Record objects do not change) and makes its fingerprint
|
|
9
|
+
again. The position it stopped at is returned, so a job continues where the last one stopped;
|
|
10
|
+
rows locked by a concurrent erasure are skipped and found by the next pass. A row whose key is
|
|
11
|
+
no longer configured cannot be opened: it is counted as `unopenable` and left as it is.
|
|
12
|
+
|
|
13
|
+
`usage` counts live rows by encryption key and fingerprint key and tombstones by fingerprint
|
|
14
|
+
key: an encryption key can be removed from `previous_keys` once no live row uses it, a token key
|
|
15
|
+
from `previous_token_keys` once no live row and no tombstone uses it (tombstones cannot be made
|
|
16
|
+
again: they hold no value; dropping a token key retires the tombstones made under it).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from sqlalchemy import func, or_, select, tuple_, update
|
|
28
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
29
|
+
|
|
30
|
+
from lorebase_store.auth.scope import rls_scope
|
|
31
|
+
from lorebase_store.vault.keys import VaultKeys
|
|
32
|
+
from lorebase_store.vault.store import TOMBSTONES, VAULT
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class RotationReport:
|
|
37
|
+
"""What a rotation pass did; `after` is where the next pass starts (None: done)."""
|
|
38
|
+
|
|
39
|
+
rows: int = 0
|
|
40
|
+
resealed: int = 0
|
|
41
|
+
fingerprinted: int = 0
|
|
42
|
+
unopenable: int = 0
|
|
43
|
+
after: tuple[str, str] | None = None
|
|
44
|
+
|
|
45
|
+
def as_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"rows": self.rows,
|
|
48
|
+
"resealed": self.resealed,
|
|
49
|
+
"fingerprinted": self.fingerprinted,
|
|
50
|
+
"unopenable": self.unopenable,
|
|
51
|
+
"after": None if self.after is None else list(self.after),
|
|
52
|
+
"done": self.after is None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class KeyUsage:
|
|
58
|
+
"""Live rows by encryption key id and by fingerprint key, tombstones by fingerprint key
|
|
59
|
+
(`<token_key_id>/<scheme>`, `legacy` for rows written before rotation)."""
|
|
60
|
+
|
|
61
|
+
encryption_keys: dict[str, int] = field(default_factory=dict)
|
|
62
|
+
fingerprint_keys: dict[str, int] = field(default_factory=dict)
|
|
63
|
+
tombstone_keys: dict[str, int] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def outdated(self, keys: VaultKeys) -> int:
|
|
66
|
+
"""Live rows the rotation still has to make again (upper bound: a row can be both)."""
|
|
67
|
+
sealed = sum(n for k, n in self.encryption_keys.items() if k != keys.key_id)
|
|
68
|
+
fingerprinted = sum(
|
|
69
|
+
n for k, n in self.fingerprint_keys.items() if k != keys.fingerprint_key
|
|
70
|
+
)
|
|
71
|
+
return max(sealed, fingerprinted)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
LEGACY = "legacy"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _outdated(keys: VaultKeys) -> Any:
|
|
78
|
+
v = VAULT
|
|
79
|
+
return or_(
|
|
80
|
+
v.c.key_id != keys.key_id,
|
|
81
|
+
v.c.fingerprint_key.is_(None),
|
|
82
|
+
v.c.fingerprint_key != keys.fingerprint_key,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def refresh_rows(
|
|
87
|
+
session: AsyncSession,
|
|
88
|
+
keys: VaultKeys,
|
|
89
|
+
condition: Sequence[Any],
|
|
90
|
+
*,
|
|
91
|
+
limit: int | None = None,
|
|
92
|
+
after: tuple[str, str] | None = None,
|
|
93
|
+
skip_locked: bool = False,
|
|
94
|
+
) -> RotationReport:
|
|
95
|
+
"""Make the outdated live rows matching `condition` again (module doc), in the caller's
|
|
96
|
+
transaction; at most `limit` rows after `after`."""
|
|
97
|
+
v = VAULT
|
|
98
|
+
query = (
|
|
99
|
+
select(
|
|
100
|
+
v.c.domain_id,
|
|
101
|
+
v.c.id,
|
|
102
|
+
v.c.kind_id,
|
|
103
|
+
v.c.attribute_id,
|
|
104
|
+
v.c.key_id,
|
|
105
|
+
v.c.value_ciphertext,
|
|
106
|
+
v.c.fingerprint_key,
|
|
107
|
+
)
|
|
108
|
+
.where(*condition, v.c.value_ciphertext.is_not(None), _outdated(keys))
|
|
109
|
+
.order_by(v.c.domain_id, v.c.id)
|
|
110
|
+
.with_for_update(skip_locked=skip_locked)
|
|
111
|
+
)
|
|
112
|
+
if after is not None:
|
|
113
|
+
query = query.where(tuple_(v.c.domain_id, v.c.id) > tuple_(*after))
|
|
114
|
+
if limit is not None:
|
|
115
|
+
query = query.limit(limit)
|
|
116
|
+
report = RotationReport()
|
|
117
|
+
for row in (await session.execute(query)).all():
|
|
118
|
+
report.rows += 1
|
|
119
|
+
report.after = (row.domain_id, row.id)
|
|
120
|
+
opened = keys.open(row.domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
|
|
121
|
+
if opened is None:
|
|
122
|
+
report.unopenable += 1
|
|
123
|
+
continue
|
|
124
|
+
value = json.loads(opened)
|
|
125
|
+
changes: dict[str, Any] = {}
|
|
126
|
+
if row.key_id != keys.key_id:
|
|
127
|
+
changes["value_ciphertext"] = keys.seal(row.domain_id, row.id, value)
|
|
128
|
+
changes["key_id"] = keys.key_id
|
|
129
|
+
report.resealed += 1
|
|
130
|
+
if row.fingerprint_key != keys.fingerprint_key:
|
|
131
|
+
changes["fingerprint"] = keys.fingerprint(
|
|
132
|
+
row.domain_id, row.kind_id, row.attribute_id, value
|
|
133
|
+
)
|
|
134
|
+
changes["fingerprint_key"] = keys.fingerprint_key
|
|
135
|
+
report.fingerprinted += 1
|
|
136
|
+
await session.execute(
|
|
137
|
+
update(v).where(v.c.domain_id == row.domain_id, v.c.id == row.id).values(**changes)
|
|
138
|
+
)
|
|
139
|
+
return report
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def rotate(
|
|
143
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
144
|
+
keys: VaultKeys,
|
|
145
|
+
*,
|
|
146
|
+
batch: int,
|
|
147
|
+
max_rows: int,
|
|
148
|
+
after: tuple[str, str] | None = None,
|
|
149
|
+
) -> RotationReport:
|
|
150
|
+
"""Make outdated rows of every Domain again, `batch` per transaction, until `max_rows` rows
|
|
151
|
+
were looked at or none is left (module doc)."""
|
|
152
|
+
total = RotationReport(after=after)
|
|
153
|
+
position = after
|
|
154
|
+
while total.rows < max_rows:
|
|
155
|
+
async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
|
|
156
|
+
report = await refresh_rows(
|
|
157
|
+
session,
|
|
158
|
+
keys,
|
|
159
|
+
[],
|
|
160
|
+
limit=min(batch, max_rows - total.rows),
|
|
161
|
+
after=position,
|
|
162
|
+
skip_locked=True,
|
|
163
|
+
)
|
|
164
|
+
total.rows += report.rows
|
|
165
|
+
total.resealed += report.resealed
|
|
166
|
+
total.fingerprinted += report.fingerprinted
|
|
167
|
+
total.unopenable += report.unopenable
|
|
168
|
+
if report.after is None:
|
|
169
|
+
total.after = None
|
|
170
|
+
return total
|
|
171
|
+
position = total.after = report.after
|
|
172
|
+
return total
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def usage(session: AsyncSession) -> KeyUsage:
|
|
176
|
+
"""Key usage of every Domain (module doc); runs with the row-level security bypass."""
|
|
177
|
+
v, t = VAULT, TOMBSTONES
|
|
178
|
+
async with rls_scope(session, bypass=True):
|
|
179
|
+
sealed = (
|
|
180
|
+
await session.execute(
|
|
181
|
+
select(v.c.key_id, func.count())
|
|
182
|
+
.where(v.c.value_ciphertext.is_not(None))
|
|
183
|
+
.group_by(v.c.key_id)
|
|
184
|
+
)
|
|
185
|
+
).all()
|
|
186
|
+
fingerprinted = (
|
|
187
|
+
await session.execute(
|
|
188
|
+
select(v.c.fingerprint_key, func.count())
|
|
189
|
+
.where(v.c.value_ciphertext.is_not(None))
|
|
190
|
+
.group_by(v.c.fingerprint_key)
|
|
191
|
+
)
|
|
192
|
+
).all()
|
|
193
|
+
tombstones = (
|
|
194
|
+
await session.execute(
|
|
195
|
+
select(t.c.fingerprint_key, func.count()).group_by(t.c.fingerprint_key)
|
|
196
|
+
)
|
|
197
|
+
).all()
|
|
198
|
+
return KeyUsage(
|
|
199
|
+
encryption_keys=_counts(sealed),
|
|
200
|
+
fingerprint_keys=_counts(fingerprinted),
|
|
201
|
+
tombstone_keys=_counts(tombstones),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _counts(rows: Sequence[Any]) -> dict[str, int]:
|
|
206
|
+
counts: Counter[str] = Counter()
|
|
207
|
+
for key, count in rows:
|
|
208
|
+
counts[key or LEGACY] += int(count)
|
|
209
|
+
return dict(sorted(counts.items()))
|