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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- 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
|