lorebase-keeper 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,882 @@
1
+ """Masking of sensitive values and disclosure of vault values in reads (design 10.2, 13.5).
2
+
3
+ **Clearance.** Values of an Attribute tagged `pii`, `confidential` or `restricted` are shown
4
+ only to callers holding the matching permission (`pii.read`, `confidential.read`,
5
+ `restricted.read`) through a Domain-wide grant or a grant narrowed to the Kind holding the
6
+ value. Grants narrowed to a branch do not count: whoever may create branches could name one to
7
+ match. Kind-narrowed grants name Kinds by api name; they are resolved to Kind ids in the
8
+ schema of the Domain's default branch (the reference schema), so renaming a Kind on another
9
+ branch does not move a clearance. An Attribute with several tags needs every clearance; Link
10
+ Attributes need it on both Kinds of the Relation; a derived value needs each tag cleared also on
11
+ every Kind the tag comes from (`Masking.origins`). Callers without it get
12
+ `keeper.reads.sensitivity.masked_value` (or nothing, with `omit`).
13
+
14
+ **Tags.** An Attribute's tags are those of the schema read plus those the same Attribute (by
15
+ id) has in the reference schema, carried through Derivations (`Masking.over`), so reading an
16
+ older Revision, or a branch forked before a tag was added, never shows less than the default
17
+ branch would. Removing a tag (or a sensitive Attribute, Kind or Relation) on every branch needs
18
+ the clearance for it (`require_lowering`; the commit pipeline and the merge service refuse it
19
+ otherwise).
20
+
21
+ **Two layers.**
22
+
23
+ - `Masking` runs where values are rendered (Records, Links, history, lineage, diffs,
24
+ Conflicts, Operation edits, explain and context packs): it masks by schema, before a body is
25
+ cached. Response caches are keyed by `Clearance.variant`, so callers with different
26
+ clearances never share a body.
27
+ - `Disclosure` runs on the finished body, after the cache: every vault reference
28
+ (`{"$vault": token}`) still in it is replaced by its value when the vault row's Kind and
29
+ Attribute are cleared for the caller, by `erased_value` when it was erased, and by
30
+ `masked_value` otherwise (unknown tokens and keys that are not configured included). A read
31
+ path that forgets to mask still never discloses a vault value, and cached bodies hold
32
+ tokens, not personal data, so an erasure takes effect on the next read. References are
33
+ found in structured data only, never by matching text: a markdown body is cached as an
34
+ envelope (`deferred_markdown`) whose references were swapped for unguessable placeholders
35
+ before rendering, so a stored string that looks like a reference stays a string.
36
+
37
+ Filtering, ordering and grouping on a sensitive Attribute need the clearance (403
38
+ otherwise); on a vault Attribute they are refused (422): its stored values are tokens.
39
+
40
+ **Validation issues.** The code of an issue about a value (`value.pattern`, `value.enum_values`,
41
+ ...) tells what the value looks like. Issues about values of an Attribute the caller may not
42
+ read are reported with the generic `value.invalid` and a message without the rule
43
+ (`generalize_issues`); path, Kind, key and Attribute stay. Where stored values are revalidated
44
+ (schema changes), such issues are collapsed into one per Kind and Attribute with a count and
45
+ no Record key or path (`collapse_issues`), so tightening a rule cannot test values Record by
46
+ Record.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import hashlib
52
+ import json
53
+ import re
54
+ import secrets
55
+ from collections import OrderedDict
56
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
57
+ from dataclasses import dataclass, field, fields, is_dataclass, replace
58
+ from threading import Lock
59
+ from types import MappingProxyType
60
+ from typing import Any
61
+
62
+ from pydantic import BaseModel, JsonValue
63
+ from sqlalchemy.ext.asyncio import AsyncSession
64
+
65
+ from lorebase_core.expr.sensitivity import (
66
+ Origins,
67
+ effective_origins,
68
+ effective_tags,
69
+ lowered_tags,
70
+ schema_tags,
71
+ )
72
+ from lorebase_core.model import (
73
+ VAULT_KEY,
74
+ Attribute,
75
+ Kind,
76
+ Relation,
77
+ Schema,
78
+ Sensitivity,
79
+ vault_token,
80
+ )
81
+ from lorebase_core.query import (
82
+ Comparison,
83
+ Condition,
84
+ Conjunction,
85
+ Disjunction,
86
+ FieldRef,
87
+ FieldSource,
88
+ LinkCount,
89
+ LinkExists,
90
+ Negation,
91
+ QueryPlan,
92
+ )
93
+ from lorebase_core.validate import Issue, IssueCode
94
+ from lorebase_keeper.auth.permissions import Permission
95
+ from lorebase_keeper.auth.principals import Principal
96
+ from lorebase_keeper.auth.rbac import AccessContext, Authorizer
97
+ from lorebase_keeper.auth.read_audit import note_sensitive
98
+ from lorebase_keeper.errors import FORBIDDEN, ProblemError
99
+ from lorebase_keeper.reading.problems import invalid
100
+ from lorebase_keeper.settings import SensitivitySettings
101
+ from lorebase_keeper.tracing.notes import note_field
102
+ from lorebase_store.materialize.rows import PartitionType, parse_partition
103
+ from lorebase_store.materialize.sensitivity import default_branch_schema
104
+ from lorebase_store.runtime import StoreRuntime
105
+ from lorebase_store.vault import VaultKeys, VaultState, VaultValue, open_values
106
+
107
+ CLEARANCES: Mapping[Sensitivity, Permission] = MappingProxyType(
108
+ {
109
+ Sensitivity.PII: Permission.PII_READ,
110
+ Sensitivity.CONFIDENTIAL: Permission.CONFIDENTIAL_READ,
111
+ Sensitivity.RESTRICTED: Permission.RESTRICTED_READ,
112
+ }
113
+ )
114
+ """The permission that clears each sensitivity tag."""
115
+
116
+ _REFERENCE_MARK = json.dumps(VAULT_KEY).encode()
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class Clearance:
121
+ """Sensitivity tags a caller may read: on every Kind (`everywhere`), or on some Kinds by
122
+ id (`kinds`, from grants narrowed to a Kind)."""
123
+
124
+ everywhere: frozenset[Sensitivity] = frozenset()
125
+ kinds: Mapping[Sensitivity, frozenset[str]] = field(default_factory=dict)
126
+
127
+ @classmethod
128
+ def full(cls) -> Clearance:
129
+ return cls(everywhere=frozenset(Sensitivity))
130
+
131
+ def covers(self, tags: Iterable[Sensitivity], kinds: Sequence[str]) -> bool:
132
+ """Whether every tag is cleared on every Kind in `kinds`."""
133
+ for tag in tags:
134
+ if tag in self.everywhere:
135
+ continue
136
+ allowed = self.kinds.get(tag, frozenset())
137
+ if not kinds or any(kind not in allowed for kind in kinds):
138
+ return False
139
+ return True
140
+
141
+ @property
142
+ def variant(self) -> str:
143
+ """Distinguishes cached responses built for callers with different clearances."""
144
+ parts = [f"{tag.value}:*" for tag in sorted(self.everywhere)]
145
+ parts.extend(
146
+ f"{tag.value}:{','.join(sorted(kinds))}"
147
+ for tag, kinds in sorted(self.kinds.items())
148
+ if kinds and tag not in self.everywhere
149
+ )
150
+ return "clear=" + ";".join(parts) if parts else ""
151
+
152
+
153
+ async def clearance_of(
154
+ authorizer: Authorizer, principal: Principal, domain_id: str, reference: Schema | None
155
+ ) -> Clearance:
156
+ """The caller's clearance in a Domain: tags cleared by Domain-wide grants, and by grants
157
+ narrowed to a Kind, resolved to Kind ids in `reference` (the default branch's schema;
158
+ names it does not know clear nothing)."""
159
+ everywhere: set[Sensitivity] = set()
160
+ kinds: dict[Sensitivity, frozenset[str]] = {}
161
+ for tag, permission in CLEARANCES.items():
162
+ if await authorizer.allows(principal, permission, domain_id, AccessContext()):
163
+ everywhere.add(tag)
164
+ continue
165
+ granted = await authorizer.granted_kinds(principal, permission, domain_id)
166
+ ids = frozenset(
167
+ kind.id
168
+ for name in granted
169
+ if reference is not None and (kind := reference.kind_by_api_name(name)) is not None
170
+ )
171
+ if ids:
172
+ kinds[tag] = ids
173
+ return Clearance(frozenset(everywhere), kinds)
174
+
175
+
176
+ async def default_schema(
177
+ session: AsyncSession, domain_id: str, runtime: StoreRuntime
178
+ ) -> Schema | None:
179
+ """The schema at the head of the Domain's default branch (the reference schema), from the
180
+ store's schema cache; None when the Domain or its materialized head is missing."""
181
+ return await default_branch_schema(session, domain_id, runtime=runtime)
182
+
183
+
184
+ def reference_tags(schema: Schema | None) -> dict[str, frozenset[Sensitivity]]:
185
+ """Attribute id -> sensitivity tags, for the Attributes of `schema` that have any."""
186
+ return schema_tags(schema)
187
+
188
+
189
+ async def masking_for(
190
+ authorizer: Authorizer,
191
+ principal: Principal,
192
+ domain_id: str,
193
+ reference: Schema | None,
194
+ settings: SensitivitySettings,
195
+ ) -> Masking:
196
+ """The caller's masking in a Domain whose default branch has the schema `reference`."""
197
+ clearance = await clearance_of(authorizer, principal, domain_id, reference)
198
+ return Masking(clearance, settings, MappingProxyType(reference_tags(reference)))
199
+
200
+
201
+ async def require_lowering(
202
+ authorizer: Authorizer,
203
+ principal: Principal,
204
+ domain_id: str,
205
+ old: Schema,
206
+ new: Schema,
207
+ ) -> None:
208
+ """403 when `new` drops sensitivity tags of `old` the caller is not cleared for
209
+ Domain-wide, on any branch."""
210
+ lowered = lowered_tags(old, new)
211
+ if not lowered:
212
+ return
213
+ clearance = await clearance_of(authorizer, principal, domain_id, None)
214
+ missing = sorted(CLEARANCES[tag].value for tag in lowered - clearance.everywhere)
215
+ if missing:
216
+ raise ProblemError(
217
+ FORBIDDEN,
218
+ "Removing sensitivity tags (or sensitive Attributes) needs "
219
+ f"{' and '.join(missing)} on the Domain.",
220
+ extensions={"code": "sensitivity_lowered", "permission": missing[0]},
221
+ )
222
+
223
+
224
+ @dataclass(frozen=True, slots=True)
225
+ class Masking:
226
+ """Masks values the caller is not cleared for, where they are rendered."""
227
+
228
+ clearance: Clearance
229
+ settings: SensitivitySettings = field(default_factory=SensitivitySettings)
230
+ reference: Mapping[str, frozenset[Sensitivity]] = field(default_factory=dict)
231
+ """Tags of the default branch's Attributes by id (`reference_tags`)."""
232
+ effective: Mapping[str, frozenset[Sensitivity]] = field(default_factory=dict)
233
+ """Tags of the schemas read, with the reference's, carried through Derivations (`over`)."""
234
+ origins: Mapping[str, Origins] = field(default_factory=dict)
235
+ """Per Attribute id, the Kinds each of its tags comes from in the schemas read (`over`)."""
236
+
237
+ def over(self, *schemas: Schema) -> Masking:
238
+ """This masking for reads of `schemas`: a derived Attribute carries the tags of what it
239
+ reads, as the default branch and the schemas read tag them (`effective_tags`)."""
240
+ effective: dict[str, frozenset[Sensitivity]] = {}
241
+ origins: dict[str, Origins] = {}
242
+ for schema in schemas:
243
+ tags_of, origins_of = _effective(schema, self.reference)
244
+ for key, tags in tags_of.items():
245
+ effective[key] = effective.get(key, frozenset()) | tags
246
+ for key, found in origins_of.items():
247
+ united = origins.setdefault(key, {})
248
+ for tag, kinds in found.items():
249
+ united[tag] = united.get(tag, frozenset()) | kinds
250
+ return replace(
251
+ self, effective=MappingProxyType(effective), origins=MappingProxyType(origins)
252
+ )
253
+
254
+ @property
255
+ def variant(self) -> str:
256
+ """What a cached response depends on: the clearance and the default branch's tags
257
+ (a tag added there changes what older Revisions show)."""
258
+ if not self.reference:
259
+ return self.clearance.variant
260
+ digest = hashlib.sha256(
261
+ json.dumps(
262
+ sorted((k, sorted(t.value for t in v)) for k, v in self.reference.items())
263
+ ).encode()
264
+ ).hexdigest()[:16]
265
+ return "|".join(p for p in (self.clearance.variant, f"tags={digest}") if p)
266
+
267
+ def tags(self, attribute: Attribute) -> frozenset[Sensitivity]:
268
+ """The Attribute's tags in the schema read, in the reference schema and through what
269
+ it is derived from."""
270
+ return (
271
+ frozenset(attribute.sensitivity)
272
+ | self.reference.get(attribute.id, frozenset())
273
+ | self.effective.get(attribute.id, frozenset())
274
+ )
275
+
276
+ def sensitive(self, attribute: Attribute) -> bool:
277
+ """Whether the Attribute is sensitive by any of its tags (cleared or not)."""
278
+ return bool(self.tags(attribute))
279
+
280
+ def title(self, schema: Schema, kind_id: str, title: str | None) -> str | None:
281
+ """A stored Record title, or None when the Kind's title Attribute is sensitive: rows
282
+ materialized before a tag was added (on the default branch, after this branch forked)
283
+ may still hold one."""
284
+ kind = schema.kinds.get(kind_id)
285
+ if title is None or kind is None or kind.title_attribute is None:
286
+ return title
287
+ attribute = kind.attributes.get(kind.title_attribute)
288
+ return None if attribute is not None and self.sensitive(attribute) else title
289
+
290
+ def hidden(self, attribute: Attribute, kinds: Sequence[str]) -> bool:
291
+ """Whether values of `attribute` (held by Records or Links of the Kinds `kinds`, by
292
+ id) are masked: each tag needs clearance on `kinds` and on every Kind it comes from
293
+ in the schemas read (`origins`)."""
294
+ return self.hidden_with_origins(attribute, kinds, {})
295
+
296
+ def hidden_with_origins(
297
+ self,
298
+ attribute: Attribute,
299
+ kinds: Sequence[str],
300
+ origins: Mapping[Sensitivity, Iterable[str]],
301
+ ) -> bool:
302
+ """`hidden`, with each tag also needing clearance on the Kinds it comes from: those of
303
+ `origins` (from `lorebase_core.expr.sensitivity.tag_origins`) and of the schemas read.
304
+ A value computed from sensitive Attributes of other Kinds tells about their Records, so
305
+ a clearance narrowed to the Kind holding the value is not enough. Domain-wide
306
+ clearances cover every Kind."""
307
+ tags = self.tags(attribute)
308
+ if not tags:
309
+ return False
310
+ if not self.clearance.covers(tags, kinds):
311
+ return True
312
+ known = self.origins.get(attribute.id, {})
313
+ for tag in tags:
314
+ sources = {*known.get(tag, ()), *origins.get(tag, ())}
315
+ if sources and not self.clearance.covers((tag,), sorted(sources)):
316
+ return True
317
+ return False
318
+
319
+ @property
320
+ def masked(self) -> str:
321
+ return self.settings.masked_value
322
+
323
+ def values(
324
+ self,
325
+ attributes: Mapping[str, Attribute],
326
+ values: Mapping[str, Any],
327
+ kinds: Sequence[str],
328
+ derived: Mapping[str, Any] | None = None,
329
+ *,
330
+ omit: bool | None = None,
331
+ ) -> dict[str, Any]:
332
+ """Values keyed by api name in schema order, masked; ids unknown to `attributes` are
333
+ dropped. `omit` defaults to the setting."""
334
+ leave_out = self.settings.omit if omit is None else omit
335
+ out: dict[str, Any] = {}
336
+ for attribute_id, attribute in attributes.items():
337
+ source = derived if attribute.is_derived and derived is not None else values
338
+ if attribute_id not in source:
339
+ continue
340
+ if self.hidden(attribute, kinds):
341
+ note_sensitive("masked")
342
+ if not leave_out:
343
+ out[attribute.api_name] = self.masked
344
+ continue
345
+ if self.sensitive(attribute):
346
+ note_sensitive("shown")
347
+ out[attribute.api_name] = source[attribute_id]
348
+ return out
349
+
350
+ def value(self, attribute: Attribute | None, kinds: Sequence[str], value: Any) -> Any:
351
+ """One value outside a value map (history, lineage, diffs): masked, never omitted.
352
+ An Attribute the schema no longer knows is masked unless the caller is cleared for
353
+ every tag."""
354
+ if value is None:
355
+ return None
356
+ if attribute is None:
357
+ return value if self.clearance.covers(Sensitivity, kinds) else self.masked
358
+ if self.hidden(attribute, kinds):
359
+ note_sensitive("masked")
360
+ return self.masked
361
+ if self.sensitive(attribute):
362
+ note_sensitive("shown")
363
+ return value
364
+
365
+ def kind_values(self, kind: Kind, values: Mapping[str, Any], **options: Any) -> dict[str, Any]:
366
+ return self.values(kind.attributes, values, (kind.id,), **options)
367
+
368
+ def link_values(
369
+ self, schema: Schema, relation: Relation, values: Mapping[str, Any] | None
370
+ ) -> dict[str, Any]:
371
+ return self.values(relation.attributes, values or {}, relation_kinds(schema, relation))
372
+
373
+ def value_map(
374
+ self, attributes: Mapping[str, Attribute], kinds: Sequence[str], values: Any
375
+ ) -> Any:
376
+ """A value map keyed by Attribute id (or an element body holding `values`), masked;
377
+ ids unknown to `attributes` are masked unless the caller is cleared for everything."""
378
+ if not isinstance(values, dict):
379
+ return values
380
+ if isinstance(values.get("values"), dict):
381
+ return {**values, "values": self.value_map(attributes, kinds, values["values"])}
382
+ return {key: self.value(attributes.get(key), kinds, item) for key, item in values.items()}
383
+
384
+ def require_readable(self, attribute: Attribute, kinds: Sequence[str], pointer: str) -> None:
385
+ """403 when a query filters, orders or groups by a value the caller may not read."""
386
+ if not self.hidden(attribute, kinds):
387
+ return
388
+ tags = sorted(t.value for t in self.tags(attribute))
389
+ missing = [CLEARANCES[Sensitivity(t)].value for t in tags]
390
+ raise ProblemError(
391
+ FORBIDDEN,
392
+ f"{attribute.api_name} is sensitive ({', '.join(tags)}): filtering, ordering or "
393
+ f"grouping by it needs {' and '.join(missing)}.",
394
+ extensions={
395
+ "code": "sensitive_attribute",
396
+ "attribute": attribute.api_name,
397
+ "pointer": pointer,
398
+ "permission": missing[0],
399
+ },
400
+ )
401
+
402
+
403
+ _EffectiveKey = tuple[int, frozenset[tuple[str, frozenset[Sensitivity]]]]
404
+ _EFFECTIVE: OrderedDict[
405
+ _EffectiveKey, tuple[Schema, dict[str, frozenset[Sensitivity]], dict[str, Origins]]
406
+ ] = OrderedDict()
407
+ _EFFECTIVE_ENTRIES = 256
408
+ _EFFECTIVE_LOCK = Lock()
409
+
410
+
411
+ def _effective(
412
+ schema: Schema, reference: Mapping[str, frozenset[Sensitivity]]
413
+ ) -> tuple[dict[str, frozenset[Sensitivity]], dict[str, Origins]]:
414
+ """`effective_tags` and `effective_origins`, remembered per schema object and reference
415
+ tags (compiled schemas are cached, so the same object comes back for the same schema)."""
416
+ key = (id(schema), frozenset(reference.items()))
417
+ with _EFFECTIVE_LOCK:
418
+ found = _EFFECTIVE.get(key)
419
+ if found is not None and found[0] is schema:
420
+ _EFFECTIVE.move_to_end(key)
421
+ return found[1], found[2]
422
+ computed = effective_tags(schema, reference)
423
+ origins = effective_origins(schema, reference)
424
+ with _EFFECTIVE_LOCK:
425
+ _EFFECTIVE[key] = (schema, computed, origins)
426
+ while len(_EFFECTIVE) > _EFFECTIVE_ENTRIES:
427
+ _EFFECTIVE.popitem(last=False)
428
+ return computed, origins
429
+
430
+
431
+ def relation_kinds(_schema: Schema, relation: Relation) -> tuple[str, ...]:
432
+ """Ids of both Kinds of a Relation."""
433
+ return (relation.from_kind, relation.to_kind)
434
+
435
+
436
+ UNCLEARED = Masking(Clearance())
437
+ """Masking for a caller without any clearance."""
438
+
439
+
440
+ class Disclosure:
441
+ """Replaces vault references in a finished response body (see module doc)."""
442
+
443
+ def __init__(
444
+ self,
445
+ session: AsyncSession,
446
+ domain_id: str,
447
+ schemas: Sequence[Schema],
448
+ masking: Masking,
449
+ keys: VaultKeys | None,
450
+ ) -> None:
451
+ self._session = session
452
+ self._domain_id = domain_id
453
+ self._schemas = tuple(schemas)
454
+ self._masking = masking
455
+ self._keys = keys
456
+
457
+ async def send(self, body: bytes, media_type: str) -> bytes:
458
+ """A body about to be sent, with its vault references resolved: JSON documents are
459
+ parsed, markdown is unwrapped from its envelope (`deferred_markdown`) with each
460
+ placeholder replaced; other bodies are sent as they are."""
461
+ kind = media_type.split(";")[0].strip()
462
+ if kind == "text/markdown":
463
+ return await self.markdown(body)
464
+ if _REFERENCE_MARK not in body:
465
+ return body
466
+ if kind == "application/json" or kind.endswith("+json"):
467
+ return await self.body(body)
468
+ if kind in ("application/x-ndjson", "application/jsonl"):
469
+ lines = [await self.body(line) if line else line for line in body.split(b"\n")]
470
+ return b"\n".join(lines)
471
+ return body
472
+
473
+ async def markdown(self, envelope: bytes) -> bytes:
474
+ """The markdown of an envelope made by `deferred_markdown`, each placeholder replaced
475
+ by what the caller may see of its reference."""
476
+ document = json.loads(envelope)
477
+ text: str = document["markdown"]
478
+ slots: dict[str, str] = document["slots"]
479
+ if not slots:
480
+ return text.encode()
481
+ shown = await self._shown(set(slots.values()))
482
+ for placeholder, token in slots.items():
483
+ value = shown[token]
484
+ plain = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
485
+ text = text.replace(placeholder, markdown_text(plain))
486
+ return text.encode()
487
+
488
+ async def body(self, body: bytes) -> bytes:
489
+ """A JSON body with its vault references resolved; unchanged when it holds none."""
490
+ if _REFERENCE_MARK not in body:
491
+ return body
492
+ data = json.loads(body)
493
+ if not await self.resolve(data):
494
+ return body
495
+ return json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
496
+
497
+ async def model(self, model: BaseModel) -> bytes:
498
+ """The JSON body of `model`, with its vault references resolved."""
499
+ return await self.body(model.model_dump_json().encode())
500
+
501
+ async def resolve(self, data: JsonValue) -> bool:
502
+ """Resolve the references inside `data` in place; whether there were any."""
503
+ slots = list(_references(data))
504
+ if not slots:
505
+ return False
506
+ shown = await self._shown({token for _, _, token in slots})
507
+ for container, slot, token in slots:
508
+ container[slot] = shown[token]
509
+ return True
510
+
511
+ async def slots(self, slots: Sequence[tuple[Any, Any, str, str]]) -> None:
512
+ """Resolve, in place, the references at `(container, key, Kind id, Attribute id)`:
513
+ each one is shown only when its vault row belongs to that Kind and Attribute (the
514
+ mask otherwise), so a reference claimed in the wrong place opens nothing."""
515
+ found = [
516
+ (container, slot, token, (kind_id, attribute_id))
517
+ for container, slot, kind_id, attribute_id in slots
518
+ if (token := vault_token(container[slot])) is not None
519
+ ]
520
+ if not found:
521
+ return
522
+ opened = await open_values(
523
+ self._session, self._domain_id, {token for _, _, token, _ in found}, self._keys
524
+ )
525
+ for container, slot, token, where in found:
526
+ value = opened[token]
527
+ if (value.kind_id, value.attribute_id) == where:
528
+ container[slot] = self._show(value)
529
+ else:
530
+ note_sensitive("masked")
531
+ container[slot] = self._masking.settings.masked_value
532
+
533
+ async def _shown(self, tokens: set[str]) -> dict[str, JsonValue]:
534
+ """What the caller sees for each token."""
535
+ opened = await open_values(self._session, self._domain_id, tokens, self._keys)
536
+ return {token: self._show(found) for token, found in opened.items()}
537
+
538
+ def _show(self, found: VaultValue) -> JsonValue:
539
+ """What the caller sees of one opened vault row."""
540
+ settings = self._masking.settings
541
+ if not self._cleared(found.kind_id, found.attribute_id):
542
+ note_sensitive("masked")
543
+ return settings.masked_value
544
+ if found.state is VaultState.VALUE:
545
+ note_sensitive("shown")
546
+ return found.value
547
+ if found.state is VaultState.ERASED:
548
+ return settings.erased_value
549
+ return settings.masked_value
550
+
551
+ def _cleared(self, kind_id: str | None, attribute_id: str | None) -> bool:
552
+ """Whether the caller may read a value of the vault row's Kind and Attribute: `pii`,
553
+ the Attribute's tags in the schemas read and in the reference schema."""
554
+ if kind_id is None or attribute_id is None:
555
+ return False # unknown token: nothing to disclose
556
+ tags: set[Sensitivity] = {Sensitivity.PII}
557
+ tags |= self._masking.reference.get(attribute_id, frozenset())
558
+ for schema in self._schemas:
559
+ kind = schema.kinds.get(kind_id)
560
+ attribute = None if kind is None else kind.attributes.get(attribute_id)
561
+ if attribute is not None:
562
+ tags |= set(attribute.sensitivity)
563
+ return self._masking.clearance.covers(tags, (kind_id,))
564
+
565
+
566
+ def deferred_markdown(data: Any, render: Callable[[Any], str]) -> bytes:
567
+ """The envelope of the markdown `render(data)`, for a cache that must hold references,
568
+ not values: every vault reference inside `data` (dicts, lists, tuples, dataclasses) is
569
+ swapped for a placeholder made with a random nonce before rendering, so no stored text can
570
+ forge one; `Disclosure.markdown` puts what the caller may see in its place when sent."""
571
+ nonce = secrets.token_hex(16)
572
+ slots: dict[str, str] = {}
573
+
574
+ def swap(value: Any) -> Any:
575
+ token = vault_token(value)
576
+ if token is not None:
577
+ placeholder = f"LBVAULT{nonce}N{len(slots):06d}E"
578
+ slots[placeholder] = token
579
+ return placeholder
580
+ if isinstance(value, Mapping):
581
+ return {key: swap(item) for key, item in value.items()}
582
+ if isinstance(value, list | tuple):
583
+ return type(value)(swap(item) for item in value)
584
+ if is_dataclass(value) and not isinstance(value, type):
585
+ changed = {f.name: swap(getattr(value, f.name)) for f in fields(value) if f.init}
586
+ return replace(value, **changed)
587
+ return value
588
+
589
+ text = render(swap(data))
590
+ return json.dumps({"markdown": text, "slots": slots}, ensure_ascii=False).encode()
591
+
592
+
593
+ _MARKDOWN_SPECIAL = re.compile(r"([\\`*_{}\[\]<>()#+!|~])")
594
+
595
+
596
+ def markdown_text(text: str) -> str:
597
+ """`text` safe inside markdown and table cells: one line, markup characters escaped."""
598
+ return _MARKDOWN_SPECIAL.sub(r"\\\1", " ".join(text.split()))
599
+
600
+
601
+ def _references(data: Any) -> Iterator[tuple[Any, Any, str]]:
602
+ """`(container, key or index, token)` of every vault reference inside `data`."""
603
+ stack: list[Any] = [data]
604
+ while stack:
605
+ current = stack.pop()
606
+ items: Iterable[tuple[Any, Any]]
607
+ if isinstance(current, dict):
608
+ items = current.items()
609
+ elif isinstance(current, list):
610
+ items = enumerate(current)
611
+ else:
612
+ continue
613
+ for slot, item in items:
614
+ token = vault_token(item)
615
+ if token is not None:
616
+ yield current, slot, token
617
+ elif isinstance(item, dict | list):
618
+ stack.append(item)
619
+
620
+
621
+ FieldUse = tuple[FieldRef, tuple[Kind, ...]]
622
+ """A field compared, and the Kinds whose Records it is read from."""
623
+
624
+
625
+ def condition_fields(condition: Condition | None, kinds: Sequence[Kind]) -> Iterator[FieldUse]:
626
+ """Fields a condition over Records of `kinds` compares, through Relation predicates too
627
+ (whose fields are read from the far Kinds)."""
628
+ match condition:
629
+ case Comparison(field=found):
630
+ yield found, tuple(kinds)
631
+ case LinkExists(hop=hop, where=where) | LinkCount(hop=hop, where=where):
632
+ yield from condition_fields(where, hop.far.kinds)
633
+ case Conjunction(items=items) | Disjunction(items=items):
634
+ for item in items:
635
+ yield from condition_fields(item, kinds)
636
+ case Negation(item=item):
637
+ yield from condition_fields(item, kinds)
638
+
639
+
640
+ def require_comparable(
641
+ schema: Schema, masking: Masking, fields: Iterable[FieldUse], pointer: str
642
+ ) -> None:
643
+ """Refuse filtering, ordering or grouping by values the caller may not read (403) or that
644
+ are stored in the vault (422: Records hold tokens, which do not compare like values). The
645
+ `title` field is the title Attribute's value: it needs the same clearance. Each field is
646
+ reported to the agent trace with whether it is sensitive (`field_sensitive`)."""
647
+ for ref, kinds in fields:
648
+ note_field(ref.name, field_sensitive(schema, masking, ref, kinds))
649
+ if ref.source is FieldSource.TITLE:
650
+ for kind in kinds:
651
+ title = kind.attributes.get(kind.title_attribute or "")
652
+ if title is not None:
653
+ masking.require_readable(title, (kind.id,), pointer)
654
+ continue
655
+ if ref.source not in (FieldSource.VALUE, FieldSource.DERIVED):
656
+ continue
657
+ for kind_id, attribute_id in sorted(ref.attributes.items()):
658
+ owner = schema.kinds.get(kind_id)
659
+ attribute = None if owner is None else owner.attributes.get(attribute_id)
660
+ if owner is None or attribute is None:
661
+ continue
662
+ if attribute.in_vault:
663
+ raise invalid(
664
+ pointer,
665
+ f"{attribute.api_name} is stored in the vault and cannot be filtered, "
666
+ "ordered or grouped by",
667
+ "vault_attribute",
668
+ )
669
+ masking.require_readable(attribute, (owner.id,), pointer)
670
+
671
+
672
+ def field_sensitive(schema: Schema, masking: Masking, ref: FieldRef, kinds: Sequence[Kind]) -> bool:
673
+ """Whether values of a field read from Records of `kinds` tell about a sensitive Attribute
674
+ under the effective tags (`Masking.sensitive`, cleared or not): one of the Attributes it is
675
+ mapped to (per Kind, through a Trait too; a field mapped to none counts as sensitive), the
676
+ title Attribute of `title`, or a primary key Attribute of `key`."""
677
+ if ref.source is FieldSource.KEY:
678
+ return any(
679
+ attribute is None or masking.sensitive(attribute)
680
+ for kind in kinds
681
+ for attribute in (kind.attributes.get(a) for a in kind.primary_key)
682
+ )
683
+ if ref.source is FieldSource.TITLE:
684
+ return any(
685
+ title is not None and masking.sensitive(title)
686
+ for kind in kinds
687
+ for title in (kind.attributes.get(kind.title_attribute or ""),)
688
+ )
689
+ if not ref.attributes:
690
+ return True
691
+ for kind_id, attribute_id in ref.attributes.items():
692
+ owner = schema.kinds.get(kind_id)
693
+ attribute = None if owner is None else owner.attributes.get(attribute_id)
694
+ if attribute is None or attribute.in_vault or masking.sensitive(attribute):
695
+ return True
696
+ return False
697
+
698
+
699
+ def require_comparable_plan(schema: Schema, masking: Masking, plan: QueryPlan, prefix: str) -> None:
700
+ """`require_comparable` over everything a query plan compares."""
701
+ kinds = plan.target.kinds
702
+ require_comparable(schema, masking, condition_fields(plan.where, kinds), f"{prefix}/where")
703
+ require_comparable(
704
+ schema, masking, ((o.field, kinds) for o in plan.order), f"{prefix}/order_by"
705
+ )
706
+ if plan.aggregate is not None and plan.aggregate.group_by is not None:
707
+ require_comparable(
708
+ schema, masking, ((plan.aggregate.group_by, kinds),), f"{prefix}/aggregate/group_by"
709
+ )
710
+ for include in plan.include:
711
+ require_comparable(
712
+ schema,
713
+ masking,
714
+ condition_fields(include.where, include.hop.far.kinds),
715
+ f"{prefix}/include",
716
+ )
717
+
718
+
719
+ GENERALIZED_CODES: frozenset[IssueCode] = frozenset(
720
+ {
721
+ IssueCode.VALUE_TYPE,
722
+ IssueCode.VALUE_NOT_CANONICAL,
723
+ IssueCode.VALUE_MIN,
724
+ IssueCode.VALUE_MAX,
725
+ IssueCode.VALUE_MIN_LENGTH,
726
+ IssueCode.VALUE_MAX_LENGTH,
727
+ IssueCode.VALUE_PATTERN,
728
+ IssueCode.VALUE_ENUM_VALUES,
729
+ IssueCode.VALUE_UNIQUE,
730
+ IssueCode.VALUE_REFERENCE_MISSING,
731
+ IssueCode.VALUE_VAULT_REFERENCE,
732
+ IssueCode.VALUE_VAULT_UNAVAILABLE,
733
+ IssueCode.VALUE_VAULT_ERASED,
734
+ IssueCode.RECORD_TRAIT_CONFORMANCE,
735
+ }
736
+ )
737
+ """Issue codes that tell something about a value beyond its absence."""
738
+
739
+
740
+ def generalize_issues(
741
+ issues: Iterable[Issue], schemas: Sequence[Schema], masking: Masking
742
+ ) -> list[Issue]:
743
+ """`issues` (with element ids, as the core reports them) where those about a value the
744
+ caller may not read have the code `value.invalid`, a message naming only the Attribute,
745
+ and no details. The Attribute is looked up in `schemas` in turn; one none of them knows is
746
+ treated as sensitive unless the caller is cleared for every tag."""
747
+ out: list[Issue] = []
748
+ for issue in issues:
749
+ if issue.code in GENERALIZED_CODES and issue.attribute is not None:
750
+ name = _issue_hidden(issue, schemas, masking)
751
+ if name is not None:
752
+ issue = issue.model_copy(
753
+ update={
754
+ "code": IssueCode.VALUE_INVALID,
755
+ "message": f"{name!r} is not valid",
756
+ "details": {},
757
+ }
758
+ )
759
+ out.append(issue)
760
+ return out
761
+
762
+
763
+ def collapse_issues(
764
+ issues: Iterable[Issue], schemas: Sequence[Schema], masking: Masking
765
+ ) -> list[Issue]:
766
+ """`issues` from revalidating stored values, where every issue about an Attribute the
767
+ caller may not read (whatever its code) is folded into one `value.invalid` issue per Kind
768
+ or Relation and Attribute, with the number of issues in `details.count` and no Record key
769
+ or path: which Records fail a new rule would test their values one by one. The other
770
+ issues are left as they are."""
771
+ out: list[Issue] = []
772
+ folded: dict[tuple[str | None, str | None, str], int] = {}
773
+ counts: dict[int, int] = {}
774
+ for issue in issues:
775
+ name = None if issue.attribute is None else _issue_hidden(issue, schemas, masking)
776
+ if name is None or issue.attribute is None:
777
+ out.append(issue)
778
+ continue
779
+ group = (issue.kind, issue.relation, issue.attribute)
780
+ if group not in folded:
781
+ folded[group] = len(out)
782
+ out.append(
783
+ Issue(
784
+ code=IssueCode.VALUE_INVALID,
785
+ message=f"stored values of {name!r} are not valid",
786
+ kind=issue.kind,
787
+ relation=issue.relation,
788
+ attribute=issue.attribute,
789
+ )
790
+ )
791
+ position = folded[group]
792
+ counts[position] = counts.get(position, 0) + 1
793
+ for position, count in counts.items():
794
+ out[position] = out[position].model_copy(update={"details": {"count": count}})
795
+ return out
796
+
797
+
798
+ def _issue_hidden(issue: Issue, schemas: Sequence[Schema], masking: Masking) -> str | None:
799
+ """The api name of the issue's Attribute when the caller may not read its values."""
800
+ attribute_id = issue.attribute or ""
801
+ for schema in schemas:
802
+ attributes: Mapping[str, Attribute]
803
+ if issue.relation is not None:
804
+ relation = schema.relations.get(issue.relation)
805
+ if relation is None:
806
+ continue
807
+ attributes, kinds = relation.attributes, relation_kinds(schema, relation)
808
+ else:
809
+ kind = schema.kinds.get(issue.kind or "")
810
+ if kind is None:
811
+ continue
812
+ attributes, kinds = kind.attributes, (kind.id,)
813
+ attribute = attributes.get(attribute_id)
814
+ if attribute is None:
815
+ continue
816
+ vault = attribute.in_vault and not masking.clearance.covers((Sensitivity.PII,), kinds)
817
+ hidden = vault or masking.hidden(attribute, kinds)
818
+ return attribute.api_name if hidden else None
819
+ kinds = tuple(k for k in (issue.kind,) if k is not None)
820
+ return None if masking.clearance.covers(Sensitivity, kinds) else attribute_id
821
+
822
+
823
+ _CONFLICT_VALUES = ("base", "ours", "theirs")
824
+
825
+
826
+ def _element_attributes(
827
+ schema: Schema | None, partition: str
828
+ ) -> tuple[Mapping[str, Attribute], tuple[str, ...]] | None:
829
+ """Attributes and holder Kinds of a data partition; None for the schema partition."""
830
+ try:
831
+ parsed = parse_partition(partition)
832
+ except ValueError:
833
+ return {}, () # unknown: masked unless the caller is cleared for everything
834
+ if parsed.type is PartitionType.SCHEMA:
835
+ return None
836
+ if schema is None:
837
+ return {}, (parsed.element_id,) if parsed.type is PartitionType.KIND else ()
838
+ if parsed.type is PartitionType.KIND:
839
+ kind = schema.kinds.get(parsed.element_id)
840
+ return ({}, (parsed.element_id,)) if kind is None else (kind.attributes, (kind.id,))
841
+ relation = schema.relations.get(parsed.element_id)
842
+ if relation is None:
843
+ return {}, ()
844
+ return relation.attributes, relation_kinds(schema, relation)
845
+
846
+
847
+ def conflict_hidden(payload: Mapping[str, Any], schema: Schema | None, masking: Masking) -> bool:
848
+ """Whether a Conflict is about one Attribute whose values the caller may not read (an
849
+ Attribute the schema does not know counts as sensitive)."""
850
+ attribute_id = payload.get("attribute")
851
+ located = _element_attributes(schema, str(payload.get("partition", "")))
852
+ if located is None or not isinstance(attribute_id, str):
853
+ return False
854
+ attributes, kinds = located
855
+ attribute = attributes.get(attribute_id)
856
+ if attribute is None:
857
+ return not masking.clearance.covers(Sensitivity, kinds)
858
+ return masking.hidden(attribute, kinds)
859
+
860
+
861
+ def mask_conflict(
862
+ payload: Mapping[str, Any], schema: Schema | None, masking: Masking
863
+ ) -> dict[str, Any]:
864
+ """A stored Conflict with the values the caller may not read masked: the three versions
865
+ of a sensitive Attribute (with the message and details, which may quote them), or the
866
+ sensitive entries of whole value maps."""
867
+ out = dict(payload)
868
+ located = _element_attributes(schema, str(payload.get("partition", "")))
869
+ if located is None:
870
+ return out
871
+ if conflict_hidden(payload, schema, masking):
872
+ for name in _CONFLICT_VALUES:
873
+ if out.get(name) is not None:
874
+ out[name] = masking.masked
875
+ out["message"] = "The versions of a sensitive Attribute differ."
876
+ out["details"] = {}
877
+ return out
878
+ if payload.get("attribute") is None:
879
+ attributes, kinds = located
880
+ for name in _CONFLICT_VALUES:
881
+ out[name] = masking.value_map(attributes, kinds, out.get(name))
882
+ return out