lorebase-store 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The `schema` partition: schema elements keyed by id, the Domain element keyed by Domain id.
|
|
2
|
+
|
|
3
|
+
The materialized head keeps the compiled schema (`Schema.content()`) next to the schema
|
|
4
|
+
partition hash, so commits read the schema with one row.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from lorebase_core.canonical import ObjectType
|
|
13
|
+
from lorebase_core.model import (
|
|
14
|
+
Derivation,
|
|
15
|
+
Domain,
|
|
16
|
+
Kind,
|
|
17
|
+
Operation,
|
|
18
|
+
Relation,
|
|
19
|
+
Schema,
|
|
20
|
+
Source,
|
|
21
|
+
Trait,
|
|
22
|
+
)
|
|
23
|
+
from lorebase_store.commit.changes import SchemaChange, SchemaDelete, SchemaElementModel
|
|
24
|
+
from lorebase_store.commit.errors import SchemaChangeError
|
|
25
|
+
|
|
26
|
+
ELEMENT_MODELS: Mapping[ObjectType, type[SchemaElementModel]] = {
|
|
27
|
+
ObjectType.SCHEMA_DOMAIN: Domain,
|
|
28
|
+
ObjectType.SCHEMA_KIND: Kind,
|
|
29
|
+
ObjectType.SCHEMA_RELATION: Relation,
|
|
30
|
+
ObjectType.SCHEMA_TRAIT: Trait,
|
|
31
|
+
ObjectType.SCHEMA_OPERATION: Operation,
|
|
32
|
+
ObjectType.SCHEMA_DERIVATION: Derivation,
|
|
33
|
+
ObjectType.SCHEMA_SOURCE: Source,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_COLLECTIONS: Mapping[ObjectType, str] = {
|
|
37
|
+
ObjectType.SCHEMA_KIND: "kinds",
|
|
38
|
+
ObjectType.SCHEMA_RELATION: "relations",
|
|
39
|
+
ObjectType.SCHEMA_TRAIT: "traits",
|
|
40
|
+
ObjectType.SCHEMA_OPERATION: "operations",
|
|
41
|
+
ObjectType.SCHEMA_DERIVATION: "derivations",
|
|
42
|
+
ObjectType.SCHEMA_SOURCE: "sources",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def object_type_of(element: SchemaElementModel) -> ObjectType:
|
|
47
|
+
return ObjectType(element.object_type)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def element_key(element: SchemaElementModel) -> str:
|
|
51
|
+
"""Key of an element in the `schema` partition."""
|
|
52
|
+
return element.id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def schema_elements(schema: Schema) -> dict[str, SchemaElementModel]:
|
|
56
|
+
"""Every element of a Schema by partition key, the Domain included."""
|
|
57
|
+
elements: dict[str, SchemaElementModel] = {schema.domain.id: schema.domain}
|
|
58
|
+
for element in schema.elements():
|
|
59
|
+
elements[element.id] = element # type: ignore[assignment]
|
|
60
|
+
return elements
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def schema_from_elements(elements: Iterable[SchemaElementModel]) -> Schema:
|
|
64
|
+
"""Assemble a Schema; exactly one Domain element is required."""
|
|
65
|
+
domain: Domain | None = None
|
|
66
|
+
collections: dict[str, dict[str, Any]] = {name: {} for name in _COLLECTIONS.values()}
|
|
67
|
+
for element in elements:
|
|
68
|
+
if isinstance(element, Domain):
|
|
69
|
+
if domain is not None:
|
|
70
|
+
raise SchemaChangeError("a schema holds exactly one Domain element")
|
|
71
|
+
domain = element
|
|
72
|
+
continue
|
|
73
|
+
collections[_COLLECTIONS[object_type_of(element)]][element.id] = element
|
|
74
|
+
if domain is None:
|
|
75
|
+
raise SchemaChangeError("a schema needs its Domain element")
|
|
76
|
+
return Schema(domain=domain, **collections)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def element_from_body(object_type: ObjectType, body: Mapping[str, Any]) -> SchemaElementModel:
|
|
80
|
+
"""Parse a stored schema object."""
|
|
81
|
+
return ELEMENT_MODELS[object_type].model_validate(body)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def compile_schema(schema: Schema) -> dict[str, Any]:
|
|
85
|
+
"""Document stored in `mat_schema.compiled`."""
|
|
86
|
+
return schema.content()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_compiled(compiled: Mapping[str, Any]) -> Schema:
|
|
90
|
+
"""Inverse of `compile_schema`."""
|
|
91
|
+
return Schema.model_validate(compiled)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apply_schema_changes(
|
|
95
|
+
schema: Schema, changes: Sequence[SchemaChange], domain_id: str
|
|
96
|
+
) -> tuple[Schema, list[SchemaElementModel], list[str]]:
|
|
97
|
+
"""New schema plus the upserted elements and the removed element ids."""
|
|
98
|
+
elements = schema_elements(schema)
|
|
99
|
+
upserted: list[SchemaElementModel] = []
|
|
100
|
+
removed: list[str] = []
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
for change in changes:
|
|
103
|
+
key = change.element_id if isinstance(change, SchemaDelete) else change.element.id
|
|
104
|
+
if key in seen:
|
|
105
|
+
raise SchemaChangeError(f"schema element {key} is changed more than once")
|
|
106
|
+
seen.add(key)
|
|
107
|
+
if isinstance(change, SchemaDelete):
|
|
108
|
+
if key == domain_id:
|
|
109
|
+
raise SchemaChangeError("the Domain element cannot be removed")
|
|
110
|
+
if key not in elements:
|
|
111
|
+
raise SchemaChangeError(f"unknown schema element {key}")
|
|
112
|
+
del elements[key]
|
|
113
|
+
removed.append(key)
|
|
114
|
+
continue
|
|
115
|
+
element = change.element
|
|
116
|
+
if isinstance(element, Domain) and element.id != domain_id:
|
|
117
|
+
raise SchemaChangeError(f"Domain element {element.id} in Domain {domain_id}")
|
|
118
|
+
current = elements.get(key)
|
|
119
|
+
if current is not None and object_type_of(current) is not object_type_of(element):
|
|
120
|
+
raise SchemaChangeError(f"schema element {key} cannot change its type")
|
|
121
|
+
elements[key] = element
|
|
122
|
+
upserted.append(element)
|
|
123
|
+
return schema_from_elements(elements.values()), upserted, removed
|
lorebase_store/db.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Async engine factory built from the `store` settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import URL, make_url
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
9
|
+
from sqlalchemy.pool import NullPool
|
|
10
|
+
|
|
11
|
+
from lorebase_store.settings import DatabaseSettings
|
|
12
|
+
|
|
13
|
+
ASYNC_DRIVER = "postgresql+asyncpg"
|
|
14
|
+
_POSTGRES_SCHEMES = frozenset({"postgres", "postgresql", ASYNC_DRIVER})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def async_url(url: str | URL) -> URL:
|
|
18
|
+
"""Parse a Postgres URL and switch it to the asyncpg driver."""
|
|
19
|
+
parsed = make_url(url)
|
|
20
|
+
if parsed.drivername not in _POSTGRES_SCHEMES:
|
|
21
|
+
raise ValueError(f"unsupported database driver {parsed.drivername!r}; use postgresql://")
|
|
22
|
+
return parsed.set(drivername=ASYNC_DRIVER)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_engine(
|
|
26
|
+
database: DatabaseSettings,
|
|
27
|
+
*,
|
|
28
|
+
url: str | URL | None = None,
|
|
29
|
+
pooled: bool = True,
|
|
30
|
+
statement_timeout_ms: int | None = None,
|
|
31
|
+
) -> AsyncEngine:
|
|
32
|
+
"""Engine for `database`; `url` overrides the configured DSN, `pooled=False` uses no pool."""
|
|
33
|
+
timeout = (
|
|
34
|
+
database.statement_timeout_ms if statement_timeout_ms is None else statement_timeout_ms
|
|
35
|
+
)
|
|
36
|
+
connect_args: dict[str, Any] = {
|
|
37
|
+
"server_settings": {
|
|
38
|
+
"application_name": database.application_name,
|
|
39
|
+
"statement_timeout": str(timeout),
|
|
40
|
+
# `indexes.sql_literal` writes standard literals, where a backslash is plain text.
|
|
41
|
+
"standard_conforming_strings": "on",
|
|
42
|
+
},
|
|
43
|
+
# Statements are prepared once per connection and SQL text, then reused.
|
|
44
|
+
"prepared_statement_cache_size": database.prepared_statement_cache_size,
|
|
45
|
+
}
|
|
46
|
+
pool_args: dict[str, Any] = (
|
|
47
|
+
{
|
|
48
|
+
"pool_size": database.pool_size,
|
|
49
|
+
"max_overflow": database.max_overflow,
|
|
50
|
+
"pool_timeout": database.pool_timeout_s,
|
|
51
|
+
"pool_recycle": database.pool_recycle_s,
|
|
52
|
+
"pool_pre_ping": True,
|
|
53
|
+
}
|
|
54
|
+
if pooled
|
|
55
|
+
else {"poolclass": NullPool}
|
|
56
|
+
)
|
|
57
|
+
target = async_url(url if url is not None else database.dsn.get_secret_value())
|
|
58
|
+
return create_async_engine(target, connect_args=connect_args, **pool_args)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Grants on the security definer read functions of revisions 0005, 0010, 0018, 0035 and 0037
|
|
2
|
+
(store schema notes).
|
|
3
|
+
|
|
4
|
+
The migration revokes EXECUTE from PUBLIC and grants it to `database.application_role`.
|
|
5
|
+
`grant_read_functions` grants it to another role later: one created after the migration, or a
|
|
6
|
+
new application role.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
13
|
+
|
|
14
|
+
READ_FUNCTIONS = (
|
|
15
|
+
"lb_search",
|
|
16
|
+
"lb_links_in",
|
|
17
|
+
"lb_neighborhood",
|
|
18
|
+
"lb_query_by_attribute",
|
|
19
|
+
"lb_search_overlay",
|
|
20
|
+
"lb_links_in_overlay",
|
|
21
|
+
"lb_neighborhood_overlay",
|
|
22
|
+
"lb_query_by_attribute_overlay",
|
|
23
|
+
"lb_search_corrections",
|
|
24
|
+
"lb_search_corrected",
|
|
25
|
+
"lb_search_corrected_overlay",
|
|
26
|
+
"lb_search_bounded",
|
|
27
|
+
"lb_search_bounded_overlay",
|
|
28
|
+
)
|
|
29
|
+
"""Functions only the store read primitives call, with a Domain already authorized."""
|
|
30
|
+
BM25_FUNCTIONS = ("lb_search_bm25", "lb_search_bm25_overlay")
|
|
31
|
+
"""The BM25 search of revision 0037, called the same way; they reach the BM25 index only
|
|
32
|
+
where `pg_search` is installed (`lorebase_store.bm25`)."""
|
|
33
|
+
|
|
34
|
+
_SIGNATURES = text(
|
|
35
|
+
"SELECT CAST(CAST(p.oid AS regprocedure) AS text) FROM pg_proc p "
|
|
36
|
+
"WHERE p.proname = ANY(CAST(:names AS text[])) "
|
|
37
|
+
"AND p.pronamespace = CAST(current_schema() AS regnamespace) ORDER BY 1"
|
|
38
|
+
)
|
|
39
|
+
_QUOTED = text("SELECT quote_ident(:role)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def grant_read_functions(connection: AsyncConnection, role: str) -> list[str]:
|
|
43
|
+
"""Grant EXECUTE on the read functions to `role`; returns the signatures granted."""
|
|
44
|
+
quoted = (await connection.execute(_QUOTED, {"role": role})).scalar_one()
|
|
45
|
+
names = [*READ_FUNCTIONS, *BM25_FUNCTIONS]
|
|
46
|
+
found = await connection.execute(_SIGNATURES, {"names": names})
|
|
47
|
+
signatures = list(found.scalars())
|
|
48
|
+
for signature in signatures:
|
|
49
|
+
await connection.exec_driver_sql(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
|
|
50
|
+
return signatures
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Keyed digests of inputs that may hold personal data (design 13.5).
|
|
2
|
+
|
|
3
|
+
Lorebase stores digests of some inputs it must not keep: the parameters of Operation invocations
|
|
4
|
+
(idempotency, provenance) and the Record keys named by erasures in the audit log. A plain hash of
|
|
5
|
+
such an input confirms a guessed value, so every digest is an HMAC-SHA256 under a key of the
|
|
6
|
+
installation, chosen in this order:
|
|
7
|
+
|
|
8
|
+
1. `vault.digest_key`;
|
|
9
|
+
2. the vault's current token key, when the vault is configured (the digests made before this
|
|
10
|
+
module existed are unchanged);
|
|
11
|
+
3. the random secret revision `0027` stored in `installation_secrets` (name `digest`): it keeps
|
|
12
|
+
the digests unconfirmable for whoever reads the API or the audit log, not for whoever reads
|
|
13
|
+
that table. Configure one of the keys above for that.
|
|
14
|
+
|
|
15
|
+
The key of case 3 is read from the database once per process (`ready`). In case 2 the
|
|
16
|
+
earlier token keys (`vault.previous_token_keys`) still recognize the digests made under them
|
|
17
|
+
(`all_digests`), so a token key rotation does not break idempotent replays.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import select
|
|
23
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
24
|
+
|
|
25
|
+
from lorebase_store.keyring import Keyring
|
|
26
|
+
from lorebase_store.models import metadata
|
|
27
|
+
from lorebase_store.vault.keys import NAMESPACE, VaultKeys, framed
|
|
28
|
+
from lorebase_store.vault.settings import VaultSettings
|
|
29
|
+
|
|
30
|
+
SECRET_NAME = "digest"
|
|
31
|
+
_SECRETS = metadata.tables["installation_secrets"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DigestKeysNotReadyError(RuntimeError):
|
|
35
|
+
"""The installation secret has not been read yet (`DigestKeys.ready`)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DigestKeys:
|
|
39
|
+
"""Makes the keyed digests of the installation (module doc)."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
keyring: Keyring | None = None,
|
|
44
|
+
*,
|
|
45
|
+
source: str = "installation",
|
|
46
|
+
earlier: tuple[Keyring, ...] = (),
|
|
47
|
+
) -> None:
|
|
48
|
+
self._keyring = keyring
|
|
49
|
+
self._source = source
|
|
50
|
+
self._earlier = earlier
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_settings(cls, settings: VaultSettings, vault: VaultKeys | None) -> DigestKeys:
|
|
54
|
+
if settings.digest_key is not None:
|
|
55
|
+
return cls(
|
|
56
|
+
Keyring(settings.digest_key.get_secret_value(), namespace=NAMESPACE),
|
|
57
|
+
source="vault.digest_key",
|
|
58
|
+
)
|
|
59
|
+
if vault is not None:
|
|
60
|
+
return cls(
|
|
61
|
+
vault.digest_keyring(),
|
|
62
|
+
source="vault.token_key",
|
|
63
|
+
earlier=vault.earlier_digest_keyrings(),
|
|
64
|
+
)
|
|
65
|
+
return cls()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def source(self) -> str:
|
|
69
|
+
"""Where the key comes from: `vault.digest_key`, `vault.token_key` or `installation`."""
|
|
70
|
+
return self._source
|
|
71
|
+
|
|
72
|
+
async def ready(self, session: AsyncSession) -> DigestKeys:
|
|
73
|
+
"""These keys, after reading the installation secret if they need it."""
|
|
74
|
+
if self._keyring is None:
|
|
75
|
+
secret = (
|
|
76
|
+
await session.execute(
|
|
77
|
+
select(_SECRETS.c.secret).where(_SECRETS.c.name == SECRET_NAME)
|
|
78
|
+
)
|
|
79
|
+
).scalar_one_or_none()
|
|
80
|
+
if secret is None:
|
|
81
|
+
raise DigestKeysNotReadyError(
|
|
82
|
+
"installation_secrets has no digest secret: run the migrations (revision "
|
|
83
|
+
"0027) or set vault.digest_key"
|
|
84
|
+
)
|
|
85
|
+
self._keyring = Keyring(secret, namespace=NAMESPACE)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def digest(self, domain_id: str, purpose: str, payload: bytes) -> str:
|
|
89
|
+
"""Keyed digest (hex) of `payload` for `purpose` in a Domain."""
|
|
90
|
+
if self._keyring is None:
|
|
91
|
+
raise DigestKeysNotReadyError("call DigestKeys.ready first")
|
|
92
|
+
return self._keyring.mac("digest/" + purpose, framed(domain_id, payload)).hex()
|
|
93
|
+
|
|
94
|
+
def all_digests(self, domain_id: str, purpose: str, payload: bytes) -> frozenset[str]:
|
|
95
|
+
"""The digest under the current key and under the earlier token keys."""
|
|
96
|
+
found = {self.digest(domain_id, purpose, payload)}
|
|
97
|
+
for keyring in self._earlier:
|
|
98
|
+
found.add(keyring.mac("digest/" + purpose, framed(domain_id, payload)).hex())
|
|
99
|
+
return frozenset(found)
|