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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. 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()))