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,222 @@
|
|
|
1
|
+
"""Who may feed values of vault Attributes through bulk paths (design 13.5).
|
|
2
|
+
|
|
3
|
+
Commits of the origins of `vault.tombstone_origins` (imports, Source syncs, extraction) check
|
|
4
|
+
values of vault Attributes against the tombstones of erased values: an erased value is stored
|
|
5
|
+
erased or refused. Whoever can choose such a value can therefore learn whether it was erased.
|
|
6
|
+
Feeding values of vault Attributes through these paths needs `pii.import` on the whole Domain
|
|
7
|
+
(only `admin` holds it among the built-in roles), checked before anything is committed:
|
|
8
|
+
|
|
9
|
+
- the built-in imports (`import_records`, `import_batch`) when a row holds a value of a vault
|
|
10
|
+
Attribute (`import_rows_vault_attributes`);
|
|
11
|
+
- schema changes and merges that add a Source mapping columns to vault Attributes, change such
|
|
12
|
+
a Source, or make an Attribute a Source maps a vault Attribute (`fed_vault_sources`);
|
|
13
|
+
- triggering a synchronization of a Source that maps vault Attributes (`source_vault_targets`);
|
|
14
|
+
- reviewer edits of extraction items that set a value of a vault Attribute
|
|
15
|
+
(`item_edits_vault_attributes`). Scribe never accepts such items at once: a reviewer does.
|
|
16
|
+
|
|
17
|
+
Scheduled synchronizations run as configured: the Source was vetted when it was defined.
|
|
18
|
+
Sources defined before `pii.import` existed were not: `python -m lorebase_keeper sources vault`
|
|
19
|
+
lists the Sources that map vault Attributes (`installation_vault_sources`), for an
|
|
20
|
+
administrator to confirm or disable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
from pydantic import JsonValue
|
|
29
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
30
|
+
|
|
31
|
+
from lorebase_core.model import Schema, Source
|
|
32
|
+
from lorebase_keeper.auth.deps import missing_permission
|
|
33
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
34
|
+
from lorebase_keeper.auth.principals import Principal
|
|
35
|
+
from lorebase_keeper.auth.rbac import NO_CONTEXT, Authorizer
|
|
36
|
+
from lorebase_keeper.reading.sensitivity import require_lowering
|
|
37
|
+
from lorebase_keeper.services.operation_catalog import (
|
|
38
|
+
KIND,
|
|
39
|
+
Action,
|
|
40
|
+
BuiltinOperation,
|
|
41
|
+
)
|
|
42
|
+
from lorebase_store.commit.schema import load_compiled
|
|
43
|
+
from lorebase_store.extraction import ItemEditError, normalize_edits
|
|
44
|
+
from lorebase_store.repositories import DomainRepository, MaterializedRepository, scope_to_domain
|
|
45
|
+
from lorebase_store.repositories.proposal_items import LINK_ITEM, ItemRow
|
|
46
|
+
from lorebase_store.settings import WriteSettings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def source_vault_targets(schema: Schema, source: Source) -> frozenset[str]:
|
|
50
|
+
"""Ids of the vault Attributes of the Source's target Kind that the Source maps."""
|
|
51
|
+
kind = schema.kinds.get(source.target_kind)
|
|
52
|
+
if kind is None:
|
|
53
|
+
return frozenset()
|
|
54
|
+
return frozenset(
|
|
55
|
+
attribute_id
|
|
56
|
+
for attribute_id in source.attribute_mapping
|
|
57
|
+
if (attribute := kind.attributes.get(attribute_id)) is not None and attribute.in_vault
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def vault_sources(schema: Schema) -> list[tuple[Source, list[str]]]:
|
|
62
|
+
"""The Sources of `schema` that map vault Attributes, with those Attributes' api names,
|
|
63
|
+
by Source api name."""
|
|
64
|
+
found: list[tuple[Source, list[str]]] = []
|
|
65
|
+
for source in sorted(schema.sources.values(), key=lambda s: s.api_name):
|
|
66
|
+
targets = source_vault_targets(schema, source)
|
|
67
|
+
if targets:
|
|
68
|
+
kind = schema.kinds[source.target_kind]
|
|
69
|
+
found.append((source, sorted(kind.attributes[a].api_name for a in targets)))
|
|
70
|
+
return found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def item_edits_vault_attributes(
|
|
74
|
+
schema: Schema, item: ItemRow, edits: Mapping[str, object]
|
|
75
|
+
) -> list[str]:
|
|
76
|
+
"""Api names of the vault Attributes whose value reviewer `edits` of an extraction item
|
|
77
|
+
set. Invalid edits are left to the review, which refuses them."""
|
|
78
|
+
if item.item_type == LINK_ITEM:
|
|
79
|
+
return [] # Link Attributes are never in the vault
|
|
80
|
+
kind = schema.kinds.get(str(item.subject.get("kind")))
|
|
81
|
+
if kind is None:
|
|
82
|
+
return []
|
|
83
|
+
try:
|
|
84
|
+
normalized = normalize_edits(item, edits, schema)
|
|
85
|
+
except ItemEditError:
|
|
86
|
+
return []
|
|
87
|
+
values = normalized.get("values") or {}
|
|
88
|
+
set_ids = {attribute_id for attribute_id, value in values.items() if value is not None}
|
|
89
|
+
if normalized.get("value") is not None:
|
|
90
|
+
set_ids.add(str(item.subject.get("attribute")))
|
|
91
|
+
return sorted(
|
|
92
|
+
attribute.api_name
|
|
93
|
+
for attribute_id in set_ids
|
|
94
|
+
if (attribute := kind.attributes.get(attribute_id)) is not None and attribute.in_vault
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fed_vault_sources(old: Schema, new: Schema) -> list[str]:
|
|
99
|
+
"""Ids of the Sources of `new` that map vault Attributes and are new, changed, or map
|
|
100
|
+
vault Attributes `old` did not."""
|
|
101
|
+
fed: list[str] = []
|
|
102
|
+
for source_id, source in new.sources.items():
|
|
103
|
+
targets = source_vault_targets(new, source)
|
|
104
|
+
if not targets:
|
|
105
|
+
continue
|
|
106
|
+
before = old.sources.get(source_id)
|
|
107
|
+
if before is None or before != source or not targets <= source_vault_targets(old, before):
|
|
108
|
+
fed.append(source_id)
|
|
109
|
+
return sorted(fed)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def import_rows_vault_attributes(
|
|
113
|
+
schema: Schema, definition: BuiltinOperation, parameters: Mapping[str, JsonValue]
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
"""Api names of the vault Attributes some row of a built-in import holds a value of.
|
|
116
|
+
Malformed rows are skipped: binding refuses them later."""
|
|
117
|
+
rows: list[tuple[object, object]] = []
|
|
118
|
+
if definition.action is Action.IMPORT_RECORDS:
|
|
119
|
+
found = parameters.get("rows")
|
|
120
|
+
if isinstance(found, list):
|
|
121
|
+
rows = [(parameters.get(KIND), row) for row in found]
|
|
122
|
+
elif definition.action is Action.IMPORT_BATCH:
|
|
123
|
+
found = parameters.get("records")
|
|
124
|
+
if isinstance(found, list):
|
|
125
|
+
rows = [(row.get(KIND) if isinstance(row, dict) else None, row) for row in found]
|
|
126
|
+
names: set[str] = set()
|
|
127
|
+
for kind_name, row in rows:
|
|
128
|
+
if not isinstance(row, dict) or not isinstance(kind_name, str):
|
|
129
|
+
continue
|
|
130
|
+
kind = schema.kind_by_api_name(kind_name)
|
|
131
|
+
if kind is None:
|
|
132
|
+
continue
|
|
133
|
+
names.update(
|
|
134
|
+
a.api_name
|
|
135
|
+
for a in kind.attributes.values()
|
|
136
|
+
if a.in_vault and row.get(a.api_name) is not None
|
|
137
|
+
)
|
|
138
|
+
return sorted(names)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def require_pii_import(authorizer: Authorizer, principal: Principal, domain_id: str) -> None:
|
|
142
|
+
"""403 naming `pii.import` unless the caller holds it through a Domain-wide grant."""
|
|
143
|
+
if not await authorizer.allows(principal, Permission.PII_IMPORT, domain_id, NO_CONTEXT):
|
|
144
|
+
raise missing_permission(Permission.PII_IMPORT, domain_id)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def require_source_feeding(
|
|
148
|
+
authorizer: Authorizer, principal: Principal, domain_id: str, old: Schema, new: Schema
|
|
149
|
+
) -> None:
|
|
150
|
+
"""403 naming `pii.import` when `new` adds or changes a Source feeding vault Attributes
|
|
151
|
+
and the caller does not hold it Domain-wide."""
|
|
152
|
+
if fed_vault_sources(old, new):
|
|
153
|
+
await require_pii_import(authorizer, principal, domain_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def require_schema_change(
|
|
157
|
+
authorizer: Authorizer, principal: Principal, domain_id: str, old: Schema, new: Schema
|
|
158
|
+
) -> None:
|
|
159
|
+
"""The checks of a caller's schema change or merge from `old` to `new`: removed
|
|
160
|
+
sensitivity tags need their clearance (`reading.sensitivity.require_lowering`), Sources
|
|
161
|
+
feeding vault Attributes need `pii.import`."""
|
|
162
|
+
await require_lowering(authorizer, principal, domain_id, old, new)
|
|
163
|
+
await require_source_feeding(authorizer, principal, domain_id, old, new)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class VaultSource:
|
|
168
|
+
"""A Source of a Domain's default branch that maps vault Attributes."""
|
|
169
|
+
|
|
170
|
+
domain: str
|
|
171
|
+
source: str
|
|
172
|
+
api_name: str
|
|
173
|
+
kind: str
|
|
174
|
+
attributes: tuple[str, ...]
|
|
175
|
+
schedule: str | None
|
|
176
|
+
status: str
|
|
177
|
+
|
|
178
|
+
def document(self) -> dict[str, object]:
|
|
179
|
+
return {
|
|
180
|
+
"domain": self.domain,
|
|
181
|
+
"source": self.source,
|
|
182
|
+
"api_name": self.api_name,
|
|
183
|
+
"kind": self.kind,
|
|
184
|
+
"attributes": list(self.attributes),
|
|
185
|
+
"schedule": self.schedule,
|
|
186
|
+
"status": self.status,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def installation_vault_sources(
|
|
191
|
+
sessions: async_sessionmaker[AsyncSession], writes: WriteSettings, *, batch: int = 100
|
|
192
|
+
) -> list[VaultSource]:
|
|
193
|
+
"""The Sources mapping vault Attributes in the default branch of every Domain: Sources
|
|
194
|
+
defined before `pii.import` existed were not vetted by a holder of it."""
|
|
195
|
+
found: list[VaultSource] = []
|
|
196
|
+
after: str | None = None
|
|
197
|
+
while True:
|
|
198
|
+
async with sessions() as session, session.begin():
|
|
199
|
+
domains = await DomainRepository(session).list(limit=batch, after=after)
|
|
200
|
+
for domain in domains:
|
|
201
|
+
await scope_to_domain(session, domain.id)
|
|
202
|
+
compiled = await MaterializedRepository(
|
|
203
|
+
session, domain.id, domain.default_branch, writes=writes
|
|
204
|
+
).schema()
|
|
205
|
+
if compiled is None:
|
|
206
|
+
continue
|
|
207
|
+
schema = load_compiled(compiled.compiled)
|
|
208
|
+
found.extend(
|
|
209
|
+
VaultSource(
|
|
210
|
+
domain=domain.id,
|
|
211
|
+
source=source.id,
|
|
212
|
+
api_name=source.api_name,
|
|
213
|
+
kind=schema.kinds[source.target_kind].api_name,
|
|
214
|
+
attributes=tuple(names),
|
|
215
|
+
schedule=source.schedule,
|
|
216
|
+
status=source.status.value,
|
|
217
|
+
)
|
|
218
|
+
for source, names in vault_sources(schema)
|
|
219
|
+
)
|
|
220
|
+
if len(domains) < batch:
|
|
221
|
+
return found
|
|
222
|
+
after = domains[-1].id
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Authorization of Proposal reads against both of their branches (design 10.2, 10.3).
|
|
2
|
+
|
|
3
|
+
A Proposal and its Conflicts carry content of its source and target branches (Conflicts hold
|
|
4
|
+
the base, ours and theirs values), so reading them follows the read rules of
|
|
5
|
+
`services.reads` on both refs:
|
|
6
|
+
|
|
7
|
+
- `data.read` on the source and on the target branch shows every data Conflict;
|
|
8
|
+
- otherwise grants narrowed to Kinds (which cover every branch and Revision) show the Conflicts
|
|
9
|
+
of those Kinds only: Records of those Kinds, and Links of Relations between two of them;
|
|
10
|
+
- schema Conflicts need `schema.read` on both branches, through grants not narrowed to a Kind;
|
|
11
|
+
- callers with neither get 403, also for unknown Proposals when their grants are narrowed.
|
|
12
|
+
|
|
13
|
+
Listings keep the Proposals the caller may read in this sense. Writes follow the same scope:
|
|
14
|
+
creating a Proposal needs read access to its target branch, and resolving a Conflict (or
|
|
15
|
+
receiving the attempt of a merge) is limited to the Conflicts the caller may read.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
|
|
23
|
+
from lorebase_core.model import Schema
|
|
24
|
+
from lorebase_core.tree import PARTITION_SCHEMA, kind_partition, relation_partition
|
|
25
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
26
|
+
from lorebase_keeper.errors import FORBIDDEN, NOT_FOUND, ProblemError
|
|
27
|
+
from lorebase_keeper.reading.visibility import projection
|
|
28
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
29
|
+
from lorebase_store.repositories import (
|
|
30
|
+
EVERY_PARTITION,
|
|
31
|
+
ConflictRow,
|
|
32
|
+
PartitionFilter,
|
|
33
|
+
ProposalRow,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ConflictScope:
|
|
39
|
+
"""The Conflicts of a Proposal a caller may read: those of every Kind (`kinds` is None)
|
|
40
|
+
or of the Kinds named (api names), and schema Conflicts when `schema` is true."""
|
|
41
|
+
|
|
42
|
+
kinds: frozenset[str] | None = None
|
|
43
|
+
schema: bool = True
|
|
44
|
+
invisible: bool = False
|
|
45
|
+
"""The other Kinds are invisible to the caller (design 13.3): a Conflict outside the scope
|
|
46
|
+
answers like an unknown one."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def everything(self) -> bool:
|
|
50
|
+
return self.kinds is None and self.schema
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
FULL_SCOPE = ConflictScope()
|
|
54
|
+
NO_SCOPE = ConflictScope(kinds=frozenset(), schema=False)
|
|
55
|
+
"""Shows no Conflict."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _both(guard: ReadGuard, proposal: ProposalRow) -> bool:
|
|
59
|
+
return await guard.may(proposal.source_ref) and await guard.may(proposal.target_ref)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def conflict_scope(data: ReadGuard, proposal: ProposalRow) -> ConflictScope:
|
|
63
|
+
"""What `data` (a `data.read` guard) may read of `proposal`; 403 when nothing."""
|
|
64
|
+
schema = replace(data, permissions=(Permission.SCHEMA_READ,), kind=None)
|
|
65
|
+
schema_readable = await _both(schema, proposal)
|
|
66
|
+
if await _both(data, proposal):
|
|
67
|
+
return ConflictScope(schema=schema_readable)
|
|
68
|
+
kinds = await data.granted_kinds()
|
|
69
|
+
if not kinds:
|
|
70
|
+
raise data.denied()
|
|
71
|
+
return ConflictScope(
|
|
72
|
+
kinds=kinds, schema=schema_readable, invisible=data.authorizer.invisible_kinds
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def reads_both(data: ReadGuard, proposal: ProposalRow) -> bool:
|
|
77
|
+
"""Whether `data` reads both branches of `proposal` (not only some Kinds)."""
|
|
78
|
+
return await _both(data, proposal)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def visible_scope(data: ReadGuard, proposal: ProposalRow) -> ConflictScope:
|
|
82
|
+
"""`conflict_scope`, or `NO_SCOPE` instead of 403: for responses of writes whose
|
|
83
|
+
authorization does not depend on reading the Proposal."""
|
|
84
|
+
try:
|
|
85
|
+
return await conflict_scope(data, proposal)
|
|
86
|
+
except ProblemError as error:
|
|
87
|
+
if error.status_code != 403:
|
|
88
|
+
raise
|
|
89
|
+
return NO_SCOPE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def readable_proposals(data: ReadGuard, rows: Sequence[ProposalRow]) -> list[ProposalRow]:
|
|
93
|
+
"""The Proposals of `rows` the caller of `data` may read (see module doc)."""
|
|
94
|
+
if await data.wide() or await data.granted_kinds():
|
|
95
|
+
return list(rows)
|
|
96
|
+
return [row for row in rows if await _both(data, row)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def require_target_read(data: ReadGuard, target_ref: str) -> None:
|
|
100
|
+
"""403 unless the caller of `data` may read the branch `target_ref`: `data.read` on it,
|
|
101
|
+
or grants narrowed to Kinds (which cover every branch)."""
|
|
102
|
+
if await data.may(target_ref) or await data.granted_kinds():
|
|
103
|
+
return
|
|
104
|
+
raise data.denied()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def require_visible(
|
|
108
|
+
partitions: PartitionFilter, conflict: ConflictRow, attempt_id: str, *, invisible: bool = False
|
|
109
|
+
) -> None:
|
|
110
|
+
"""403 for a Conflict stored in a partition outside `partitions`; with `invisible`, the
|
|
111
|
+
404 of a Conflict that is not part of the attempt."""
|
|
112
|
+
if not partitions.allows(conflict.partition, conflict.attribute_id):
|
|
113
|
+
if invisible:
|
|
114
|
+
raise unknown_conflict(conflict.fingerprint, attempt_id)
|
|
115
|
+
raise ProblemError(
|
|
116
|
+
FORBIDDEN,
|
|
117
|
+
f"Conflict {conflict.fingerprint} is outside what the caller may read.",
|
|
118
|
+
extensions={"conflict": conflict.fingerprint},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def visible_partitions(scope: ConflictScope, schemas: Iterable[Schema]) -> PartitionFilter:
|
|
123
|
+
"""The partitions holding Conflicts `scope` shows, given the schemas of the attempt's
|
|
124
|
+
Revisions (Kind and Relation ids are stable across them)."""
|
|
125
|
+
if scope.everything:
|
|
126
|
+
return EVERY_PARTITION
|
|
127
|
+
if scope.kinds is None:
|
|
128
|
+
return PartitionFilter(exclude=frozenset({PARTITION_SCHEMA}))
|
|
129
|
+
seen = list(schemas)
|
|
130
|
+
hidden: frozenset[str] = frozenset()
|
|
131
|
+
if scope.invisible:
|
|
132
|
+
projections = [projection(schema, scope.kinds) for schema in seen]
|
|
133
|
+
seen = [p.schema for p in projections]
|
|
134
|
+
# Conflicts about Attributes left out of a visible Kind (a reference to an invisible
|
|
135
|
+
# Kind) are left out too: their values are keys of invisible Records.
|
|
136
|
+
hidden = frozenset().union(*(p.hidden for p in projections))
|
|
137
|
+
kind_ids = {
|
|
138
|
+
kind.id for schema in seen for kind in schema.kinds.values() if kind.api_name in scope.kinds
|
|
139
|
+
}
|
|
140
|
+
partitions = {kind_partition(kind_id) for kind_id in kind_ids}
|
|
141
|
+
partitions |= {
|
|
142
|
+
relation_partition(relation.id)
|
|
143
|
+
for schema in seen
|
|
144
|
+
for relation in schema.relations.values()
|
|
145
|
+
if relation.from_kind in kind_ids and relation.to_kind in kind_ids
|
|
146
|
+
}
|
|
147
|
+
if scope.schema:
|
|
148
|
+
partitions.add(PARTITION_SCHEMA)
|
|
149
|
+
return PartitionFilter(include=frozenset(partitions), exclude_attributes=hidden)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def unknown_conflict(fingerprint: str, attempt_id: str) -> ProblemError:
|
|
153
|
+
"""404 for a Conflict that is not part of the current merge attempt."""
|
|
154
|
+
return ProblemError(
|
|
155
|
+
NOT_FOUND,
|
|
156
|
+
f"Conflict {fingerprint} is not part of the current merge attempt.",
|
|
157
|
+
extensions={"conflict": fingerprint, "attempt": attempt_id},
|
|
158
|
+
)
|