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 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Run Keeper, or prepare an installation, with the settings Keeper reads.
|
|
2
|
+
|
|
3
|
+
python -m lorebase_keeper [serve]
|
|
4
|
+
Serve the API; host and port come from the `keeper` settings.
|
|
5
|
+
python -m lorebase_keeper bootstrap [--principal ID] [--name NAME] [--force]
|
|
6
|
+
Create the first installation administrator key and print it once (see bootstrap.py).
|
|
7
|
+
python -m lorebase_keeper principal create ID [--name NAME]
|
|
8
|
+
Add a service principal with a chosen id; nothing happens when it exists.
|
|
9
|
+
python -m lorebase_keeper audit verify (DOMAIN | --installation | --all) [--after-seq N] [--json]
|
|
10
|
+
[--reads] [--checkpoints PATH ... --checkpoint-key FILE]
|
|
11
|
+
Recompute the hash chains of the audit log (the read stream with --reads) and report the
|
|
12
|
+
first broken entry; with --checkpoints, also compare the log with the signed checkpoints
|
|
13
|
+
Scribe published (files or directories of them) to detect removed or rewritten entries.
|
|
14
|
+
The exit status is 1 when an entry does not verify, a checkpointed entry is missing or
|
|
15
|
+
changed, or a checkpoint is invalid.
|
|
16
|
+
python -m lorebase_keeper sources vault [--json] [--check]
|
|
17
|
+
List the Sources of every Domain's default branch that map vault Attributes (upgrade
|
|
18
|
+
check for pii.import, design 13.5); with --check the exit status is 1 when any is listed.
|
|
19
|
+
|
|
20
|
+
The installation commands connect with `database.maintenance_dsn` when it is set, else with
|
|
21
|
+
`database.dsn`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from collections.abc import Awaitable, Callable, Coroutine, Sequence
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import uvicorn
|
|
37
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
38
|
+
|
|
39
|
+
from lorebase_keeper.app import create_app
|
|
40
|
+
from lorebase_keeper.bootstrap import (
|
|
41
|
+
DEFAULT_ADMIN_NAME,
|
|
42
|
+
BootstrapError,
|
|
43
|
+
bootstrap,
|
|
44
|
+
create_service_principal,
|
|
45
|
+
)
|
|
46
|
+
from lorebase_keeper.services.pii_import import installation_vault_sources
|
|
47
|
+
from lorebase_keeper.settings import keeper_settings
|
|
48
|
+
from lorebase_platform.logging import configure_logging
|
|
49
|
+
from lorebase_platform.settings import ConfigError, Settings, load_settings
|
|
50
|
+
from lorebase_platform.telemetry import setup_telemetry
|
|
51
|
+
from lorebase_store.auth import AuditStream, Chains, ChainVerification, SqlAuthStore
|
|
52
|
+
from lorebase_store.auth.checkpoints import (
|
|
53
|
+
Checkpoint,
|
|
54
|
+
CheckpointError,
|
|
55
|
+
CheckpointFinding,
|
|
56
|
+
compare,
|
|
57
|
+
load_public_key,
|
|
58
|
+
parse_document,
|
|
59
|
+
)
|
|
60
|
+
from lorebase_store.db import create_engine
|
|
61
|
+
from lorebase_store.settings import DatabaseSettings, store_settings
|
|
62
|
+
|
|
63
|
+
SERVICE_NAME = "keeper"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parser() -> argparse.ArgumentParser:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="python -m lorebase_keeper",
|
|
69
|
+
description="Run Keeper or prepare an installation with Keeper's settings.",
|
|
70
|
+
)
|
|
71
|
+
commands = parser.add_subparsers(dest="command")
|
|
72
|
+
commands.add_parser("serve", help="serve the API (the default)")
|
|
73
|
+
boot = commands.add_parser(
|
|
74
|
+
"bootstrap",
|
|
75
|
+
help="create the first installation administrator key and print it once",
|
|
76
|
+
description="Create a key for an installation administrator (keeper.auth."
|
|
77
|
+
"installation_admins) and print it on standard output, once. Refused when an "
|
|
78
|
+
"installation administrator already holds a usable key, unless --force is given.",
|
|
79
|
+
)
|
|
80
|
+
boot.add_argument(
|
|
81
|
+
"--principal",
|
|
82
|
+
help="administrator principal id (default: the first of keeper.auth.installation_admins)",
|
|
83
|
+
)
|
|
84
|
+
boot.add_argument(
|
|
85
|
+
"--name",
|
|
86
|
+
default=DEFAULT_ADMIN_NAME,
|
|
87
|
+
help="display name when the principal is created",
|
|
88
|
+
)
|
|
89
|
+
boot.add_argument(
|
|
90
|
+
"--force",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="add a key even when an administrator already holds one (existing keys stay valid)",
|
|
93
|
+
)
|
|
94
|
+
principal = commands.add_parser("principal", help="manage service principals")
|
|
95
|
+
principal_commands = principal.add_subparsers(dest="principal_command", required=True)
|
|
96
|
+
create = principal_commands.add_parser(
|
|
97
|
+
"create",
|
|
98
|
+
help="add a service principal with a chosen id",
|
|
99
|
+
description="Add a service principal with a chosen id, for example one that a schema "
|
|
100
|
+
"names in Operation permissions. Nothing happens when it already exists.",
|
|
101
|
+
)
|
|
102
|
+
create.add_argument("principal_id", metavar="ID")
|
|
103
|
+
create.add_argument("--name", help="display name (default: the id)")
|
|
104
|
+
audit = commands.add_parser("audit", help="check the audit log")
|
|
105
|
+
audit_commands = audit.add_subparsers(dest="audit_command", required=True)
|
|
106
|
+
verify = audit_commands.add_parser(
|
|
107
|
+
"verify",
|
|
108
|
+
help="recompute the audit hash chains and report the first broken entry",
|
|
109
|
+
description="Recompute the hash chain of a Domain's audit log, of the installation-wide "
|
|
110
|
+
"chain or of every chain, reading entries in batches. Exits with status 1 when an entry "
|
|
111
|
+
"does not verify.",
|
|
112
|
+
)
|
|
113
|
+
chain = verify.add_mutually_exclusive_group(required=True)
|
|
114
|
+
chain.add_argument("domain", nargs="?", metavar="DOMAIN", help="Domain id")
|
|
115
|
+
chain.add_argument(
|
|
116
|
+
"--installation", action="store_true", help="the chain of entries without a Domain"
|
|
117
|
+
)
|
|
118
|
+
chain.add_argument("--all", action="store_true", help="every chain")
|
|
119
|
+
verify.add_argument(
|
|
120
|
+
"--after-seq",
|
|
121
|
+
type=int,
|
|
122
|
+
default=0,
|
|
123
|
+
metavar="N",
|
|
124
|
+
help="start after entry N, trusting it (the last_seq of an earlier run); default 0",
|
|
125
|
+
)
|
|
126
|
+
verify.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
127
|
+
verify.add_argument(
|
|
128
|
+
"--reads", action="store_true", help="the read stream (audit_read_log) instead of actions"
|
|
129
|
+
)
|
|
130
|
+
verify.add_argument(
|
|
131
|
+
"--checkpoints",
|
|
132
|
+
action="append",
|
|
133
|
+
default=[],
|
|
134
|
+
type=Path,
|
|
135
|
+
metavar="PATH",
|
|
136
|
+
help="checkpoint file, or directory searched for *.json (repeatable)",
|
|
137
|
+
)
|
|
138
|
+
verify.add_argument(
|
|
139
|
+
"--checkpoint-key",
|
|
140
|
+
type=Path,
|
|
141
|
+
metavar="FILE",
|
|
142
|
+
help="PEM Ed25519 public key the checkpoints are signed with",
|
|
143
|
+
)
|
|
144
|
+
verify.add_argument(
|
|
145
|
+
"--max-age",
|
|
146
|
+
type=float,
|
|
147
|
+
metavar="SECONDS",
|
|
148
|
+
help="fail when the newest checkpoint is older (publication stopped)",
|
|
149
|
+
)
|
|
150
|
+
sources = commands.add_parser("sources", help="check Sources")
|
|
151
|
+
sources_commands = sources.add_subparsers(dest="sources_command", required=True)
|
|
152
|
+
vault = sources_commands.add_parser(
|
|
153
|
+
"vault",
|
|
154
|
+
help="list the Sources that map vault Attributes",
|
|
155
|
+
description="List the Sources of every Domain's default branch that map columns to "
|
|
156
|
+
"vault Attributes. Defining, changing or triggering such a Source needs pii.import; "
|
|
157
|
+
"Sources defined before that permission existed still sync on their schedule: confirm "
|
|
158
|
+
"each, or disable it (remove its schedule or deprecate it) with a schema change.",
|
|
159
|
+
)
|
|
160
|
+
vault.add_argument("--json", action="store_true", help="print the Sources as JSON")
|
|
161
|
+
vault.add_argument(
|
|
162
|
+
"--check", action="store_true", help="exit with status 1 when any Source is listed"
|
|
163
|
+
)
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
168
|
+
"""Dispatch to serving (no arguments) or to an installation command."""
|
|
169
|
+
args = _parser().parse_args(argv)
|
|
170
|
+
if args.command == "bootstrap":
|
|
171
|
+
_run_admin(lambda settings: _bootstrap(settings, args))
|
|
172
|
+
elif args.command == "principal":
|
|
173
|
+
_run_admin(lambda settings: _create_principal(settings, args))
|
|
174
|
+
elif args.command == "audit":
|
|
175
|
+
_run_admin(lambda settings: _verify_audit(settings, args))
|
|
176
|
+
elif args.command == "sources":
|
|
177
|
+
_run_admin(lambda settings: _vault_sources(settings, args))
|
|
178
|
+
else:
|
|
179
|
+
serve()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def serve() -> None:
|
|
183
|
+
"""Load settings, configure logging and telemetry, and serve until stopped."""
|
|
184
|
+
try:
|
|
185
|
+
settings = load_settings()
|
|
186
|
+
keeper = keeper_settings(settings)
|
|
187
|
+
configure_logging(settings.logging, service=SERVICE_NAME)
|
|
188
|
+
telemetry = setup_telemetry(settings.telemetry)
|
|
189
|
+
app = create_app(settings, telemetry=telemetry)
|
|
190
|
+
except ConfigError as exc:
|
|
191
|
+
sys.exit(str(exc))
|
|
192
|
+
try:
|
|
193
|
+
uvicorn.run(
|
|
194
|
+
app,
|
|
195
|
+
host=keeper.host,
|
|
196
|
+
port=keeper.port,
|
|
197
|
+
log_config=None,
|
|
198
|
+
access_log=False,
|
|
199
|
+
server_header=False,
|
|
200
|
+
# The client address comes from `keeper.trusted_proxies` (client_address.py) only.
|
|
201
|
+
proxy_headers=False,
|
|
202
|
+
)
|
|
203
|
+
finally:
|
|
204
|
+
telemetry.shutdown()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_admin(command: Callable[[Settings], Coroutine[Any, Any, None]]) -> None:
|
|
208
|
+
"""Load settings and run an installation command; problems exit with a message."""
|
|
209
|
+
try:
|
|
210
|
+
settings = load_settings()
|
|
211
|
+
configure_logging(settings.logging, service=SERVICE_NAME)
|
|
212
|
+
asyncio.run(command(settings))
|
|
213
|
+
except (ConfigError, BootstrapError) as exc:
|
|
214
|
+
sys.exit(f"lorebase_keeper: {exc}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _store(settings: Settings) -> tuple[SqlAuthStore, Callable[[], Awaitable[None]]]:
|
|
218
|
+
"""Auth store of the installation commands: the owner role of `database.maintenance_dsn`
|
|
219
|
+
when it is set (the services may connect as a role subject to row-level security), else
|
|
220
|
+
`database.dsn`."""
|
|
221
|
+
database = settings.section("database", DatabaseSettings)
|
|
222
|
+
owner = database.maintenance_dsn
|
|
223
|
+
engine = create_engine(
|
|
224
|
+
database, url=None if owner is None else owner.get_secret_value(), pooled=False
|
|
225
|
+
)
|
|
226
|
+
return SqlAuthStore(async_sessionmaker(engine, expire_on_commit=False)), engine.dispose
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _bootstrap(settings: Settings, args: argparse.Namespace) -> None:
|
|
230
|
+
auth = keeper_settings(settings).auth
|
|
231
|
+
store, dispose = _store(settings)
|
|
232
|
+
try:
|
|
233
|
+
created = await bootstrap(
|
|
234
|
+
store,
|
|
235
|
+
auth,
|
|
236
|
+
now=datetime.now(UTC),
|
|
237
|
+
principal_id=args.principal,
|
|
238
|
+
display_name=args.name,
|
|
239
|
+
force=args.force,
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
await dispose()
|
|
243
|
+
expiry = created.expires_at.date().isoformat() if created.expires_at else "never"
|
|
244
|
+
action = "created" if created.principal_created else "existing"
|
|
245
|
+
print(
|
|
246
|
+
f"API key {created.prefix}... for {action} principal {created.principal_id} "
|
|
247
|
+
f"(expires {expiry}); it is printed once on standard output",
|
|
248
|
+
file=sys.stderr,
|
|
249
|
+
)
|
|
250
|
+
print(created.token)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _create_principal(settings: Settings, args: argparse.Namespace) -> None:
|
|
254
|
+
store, dispose = _store(settings)
|
|
255
|
+
try:
|
|
256
|
+
added = await create_service_principal(
|
|
257
|
+
store, args.principal_id, args.name or args.principal_id, now=datetime.now(UTC)
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
await dispose()
|
|
261
|
+
state = "created" if added else "already exists"
|
|
262
|
+
print(f"service principal {args.principal_id} {state}", file=sys.stderr)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _chain_of(args: argparse.Namespace) -> str | Chains | None:
|
|
266
|
+
if args.all:
|
|
267
|
+
return Chains.ALL
|
|
268
|
+
return None if args.installation else str(args.domain)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def verification_report(
|
|
272
|
+
chain: str | Chains | None,
|
|
273
|
+
result: ChainVerification,
|
|
274
|
+
stream: AuditStream = AuditStream.ACTIONS,
|
|
275
|
+
anchored: Anchoring | None = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""JSON form of a verification, as `audit verify --json` prints it."""
|
|
278
|
+
broken = (
|
|
279
|
+
None
|
|
280
|
+
if result.ok
|
|
281
|
+
else {"seq": result.bad_seq, "domain_id": result.bad_domain_id, "reason": result.reason}
|
|
282
|
+
)
|
|
283
|
+
report: dict[str, Any] = {
|
|
284
|
+
"stream": stream.value,
|
|
285
|
+
"chain": chain.value if isinstance(chain, Chains) else chain,
|
|
286
|
+
"intact": result.ok and (anchored is None or anchored.ok),
|
|
287
|
+
"checked": result.checked,
|
|
288
|
+
"chains": result.chains,
|
|
289
|
+
"last_seq": result.last_seq,
|
|
290
|
+
"first_broken": broken,
|
|
291
|
+
}
|
|
292
|
+
if anchored is not None:
|
|
293
|
+
report["checkpoints"] = {
|
|
294
|
+
"checked": anchored.checked,
|
|
295
|
+
"newest_at": anchored.newest.isoformat() if anchored.newest else None,
|
|
296
|
+
"newest_age_s": anchored.age_s,
|
|
297
|
+
"problems": anchored.problems,
|
|
298
|
+
"invalid": anchored.invalid,
|
|
299
|
+
"findings": [
|
|
300
|
+
{
|
|
301
|
+
"checkpoint_at": f.checkpoint_at.isoformat(),
|
|
302
|
+
"domain_id": f.domain_id,
|
|
303
|
+
"seq": f.seq,
|
|
304
|
+
"problem": f.problem,
|
|
305
|
+
}
|
|
306
|
+
for f in anchored.findings
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
return report
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class Anchoring:
|
|
314
|
+
"""Comparison of the log with checkpoints: how many were compared, the files that are
|
|
315
|
+
not valid checkpoints, and the checkpointed entries the log no longer holds as they were."""
|
|
316
|
+
|
|
317
|
+
checked: int
|
|
318
|
+
invalid: list[str]
|
|
319
|
+
findings: list[CheckpointFinding]
|
|
320
|
+
newest: datetime | None = None
|
|
321
|
+
max_age_s: float | None = None
|
|
322
|
+
now: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def age_s(self) -> float | None:
|
|
326
|
+
"""Age of the newest checkpoint compared."""
|
|
327
|
+
return None if self.newest is None else (self.now - self.newest).total_seconds()
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def problems(self) -> list[str]:
|
|
331
|
+
"""Why the comparison does not vouch for the log, besides the findings."""
|
|
332
|
+
found = [f"invalid checkpoint {problem}" for problem in self.invalid]
|
|
333
|
+
if self.checked == 0:
|
|
334
|
+
found.append("no valid checkpoint of this stream was found")
|
|
335
|
+
age = self.age_s
|
|
336
|
+
if self.max_age_s is not None and age is not None and age > self.max_age_s:
|
|
337
|
+
found.append(f"the newest checkpoint is {age:.0f} s old (--max-age {self.max_age_s:g})")
|
|
338
|
+
return found
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def ok(self) -> bool:
|
|
342
|
+
return not self.problems and not self.findings
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _checkpoint_files(paths: Sequence[Path]) -> list[Path]:
|
|
346
|
+
files: list[Path] = []
|
|
347
|
+
for path in paths:
|
|
348
|
+
files.extend(sorted(path.rglob("*.json")) if path.is_dir() else [path])
|
|
349
|
+
return files
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _load_checkpoints(
|
|
353
|
+
paths: Sequence[Path], key_file: Path, stream: AuditStream
|
|
354
|
+
) -> tuple[list[Checkpoint], list[str]]:
|
|
355
|
+
"""Valid checkpoints of `stream` and the files that are not valid checkpoints."""
|
|
356
|
+
try:
|
|
357
|
+
key = load_public_key(key_file.read_text(encoding="utf-8"))
|
|
358
|
+
except (OSError, CheckpointError) as error:
|
|
359
|
+
raise BootstrapError(f"--checkpoint-key: {error}") from None
|
|
360
|
+
found: list[Checkpoint] = []
|
|
361
|
+
invalid: list[str] = []
|
|
362
|
+
for file in _checkpoint_files(paths):
|
|
363
|
+
try:
|
|
364
|
+
checkpoint = parse_document(file.read_bytes(), key)
|
|
365
|
+
except (OSError, CheckpointError) as error:
|
|
366
|
+
invalid.append(f"{file}: {error}")
|
|
367
|
+
continue
|
|
368
|
+
if checkpoint.stream is stream:
|
|
369
|
+
found.append(checkpoint)
|
|
370
|
+
return found, invalid
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _verify_audit(settings: Settings, args: argparse.Namespace) -> None:
|
|
374
|
+
if args.after_seq < 0:
|
|
375
|
+
raise BootstrapError("--after-seq must not be negative")
|
|
376
|
+
if args.checkpoints and args.checkpoint_key is None:
|
|
377
|
+
raise BootstrapError("--checkpoints needs --checkpoint-key")
|
|
378
|
+
chain = _chain_of(args)
|
|
379
|
+
stream = AuditStream.READS if args.reads else AuditStream.ACTIONS
|
|
380
|
+
checkpoints, invalid = (
|
|
381
|
+
_load_checkpoints(args.checkpoints, args.checkpoint_key, stream)
|
|
382
|
+
if args.checkpoints
|
|
383
|
+
else ([], [])
|
|
384
|
+
)
|
|
385
|
+
anchored: Anchoring | None = None
|
|
386
|
+
store, dispose = _store(settings)
|
|
387
|
+
try:
|
|
388
|
+
async with store.unit() as unit:
|
|
389
|
+
repository = unit.read_audit if args.reads else unit.audit
|
|
390
|
+
result = await repository.verify(after_seq=args.after_seq, domain_id=chain)
|
|
391
|
+
if args.checkpoints:
|
|
392
|
+
domains = None if isinstance(chain, Chains) else [chain]
|
|
393
|
+
findings = await compare(repository, checkpoints, domain_ids=domains)
|
|
394
|
+
newest = max((c.at for c in checkpoints), default=None)
|
|
395
|
+
anchored = Anchoring(len(checkpoints), invalid, findings, newest, args.max_age)
|
|
396
|
+
finally:
|
|
397
|
+
await dispose()
|
|
398
|
+
if args.json:
|
|
399
|
+
print(json.dumps(verification_report(chain, result, stream, anchored)))
|
|
400
|
+
elif result.ok:
|
|
401
|
+
last = "-" if result.last_seq is None else result.last_seq
|
|
402
|
+
print(
|
|
403
|
+
f"audit log intact: {result.checked} entries in {result.chains} chain(s), "
|
|
404
|
+
f"last seq {last}"
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
where = result.bad_domain_id or "installation"
|
|
408
|
+
print(
|
|
409
|
+
f"audit log broken at seq {result.bad_seq} (chain {where}): {result.reason}; "
|
|
410
|
+
f"{result.checked} entries verified before it"
|
|
411
|
+
)
|
|
412
|
+
if anchored is not None and not args.json:
|
|
413
|
+
age = "-" if anchored.age_s is None else f"{anchored.age_s:.0f} s"
|
|
414
|
+
print(f"checkpoints compared: {anchored.checked}, newest {age} old")
|
|
415
|
+
for problem in anchored.problems:
|
|
416
|
+
print(problem)
|
|
417
|
+
for finding in anchored.findings:
|
|
418
|
+
where = finding.domain_id or "installation"
|
|
419
|
+
print(
|
|
420
|
+
f"entry seq {finding.seq} (chain {where}) of the checkpoint of "
|
|
421
|
+
f"{finding.checkpoint_at.isoformat()} is {finding.problem}"
|
|
422
|
+
)
|
|
423
|
+
if not result.ok or (anchored is not None and not anchored.ok):
|
|
424
|
+
sys.exit(1)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _vault_sources(settings: Settings, args: argparse.Namespace) -> None:
|
|
428
|
+
database = settings.section("database", DatabaseSettings)
|
|
429
|
+
owner = database.maintenance_dsn
|
|
430
|
+
engine = create_engine(
|
|
431
|
+
database, url=None if owner is None else owner.get_secret_value(), pooled=False
|
|
432
|
+
)
|
|
433
|
+
try:
|
|
434
|
+
found = await installation_vault_sources(
|
|
435
|
+
async_sessionmaker(engine, expire_on_commit=False),
|
|
436
|
+
store_settings(settings).writes,
|
|
437
|
+
)
|
|
438
|
+
finally:
|
|
439
|
+
await engine.dispose()
|
|
440
|
+
if args.json:
|
|
441
|
+
print(json.dumps([source.document() for source in found]))
|
|
442
|
+
elif not found:
|
|
443
|
+
print("no Source maps vault Attributes")
|
|
444
|
+
else:
|
|
445
|
+
for source in found:
|
|
446
|
+
schedule = source.schedule or "manual"
|
|
447
|
+
print(
|
|
448
|
+
f"{source.domain} {source.api_name} ({source.source}): {source.kind}."
|
|
449
|
+
f"{', '.join(source.attributes)}; schedule {schedule}; {source.status}"
|
|
450
|
+
)
|
|
451
|
+
if args.check and found:
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if __name__ == "__main__":
|
|
456
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent-facing reads: context packs, Record explanations and the change feed (design 11)."""
|