lorebase-store 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,52 @@
1
+ """Repositories of principals, groups, API keys, grants, web sessions and the audit log."""
2
+
3
+ from lorebase_store.auth.api_keys import ApiKeyRecord, ApiKeyRepository, PrefixTakenError
4
+ from lorebase_store.auth.audit import (
5
+ AuditRecord,
6
+ AuditRepository,
7
+ AuditStream,
8
+ ChainHead,
9
+ Chains,
10
+ ChainVerification,
11
+ NewAuditEntry,
12
+ audit_lock_key,
13
+ entry_digest,
14
+ )
15
+ from lorebase_store.auth.grants import GrantRecord, GrantRepository
16
+ from lorebase_store.auth.principals import (
17
+ GroupRepository,
18
+ PrincipalRecord,
19
+ PrincipalRepository,
20
+ )
21
+ from lorebase_store.auth.scope import new_id, rls_scope
22
+ from lorebase_store.auth.sessions import SessionRecord, SessionRepository
23
+ from lorebase_store.auth.unit import AuthUnit, SqlAuthStore
24
+ from lorebase_store.auth.usage import UsageRecord, UsageRepository
25
+
26
+ __all__ = [
27
+ "ApiKeyRecord",
28
+ "ApiKeyRepository",
29
+ "AuditRecord",
30
+ "AuditRepository",
31
+ "AuditStream",
32
+ "AuthUnit",
33
+ "ChainHead",
34
+ "ChainVerification",
35
+ "Chains",
36
+ "GrantRecord",
37
+ "GrantRepository",
38
+ "GroupRepository",
39
+ "NewAuditEntry",
40
+ "PrefixTakenError",
41
+ "PrincipalRecord",
42
+ "PrincipalRepository",
43
+ "SessionRecord",
44
+ "SessionRepository",
45
+ "SqlAuthStore",
46
+ "UsageRecord",
47
+ "UsageRepository",
48
+ "audit_lock_key",
49
+ "entry_digest",
50
+ "new_id",
51
+ "rls_scope",
52
+ ]
@@ -0,0 +1,161 @@
1
+ """API keys of service principals (design 13.2).
2
+
3
+ The repository stores and looks up opaque values: the key format, the HMAC and its pepper are
4
+ decided by the caller. A key is found by its public `prefix`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Collection, Sequence
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+
13
+ from sqlalchemy import select, tuple_, update
14
+ from sqlalchemy.exc import IntegrityError
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from lorebase_store.auth.scope import new_id
18
+ from lorebase_store.models import ApiKey
19
+
20
+ ID_PREFIX = "key"
21
+
22
+
23
+ class PrefixTakenError(Exception):
24
+ """Another key already uses this public prefix; generate a new key."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ApiKeyRecord:
29
+ """A stored key. `hash` is the HMAC of the secret, never the secret."""
30
+
31
+ id: str
32
+ principal_id: str
33
+ prefix: str
34
+ hash: bytes
35
+ scopes: tuple[str, ...]
36
+ expires_at: datetime | None
37
+ last_used_at: datetime | None
38
+ revoked_at: datetime | None
39
+ created_at: datetime
40
+ allowed_networks: tuple[str, ...] = ()
41
+ """Client networks (CIDR) the key may be used from; empty: any address."""
42
+
43
+ def is_usable(self, now: datetime) -> bool:
44
+ """Neither revoked nor expired at `now`."""
45
+ return self.revoked_at is None and (self.expires_at is None or self.expires_at > now)
46
+
47
+
48
+ def _record(row: ApiKey) -> ApiKeyRecord:
49
+ return ApiKeyRecord(
50
+ id=row.id,
51
+ principal_id=row.principal_id,
52
+ prefix=row.prefix,
53
+ hash=row.hash,
54
+ scopes=tuple(row.scopes or ()),
55
+ expires_at=row.expires_at,
56
+ last_used_at=row.last_used_at,
57
+ revoked_at=row.revoked_at,
58
+ created_at=row.created_at,
59
+ allowed_networks=tuple(str(network) for network in row.allowed_networks or ()),
60
+ )
61
+
62
+
63
+ class ApiKeyRepository:
64
+ """API keys. The table is installation-wide; Domain scope lives in `scopes`."""
65
+
66
+ def __init__(self, session: AsyncSession) -> None:
67
+ self._session = session
68
+
69
+ async def add(
70
+ self,
71
+ *,
72
+ principal_id: str,
73
+ prefix: str,
74
+ key_hash: bytes,
75
+ scopes: Sequence[str] = (),
76
+ expires_at: datetime | None = None,
77
+ allowed_networks: Sequence[str] = (),
78
+ ) -> ApiKeyRecord:
79
+ """Insert a key; `PrefixTakenError` when the prefix is already in use.
80
+
81
+ `allowed_networks` are CIDR strings; empty stores null (any address).
82
+ """
83
+ row = ApiKey(
84
+ id=new_id(ID_PREFIX),
85
+ principal_id=principal_id,
86
+ prefix=prefix,
87
+ hash=key_hash,
88
+ scopes=list(scopes),
89
+ expires_at=expires_at,
90
+ allowed_networks=list(allowed_networks) or None,
91
+ )
92
+ try:
93
+ async with self._session.begin_nested():
94
+ self._session.add(row)
95
+ await self._session.flush()
96
+ except IntegrityError as exc:
97
+ if "prefix" in str(exc.orig):
98
+ raise PrefixTakenError(prefix) from None
99
+ raise
100
+ await self._session.refresh(row)
101
+ return _record(row)
102
+
103
+ async def get(self, key_id: str) -> ApiKeyRecord | None:
104
+ """The key with this id."""
105
+ row = await self._session.get(ApiKey, key_id)
106
+ return _record(row) if row else None
107
+
108
+ async def get_by_prefix(self, prefix: str) -> ApiKeyRecord | None:
109
+ """The key with this public prefix."""
110
+ row = (
111
+ await self._session.execute(select(ApiKey).where(ApiKey.prefix == prefix))
112
+ ).scalar_one_or_none()
113
+ return _record(row) if row else None
114
+
115
+ async def touch(self, key_id: str, at: datetime, *, min_interval: timedelta) -> bool:
116
+ """Record a use at `at`, unless the last recorded use is more recent than `min_interval`.
117
+
118
+ Returns whether the row was updated; the interval keeps hot keys from writing on every
119
+ request.
120
+ """
121
+ result = await self._session.execute(
122
+ update(ApiKey)
123
+ .where(ApiKey.id == key_id)
124
+ .where((ApiKey.last_used_at.is_(None)) | (ApiKey.last_used_at <= at - min_interval))
125
+ .values(last_used_at=at)
126
+ )
127
+ return bool(result.rowcount) # type: ignore[attr-defined]
128
+
129
+ async def revoke(self, key_id: str, at: datetime) -> bool:
130
+ """Revoke a key; false when it does not exist or was already revoked."""
131
+ result = await self._session.execute(
132
+ update(ApiKey)
133
+ .where(ApiKey.id == key_id, ApiKey.revoked_at.is_(None))
134
+ .values(revoked_at=at)
135
+ )
136
+ return bool(result.rowcount) # type: ignore[attr-defined]
137
+
138
+ async def list_for_scope(
139
+ self, scope: str, *, limit: int, after: tuple[datetime, str] | None = None
140
+ ) -> list[ApiKeyRecord]:
141
+ """Keys whose `scopes` contain `scope` (for example `domain:acme`), oldest first."""
142
+ statement = (
143
+ select(ApiKey)
144
+ .where(ApiKey.scopes.contains([scope]))
145
+ .order_by(ApiKey.created_at, ApiKey.id)
146
+ .limit(limit)
147
+ )
148
+ if after is not None:
149
+ statement = statement.where(tuple_(ApiKey.created_at, ApiKey.id) > after)
150
+ return [_record(row) for row in (await self._session.execute(statement)).scalars()]
151
+
152
+ async def list_for_principals(self, principal_ids: Collection[str]) -> list[ApiKeyRecord]:
153
+ """Every key of these principals, revoked and expired ones included, oldest first."""
154
+ if not principal_ids:
155
+ return []
156
+ statement = (
157
+ select(ApiKey)
158
+ .where(ApiKey.principal_id.in_(list(principal_ids)))
159
+ .order_by(ApiKey.created_at, ApiKey.id)
160
+ )
161
+ return [_record(row) for row in (await self._session.execute(statement)).scalars()]
@@ -0,0 +1,382 @@
1
+ """Hash-chained audit log (design 10.4).
2
+
3
+ The log holds one chain per Domain, plus one installation-wide chain for entries without a
4
+ Domain. Each entry stores `entry_hash = sha256(prev_hash || canonical entry)` where `prev_hash`
5
+ is the hash of the previous entry of the same chain in `seq` order (empty for the first) and the
6
+ canonical entry is the RFC 8785 serialization of its content. Changing or removing a stored row
7
+ breaks its chain from that point on, which `AuditRepository.verify` reports.
8
+
9
+ Appends to one chain are serialized with a transaction-level advisory lock keyed by the Domain
10
+ and run in the system row-level security scope; appends to different chains do not wait for
11
+ each other. The lock is held until commit: the Keeper writes in a short transaction of its own,
12
+ the commit pipeline inside the commit transaction, after the ref update.
13
+
14
+ There are two streams, each in a table of its own with chains of its own (`AuditStream`):
15
+ actions (`audit_log`: mutations, denials, administrative events) and reads (`audit_read_log`,
16
+ written in batches by Keeper when `keeper.auth.audit.reads` is on). Reads are sampled and their
17
+ writer drops records under pressure, by design; in chains apart, neither their volume nor their
18
+ gaps reach the chains of actions, whose appends (inside commits too) never wait for reads, and
19
+ each stream is verified, anchored and kept for its own retention.
20
+
21
+ Removing the newest entries of a chain leaves a valid, shorter chain. `heads` gives the newest
22
+ entry of every chain, which `lorebase_store.auth.checkpoints` publishes outside the database so
23
+ that such a truncation can be detected.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ from collections.abc import Collection, Mapping, Sequence
30
+ from dataclasses import dataclass, field
31
+ from datetime import UTC, datetime
32
+ from enum import Enum, StrEnum
33
+ from typing import Any
34
+
35
+ from sqlalchemy import ColumnElement, insert, select, text
36
+ from sqlalchemy.ext.asyncio import AsyncSession
37
+
38
+ from lorebase_core.canonical import jcs_bytes
39
+ from lorebase_store.auth.scope import rls_scope
40
+ from lorebase_store.models import AuditEntry, AuditReadEntry
41
+
42
+ _AUDIT_LOCK_SCOPE = b"lb1\x00audit-chain\x00"
43
+ _READ_LOCK_SCOPE = b"lb1\x00audit-read-chain\x00"
44
+
45
+ VERIFY_BATCH = 1000
46
+
47
+
48
+ class AuditStream(StrEnum):
49
+ """The audit tables, each with chains of its own."""
50
+
51
+ ACTIONS = "actions"
52
+ """`audit_log`: mutations, denials and administrative events."""
53
+ READS = "reads"
54
+ """`audit_read_log`: reads recorded under `keeper.auth.audit.reads`."""
55
+
56
+
57
+ AuditModel = type[AuditEntry] | type[AuditReadEntry]
58
+
59
+ _MODELS: dict[AuditStream, AuditModel] = {
60
+ AuditStream.ACTIONS: AuditEntry,
61
+ AuditStream.READS: AuditReadEntry,
62
+ }
63
+ _LOCK_SCOPES = {AuditStream.ACTIONS: _AUDIT_LOCK_SCOPE, AuditStream.READS: _READ_LOCK_SCOPE}
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class NewAuditEntry:
68
+ """An action to record. `at` defaults to the current time."""
69
+
70
+ actor: str
71
+ action: str
72
+ domain_id: str | None = None
73
+ target: str | None = None
74
+ details: Mapping[str, Any] = field(default_factory=dict)
75
+ at: datetime | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class AuditRecord:
80
+ """A stored audit entry."""
81
+
82
+ seq: int
83
+ domain_id: str | None
84
+ at: datetime
85
+ actor: str
86
+ action: str
87
+ target: str | None
88
+ details: Mapping[str, Any]
89
+ prev_hash: bytes | None
90
+ entry_hash: bytes
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ChainHead:
95
+ """The newest entry of one chain."""
96
+
97
+ domain_id: str | None
98
+ seq: int
99
+ entry_hash: bytes
100
+
101
+
102
+ class Chains(Enum):
103
+ """Chain selectors of `AuditRepository.verify` besides one Domain id."""
104
+
105
+ ALL = "all"
106
+ """Every chain: each Domain's and the installation-wide one."""
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ChainVerification:
111
+ """Result of walking the chain. `bad_seq` is the first entry that does not verify.
112
+
113
+ `checked` counts the entries that verified before it, `chains` the chains they belong to,
114
+ `last_seq` is the `seq` of the last entry that verified (a checkpoint for `after_seq`) and
115
+ `bad_domain_id` the Domain of the broken entry (None for the installation-wide chain).
116
+ """
117
+
118
+ checked: int
119
+ bad_seq: int | None = None
120
+ reason: str | None = None
121
+ chains: int = 0
122
+ last_seq: int | None = None
123
+ bad_domain_id: str | None = None
124
+
125
+ @property
126
+ def ok(self) -> bool:
127
+ """The whole chain verified."""
128
+ return self.bad_seq is None
129
+
130
+
131
+ def audit_lock_key(domain_id: str | None, stream: AuditStream = AuditStream.ACTIONS) -> int:
132
+ """Advisory lock key of a Domain's chain (`None`: the installation-wide chain)."""
133
+ digest = hashlib.sha256(_LOCK_SCOPES[stream] + (domain_id or "").encode("utf-8")).digest()
134
+ return int.from_bytes(digest[:8], "big", signed=True)
135
+
136
+
137
+ def _same_chain(model: AuditModel, domain_id: str | None) -> ColumnElement[bool]:
138
+ return model.domain_id.is_(None) if domain_id is None else model.domain_id == domain_id
139
+
140
+
141
+ def _timestamp(at: datetime) -> str:
142
+ return at.astimezone(UTC).isoformat(timespec="microseconds")
143
+
144
+
145
+ def entry_digest(
146
+ prev_hash: bytes | None,
147
+ *,
148
+ domain_id: str | None,
149
+ at: datetime,
150
+ actor: str,
151
+ action: str,
152
+ target: str | None,
153
+ details: Mapping[str, Any],
154
+ ) -> bytes:
155
+ """`sha256(prev_hash || canonical entry)` for the given content."""
156
+ canonical = jcs_bytes(
157
+ {
158
+ "domain_id": domain_id,
159
+ "at": _timestamp(at),
160
+ "actor": actor,
161
+ "action": action,
162
+ "target": target,
163
+ "details": dict(details),
164
+ }
165
+ )
166
+ return hashlib.sha256((prev_hash or b"") + canonical).digest()
167
+
168
+
169
+ def _record(row: Any) -> AuditRecord:
170
+ """The record of a row of either audit table."""
171
+ return AuditRecord(
172
+ seq=row.seq,
173
+ domain_id=row.domain_id,
174
+ at=row.at,
175
+ actor=row.actor,
176
+ action=row.action,
177
+ target=row.target,
178
+ details=row.details,
179
+ prev_hash=row.prev_hash,
180
+ entry_hash=row.entry_hash,
181
+ )
182
+
183
+
184
+ def _digest_of(record: AuditRecord, prev_hash: bytes | None) -> bytes:
185
+ return entry_digest(
186
+ prev_hash,
187
+ domain_id=record.domain_id,
188
+ at=record.at,
189
+ actor=record.actor,
190
+ action=record.action,
191
+ target=record.target,
192
+ details=record.details,
193
+ )
194
+
195
+
196
+ def _values(entry: NewAuditEntry, at: datetime, prev_hash: bytes | None) -> dict[str, Any]:
197
+ """Column values of an entry linked to `prev_hash`."""
198
+ return {
199
+ "domain_id": entry.domain_id,
200
+ "at": at,
201
+ "actor": entry.actor,
202
+ "action": entry.action,
203
+ "target": entry.target,
204
+ "details": dict(entry.details),
205
+ "prev_hash": prev_hash,
206
+ "entry_hash": entry_digest(
207
+ prev_hash,
208
+ domain_id=entry.domain_id,
209
+ at=at,
210
+ actor=entry.actor,
211
+ action=entry.action,
212
+ target=entry.target,
213
+ details=entry.details,
214
+ ),
215
+ }
216
+
217
+
218
+ class AuditRepository:
219
+ """Append, read and verify one stream of the audit log (default: actions)."""
220
+
221
+ def __init__(self, session: AsyncSession, stream: AuditStream = AuditStream.ACTIONS) -> None:
222
+ self._session = session
223
+ self._stream = stream
224
+ self._model: AuditModel = _MODELS[stream]
225
+
226
+ async def _lock_and_head(self, domain_id: str | None) -> bytes | None:
227
+ """Lock the chain until commit and read the hash of its newest entry."""
228
+ model = self._model
229
+ await self._session.execute(
230
+ text("SELECT pg_advisory_xact_lock(:key)"),
231
+ {"key": audit_lock_key(domain_id, self._stream)},
232
+ )
233
+ head: bytes | None = (
234
+ await self._session.execute(
235
+ select(model.entry_hash)
236
+ .where(_same_chain(model, domain_id))
237
+ .order_by(model.seq.desc())
238
+ .limit(1)
239
+ )
240
+ ).scalar_one_or_none()
241
+ return head
242
+
243
+ async def append(self, entry: NewAuditEntry) -> AuditRecord:
244
+ """Append an entry to its Domain's chain and return it."""
245
+ at = entry.at or datetime.now(UTC)
246
+ async with rls_scope(self._session, bypass=True):
247
+ prev_hash = await self._lock_and_head(entry.domain_id)
248
+ row = (
249
+ await self._session.execute(
250
+ insert(self._model)
251
+ .values(**_values(entry, at, prev_hash))
252
+ .returning(self._model)
253
+ )
254
+ ).scalar_one()
255
+ return _record(row)
256
+
257
+ async def append_many(self, entries: Sequence[NewAuditEntry]) -> int:
258
+ """Append entries to their chains, keeping their order within each chain; returns
259
+ the number appended. Chains are locked in a fixed order (by Domain id), so concurrent
260
+ batches never deadlock, and each chain is read once per batch."""
261
+ by_chain: dict[str | None, list[NewAuditEntry]] = {}
262
+ for entry in entries:
263
+ by_chain.setdefault(entry.domain_id, []).append(entry)
264
+ now = datetime.now(UTC)
265
+ async with rls_scope(self._session, bypass=True):
266
+ for domain_id in sorted(by_chain, key=lambda d: (d is not None, d or "")):
267
+ prev_hash = await self._lock_and_head(domain_id)
268
+ rows = []
269
+ for entry in by_chain[domain_id]:
270
+ row = _values(entry, entry.at or now, prev_hash)
271
+ prev_hash = row["entry_hash"]
272
+ rows.append(row)
273
+ # Postgres assigns the identity in the order of the VALUES list.
274
+ await self._session.execute(insert(self._model).values(rows))
275
+ return len(entries)
276
+
277
+ async def heads(self) -> list[ChainHead]:
278
+ """The newest entry of every chain of the stream."""
279
+ model = self._model
280
+ statement = (
281
+ select(model.domain_id, model.seq, model.entry_hash)
282
+ .distinct(model.domain_id)
283
+ .order_by(model.domain_id, model.seq.desc())
284
+ )
285
+ async with rls_scope(self._session, bypass=True):
286
+ rows = (await self._session.execute(statement)).all()
287
+ return [ChainHead(domain_id, int(seq), bytes(digest)) for domain_id, seq, digest in rows]
288
+
289
+ async def entry_hashes(
290
+ self, positions: Collection[tuple[str | None, int]]
291
+ ) -> dict[tuple[str | None, int], bytes]:
292
+ """`entry_hash` of the entries at `(domain_id, seq)`; absent ones are left out."""
293
+ if not positions:
294
+ return {}
295
+ model = self._model
296
+ seqs = sorted({seq for _, seq in positions})
297
+ statement = select(model.domain_id, model.seq, model.entry_hash).where(model.seq.in_(seqs))
298
+ async with rls_scope(self._session, bypass=True):
299
+ rows = (await self._session.execute(statement)).all()
300
+ wanted = set(positions)
301
+ return {
302
+ (domain_id, int(seq)): bytes(digest)
303
+ for domain_id, seq, digest in rows
304
+ if (domain_id, int(seq)) in wanted
305
+ }
306
+
307
+ async def list(
308
+ self, *, domain_id: str | None, limit: int, before_seq: int | None = None
309
+ ) -> list[AuditRecord]:
310
+ """Entries of a Domain (or the installation-wide ones when `domain_id` is None).
311
+
312
+ Newest first; `before_seq` is the `seq` of the last entry of the previous page.
313
+ """
314
+ model = self._model
315
+ statement = (
316
+ select(model)
317
+ .where(_same_chain(model, domain_id))
318
+ .order_by(model.seq.desc())
319
+ .limit(limit)
320
+ )
321
+ if before_seq is not None:
322
+ statement = statement.where(model.seq < before_seq)
323
+ async with rls_scope(
324
+ self._session,
325
+ domain_ids=[] if domain_id is None else [domain_id],
326
+ bypass=domain_id is None,
327
+ ):
328
+ return [_record(row) for row in (await self._session.execute(statement)).scalars()]
329
+
330
+ async def verify(
331
+ self,
332
+ *,
333
+ after_seq: int = 0,
334
+ batch_size: int = VERIFY_BATCH,
335
+ domain_id: str | Chains | None = Chains.ALL,
336
+ ) -> ChainVerification:
337
+ """Recompute chains from `after_seq` (exclusive) and report the first broken entry.
338
+
339
+ `domain_id` selects one Domain's chain, None the installation-wide chain, and
340
+ `Chains.ALL` (the default) every chain. Entries are read in `seq` order, `batch_size`
341
+ at a time, so memory holds one batch and the head hash of each chain seen. Each entry
342
+ must link to the previous entry of its own chain. The first entry of each chain in the
343
+ range is trusted as the anchor for its own `prev_hash`; verifying from 0 also requires
344
+ the first entry of every chain to have none.
345
+ """
346
+ checked = 0
347
+ cursor = after_seq
348
+ last_seq: int | None = None
349
+ heads: dict[str | None, bytes] = {}
350
+ model = self._model
351
+ statement = select(model).order_by(model.seq).limit(batch_size)
352
+ if not isinstance(domain_id, Chains):
353
+ statement = statement.where(_same_chain(model, domain_id))
354
+
355
+ def broken(record: AuditRecord, reason: str) -> ChainVerification:
356
+ return ChainVerification(
357
+ checked,
358
+ record.seq,
359
+ reason,
360
+ chains=len(heads | {record.domain_id: b""}),
361
+ last_seq=last_seq,
362
+ bad_domain_id=record.domain_id,
363
+ )
364
+
365
+ async with rls_scope(self._session, bypass=True):
366
+ while True:
367
+ rows = (await self._session.execute(statement.where(model.seq > cursor))).scalars()
368
+ batch = [_record(row) for row in rows]
369
+ if not batch:
370
+ return ChainVerification(checked, chains=len(heads), last_seq=last_seq)
371
+ for record in batch:
372
+ if record.domain_id in heads:
373
+ expected_prev: bytes | None = heads[record.domain_id]
374
+ else:
375
+ expected_prev = record.prev_hash if after_seq > 0 else None
376
+ if record.prev_hash != expected_prev:
377
+ return broken(record, "chain link broken")
378
+ if _digest_of(record, record.prev_hash) != record.entry_hash:
379
+ return broken(record, "entry content changed")
380
+ heads[record.domain_id] = record.entry_hash
381
+ cursor = last_seq = record.seq
382
+ checked += 1