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,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
|