ledgerchain 0.1.0__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.
- ledgerchain/__init__.py +62 -0
- ledgerchain/__main__.py +5 -0
- ledgerchain/anchor.py +243 -0
- ledgerchain/canonical.py +32 -0
- ledgerchain/checkpoint.py +132 -0
- ledgerchain/cli.py +170 -0
- ledgerchain/immutability.py +126 -0
- ledgerchain/models.py +182 -0
- ledgerchain/py.typed +0 -0
- ledgerchain/verify.py +218 -0
- ledgerchain/writer.py +182 -0
- ledgerchain-0.1.0.dist-info/METADATA +264 -0
- ledgerchain-0.1.0.dist-info/RECORD +16 -0
- ledgerchain-0.1.0.dist-info/WHEEL +4 -0
- ledgerchain-0.1.0.dist-info/entry_points.txt +2 -0
- ledgerchain-0.1.0.dist-info/licenses/LICENSE +21 -0
ledgerchain/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""ledgerchain: a hash-chained, tamper-evident decision ledger on SQLAlchemy.
|
|
2
|
+
|
|
3
|
+
Append events inside your own transaction, verify the whole chain by
|
|
4
|
+
recomputation, checkpoint it with Merkle roots, and anchor those roots to
|
|
5
|
+
object storage as an independent witness.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .anchor import (
|
|
9
|
+
AnchorConfig,
|
|
10
|
+
AnchorResult,
|
|
11
|
+
AnchorVerifyResult,
|
|
12
|
+
anchor_checkpoint,
|
|
13
|
+
anchor_payload,
|
|
14
|
+
retry_failed_anchors,
|
|
15
|
+
verify_anchor,
|
|
16
|
+
)
|
|
17
|
+
from .canonical import CanonicalizationError, canonicalize, sha256_hex
|
|
18
|
+
from .checkpoint import CheckpointResult, build_checkpoint, merkle_root
|
|
19
|
+
from .immutability import create_all, drop_immutability, install_immutability
|
|
20
|
+
from .models import Base, DecisionLedgerEntry, LedgerCheckpoint, LedgerHead, metadata
|
|
21
|
+
from .verify import VerifyResult, verify_chain
|
|
22
|
+
from .writer import (
|
|
23
|
+
CHECKPOINT_EVENT_TYPE,
|
|
24
|
+
GENESIS_EVENT_TYPE,
|
|
25
|
+
GENESIS_PREV_HASH,
|
|
26
|
+
LedgerWriter,
|
|
27
|
+
compute_entry_hash,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AnchorConfig",
|
|
34
|
+
"AnchorResult",
|
|
35
|
+
"AnchorVerifyResult",
|
|
36
|
+
"Base",
|
|
37
|
+
"CHECKPOINT_EVENT_TYPE",
|
|
38
|
+
"CanonicalizationError",
|
|
39
|
+
"CheckpointResult",
|
|
40
|
+
"DecisionLedgerEntry",
|
|
41
|
+
"GENESIS_EVENT_TYPE",
|
|
42
|
+
"GENESIS_PREV_HASH",
|
|
43
|
+
"LedgerCheckpoint",
|
|
44
|
+
"LedgerHead",
|
|
45
|
+
"LedgerWriter",
|
|
46
|
+
"VerifyResult",
|
|
47
|
+
"anchor_checkpoint",
|
|
48
|
+
"anchor_payload",
|
|
49
|
+
"build_checkpoint",
|
|
50
|
+
"canonicalize",
|
|
51
|
+
"compute_entry_hash",
|
|
52
|
+
"create_all",
|
|
53
|
+
"drop_immutability",
|
|
54
|
+
"install_immutability",
|
|
55
|
+
"merkle_root",
|
|
56
|
+
"metadata",
|
|
57
|
+
"retry_failed_anchors",
|
|
58
|
+
"sha256_hex",
|
|
59
|
+
"verify_anchor",
|
|
60
|
+
"verify_chain",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
ledgerchain/__main__.py
ADDED
ledgerchain/anchor.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""External anchoring of checkpoints to S3-compatible object storage.
|
|
2
|
+
|
|
3
|
+
The hash chain is already tamper-evident. Anchoring defeats a different
|
|
4
|
+
threat: a fully privileged database administrator rewriting the whole
|
|
5
|
+
chain and its checkpoints consistently. A checkpoint's Merkle root written
|
|
6
|
+
to a bucket under Object Lock is an independent witness that the live
|
|
7
|
+
database cannot alter.
|
|
8
|
+
|
|
9
|
+
Anchoring is eventually consistent by design. A storage outage marks the
|
|
10
|
+
checkpoint ``failed`` for retry and never blocks or fails the job that
|
|
11
|
+
built it.
|
|
12
|
+
|
|
13
|
+
Configuration comes from :class:`AnchorConfig`, either built explicitly or
|
|
14
|
+
read from the environment:
|
|
15
|
+
|
|
16
|
+
``LEDGER_ANCHOR_BUCKET``
|
|
17
|
+
Target bucket. Anchoring is skipped, not failed, when unset.
|
|
18
|
+
``LEDGER_ANCHOR_PREFIX``
|
|
19
|
+
Key prefix. Default ``ledger-checkpoints/``.
|
|
20
|
+
``LEDGER_ANCHOR_ENDPOINT_URL``
|
|
21
|
+
S3-compatible endpoint (MinIO and friends). Unset uses AWS defaults.
|
|
22
|
+
``LEDGER_ANCHOR_OBJECT_LOCK_MODE``
|
|
23
|
+
``COMPLIANCE`` (default) or ``GOVERNANCE``. An empty string disables
|
|
24
|
+
Object Lock, for buckets that do not have it enabled.
|
|
25
|
+
|
|
26
|
+
The S3 client is injectable. Anything with ``put_object(**kwargs)`` and
|
|
27
|
+
``get_object(Bucket=..., Key=...)`` works, which keeps the test suite free
|
|
28
|
+
of network access and credentials. ``boto3`` is only imported when no
|
|
29
|
+
client is supplied (install the ``s3`` extra).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
from collections.abc import Mapping
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from typing import Any, Protocol
|
|
41
|
+
|
|
42
|
+
from sqlalchemy import select
|
|
43
|
+
from sqlalchemy.orm import Session
|
|
44
|
+
|
|
45
|
+
from .models import LedgerCheckpoint
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
DEFAULT_PREFIX = "ledger-checkpoints/"
|
|
50
|
+
DEFAULT_RETAIN_UNTIL = datetime(2126, 1, 1, tzinfo=timezone.utc)
|
|
51
|
+
"""A fixed far-future retain-until date approximates "keep forever" without
|
|
52
|
+
depending on bucket-default retention being configured."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ObjectStore(Protocol):
|
|
56
|
+
"""The slice of the boto3 S3 client this module uses."""
|
|
57
|
+
|
|
58
|
+
def put_object(self, **kwargs: Any) -> Any: ...
|
|
59
|
+
|
|
60
|
+
def get_object(self, **kwargs: Any) -> Any: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AnchorConfig:
|
|
65
|
+
bucket: str | None
|
|
66
|
+
prefix: str = DEFAULT_PREFIX
|
|
67
|
+
endpoint_url: str | None = None
|
|
68
|
+
object_lock_mode: str | None = "COMPLIANCE"
|
|
69
|
+
retain_until: datetime = DEFAULT_RETAIN_UNTIL
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_env(cls, environ: Mapping[str, str] | None = None) -> AnchorConfig:
|
|
73
|
+
env = os.environ if environ is None else environ
|
|
74
|
+
bucket = env.get("LEDGER_ANCHOR_BUCKET", "").strip() or None
|
|
75
|
+
prefix = env.get("LEDGER_ANCHOR_PREFIX", DEFAULT_PREFIX)
|
|
76
|
+
endpoint = env.get("LEDGER_ANCHOR_ENDPOINT_URL", "").strip() or None
|
|
77
|
+
lock_mode = env.get("LEDGER_ANCHOR_OBJECT_LOCK_MODE", "COMPLIANCE").strip().upper() or None
|
|
78
|
+
return cls(bucket=bucket, prefix=prefix, endpoint_url=endpoint, object_lock_mode=lock_mode)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class AnchorResult:
|
|
83
|
+
ok: bool
|
|
84
|
+
anchor_uri: str | None = None
|
|
85
|
+
error: str | None = None
|
|
86
|
+
skipped: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class AnchorVerifyResult:
|
|
91
|
+
"""Outcome of :func:`verify_anchor`.
|
|
92
|
+
|
|
93
|
+
``ok`` means the anchored copy was read and matches the checkpoint row.
|
|
94
|
+
``matched=False`` with ``ok=False`` means it was read and disagrees,
|
|
95
|
+
which is the signal that the database copy was rewritten after the
|
|
96
|
+
anchor was taken. ``error`` is set when the copy could not be read.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
ok: bool
|
|
100
|
+
matched: bool | None = None
|
|
101
|
+
anchored: dict[str, Any] | None = None
|
|
102
|
+
error: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def anchor_payload(checkpoint: LedgerCheckpoint) -> dict[str, Any]:
|
|
106
|
+
"""``{from_seq, to_seq, merkle_root, ts, prev_checkpoint_root}``."""
|
|
107
|
+
return {
|
|
108
|
+
"from_seq": checkpoint.from_seq,
|
|
109
|
+
"to_seq": checkpoint.to_seq,
|
|
110
|
+
"merkle_root": checkpoint.merkle_root,
|
|
111
|
+
"ts": checkpoint.ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
112
|
+
"prev_checkpoint_root": checkpoint.prev_checkpoint_root,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def anchor_key(config: AnchorConfig, checkpoint: LedgerCheckpoint) -> str:
|
|
117
|
+
return f"{config.prefix}{checkpoint.from_seq:020d}-{checkpoint.to_seq:020d}.json"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_anchor_uri(uri: str) -> tuple[str, str]:
|
|
121
|
+
"""Split ``s3://bucket/key`` into ``(bucket, key)``."""
|
|
122
|
+
if not uri.startswith("s3://"):
|
|
123
|
+
raise ValueError(f"not an s3:// URI: {uri!r}")
|
|
124
|
+
rest = uri[len("s3://") :]
|
|
125
|
+
bucket, sep, key = rest.partition("/")
|
|
126
|
+
if not bucket or not sep or not key:
|
|
127
|
+
raise ValueError(f"malformed s3:// URI: {uri!r}")
|
|
128
|
+
return bucket, key
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_client(config: AnchorConfig) -> ObjectStore:
|
|
132
|
+
"""A boto3 S3 client for ``config``. Requires the ``s3`` extra."""
|
|
133
|
+
import boto3 # imported lazily so the base install has no AWS dependency
|
|
134
|
+
|
|
135
|
+
client: ObjectStore = boto3.client("s3", endpoint_url=config.endpoint_url)
|
|
136
|
+
return client
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def anchor_checkpoint(
|
|
140
|
+
session: Session,
|
|
141
|
+
checkpoint: LedgerCheckpoint,
|
|
142
|
+
*,
|
|
143
|
+
config: AnchorConfig | None = None,
|
|
144
|
+
client: ObjectStore | None = None,
|
|
145
|
+
) -> AnchorResult:
|
|
146
|
+
"""Write ``checkpoint``'s anchor payload to the configured bucket.
|
|
147
|
+
|
|
148
|
+
Updates ``anchor_status`` and ``anchor_uri`` in place and commits.
|
|
149
|
+
Never raises: any failure (bucket unset, credentials missing, network
|
|
150
|
+
error, Object Lock rejected) is logged and recorded as
|
|
151
|
+
``anchor_status='failed'`` for a later retry.
|
|
152
|
+
"""
|
|
153
|
+
cfg = config if config is not None else AnchorConfig.from_env()
|
|
154
|
+
if cfg.bucket is None:
|
|
155
|
+
return AnchorResult(ok=False, skipped=True, error="LEDGER_ANCHOR_BUCKET not configured")
|
|
156
|
+
|
|
157
|
+
key = anchor_key(cfg, checkpoint)
|
|
158
|
+
body = json.dumps(anchor_payload(checkpoint), sort_keys=True).encode("utf-8")
|
|
159
|
+
put_kwargs: dict[str, Any] = {
|
|
160
|
+
"Bucket": cfg.bucket,
|
|
161
|
+
"Key": key,
|
|
162
|
+
"Body": body,
|
|
163
|
+
"ContentType": "application/json",
|
|
164
|
+
}
|
|
165
|
+
if cfg.object_lock_mode:
|
|
166
|
+
put_kwargs["ObjectLockMode"] = cfg.object_lock_mode
|
|
167
|
+
put_kwargs["ObjectLockRetainUntilDate"] = cfg.retain_until
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
store = client if client is not None else default_client(cfg)
|
|
171
|
+
store.put_object(**put_kwargs)
|
|
172
|
+
anchor_uri = f"s3://{cfg.bucket}/{key}"
|
|
173
|
+
checkpoint.anchor_uri = anchor_uri
|
|
174
|
+
checkpoint.anchor_status = "anchored"
|
|
175
|
+
session.commit()
|
|
176
|
+
return AnchorResult(ok=True, anchor_uri=anchor_uri)
|
|
177
|
+
except Exception as exc: # noqa: BLE001 - anchoring is best-effort and retryable
|
|
178
|
+
logger.error("checkpoint %s anchor failed: %s: %s", checkpoint.id, type(exc).__name__, exc)
|
|
179
|
+
try:
|
|
180
|
+
checkpoint.anchor_status = "failed"
|
|
181
|
+
session.commit()
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
session.rollback()
|
|
184
|
+
return AnchorResult(ok=False, error=f"{type(exc).__name__}: {exc}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def retry_failed_anchors(
|
|
188
|
+
session: Session,
|
|
189
|
+
*,
|
|
190
|
+
limit: int = 20,
|
|
191
|
+
config: AnchorConfig | None = None,
|
|
192
|
+
client: ObjectStore | None = None,
|
|
193
|
+
) -> list[AnchorResult]:
|
|
194
|
+
"""Retry every ``pending`` or ``failed`` checkpoint, oldest first.
|
|
195
|
+
|
|
196
|
+
Run this from a periodic job so a transient storage outage self-heals
|
|
197
|
+
without operator intervention.
|
|
198
|
+
"""
|
|
199
|
+
rows = (
|
|
200
|
+
session.execute(
|
|
201
|
+
select(LedgerCheckpoint)
|
|
202
|
+
.where(LedgerCheckpoint.anchor_status.in_(["pending", "failed"]))
|
|
203
|
+
.order_by(LedgerCheckpoint.id.asc())
|
|
204
|
+
.limit(limit)
|
|
205
|
+
)
|
|
206
|
+
.scalars()
|
|
207
|
+
.all()
|
|
208
|
+
)
|
|
209
|
+
return [anchor_checkpoint(session, row, config=config, client=client) for row in rows]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def verify_anchor(
|
|
213
|
+
checkpoint: LedgerCheckpoint,
|
|
214
|
+
*,
|
|
215
|
+
config: AnchorConfig | None = None,
|
|
216
|
+
client: ObjectStore | None = None,
|
|
217
|
+
) -> AnchorVerifyResult:
|
|
218
|
+
"""Read the anchored copy of ``checkpoint`` and compare it to the row.
|
|
219
|
+
|
|
220
|
+
This is the independent-witness check: the object in the bucket was
|
|
221
|
+
written when the checkpoint was taken and cannot be changed under
|
|
222
|
+
Object Lock, so a mismatch proves the database row was rewritten after
|
|
223
|
+
anchoring. Never raises.
|
|
224
|
+
"""
|
|
225
|
+
if checkpoint.anchor_uri is None:
|
|
226
|
+
return AnchorVerifyResult(ok=False, error="checkpoint has no anchor_uri")
|
|
227
|
+
cfg = config if config is not None else AnchorConfig.from_env()
|
|
228
|
+
try:
|
|
229
|
+
bucket, key = parse_anchor_uri(checkpoint.anchor_uri)
|
|
230
|
+
store = client if client is not None else default_client(cfg)
|
|
231
|
+
response = store.get_object(Bucket=bucket, Key=key)
|
|
232
|
+
raw = response["Body"].read()
|
|
233
|
+
anchored = json.loads(raw)
|
|
234
|
+
except Exception as exc: # noqa: BLE001 - report, never raise
|
|
235
|
+
return AnchorVerifyResult(ok=False, error=f"{type(exc).__name__}: {exc}")
|
|
236
|
+
|
|
237
|
+
matched = (
|
|
238
|
+
anchored.get("from_seq") == checkpoint.from_seq
|
|
239
|
+
and anchored.get("to_seq") == checkpoint.to_seq
|
|
240
|
+
and anchored.get("merkle_root") == checkpoint.merkle_root
|
|
241
|
+
and anchored.get("prev_checkpoint_root") == checkpoint.prev_checkpoint_root
|
|
242
|
+
)
|
|
243
|
+
return AnchorVerifyResult(ok=matched, matched=matched, anchored=anchored)
|
ledgerchain/canonical.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""RFC 8785 (JSON Canonicalization Scheme) helpers.
|
|
2
|
+
|
|
3
|
+
Every hash in this package is computed over the canonical byte form of a
|
|
4
|
+
JSON value: ``payload_sha256`` for ledger entries, and anything a caller
|
|
5
|
+
chooses to sign before appending. Two dicts with the same content but a
|
|
6
|
+
different key order, or the same number written two ways, hash the same.
|
|
7
|
+
|
|
8
|
+
Canonicalization is delegated to the ``rfc8785`` package rather than
|
|
9
|
+
hand-rolled. ECMAScript shortest-round-trip number formatting and UTF-16
|
|
10
|
+
code-unit key ordering are easy to get subtly wrong, and every hash in the
|
|
11
|
+
chain depends on them being exact.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import rfc8785
|
|
20
|
+
|
|
21
|
+
CanonicalizationError = rfc8785.CanonicalizationError
|
|
22
|
+
"""Raised when a value cannot be canonicalized (for example, a ``set``)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def canonicalize(value: Any) -> bytes:
|
|
26
|
+
"""Serialize ``value`` to canonical JSON bytes per RFC 8785."""
|
|
27
|
+
return rfc8785.dumps(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256_hex(value: Any) -> str:
|
|
31
|
+
"""SHA-256 of the canonical JSON serialization, as lowercase hex."""
|
|
32
|
+
return hashlib.sha256(canonicalize(value)).hexdigest()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Merkle checkpoints over the ledger.
|
|
2
|
+
|
|
3
|
+
A checkpoint summarizes a contiguous ``seq`` range with one Merkle root and
|
|
4
|
+
chains to the previous checkpoint's root via ``prev_checkpoint_root``. The
|
|
5
|
+
checkpoint is also appended to the ledger as a ``checkpoint`` entry, so the
|
|
6
|
+
chain carries its own checkpoint history. External anchoring is a separate
|
|
7
|
+
step (:mod:`ledgerchain.anchor`) so a storage outage never blocks
|
|
8
|
+
checkpoint creation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from sqlalchemy.orm import Session
|
|
20
|
+
|
|
21
|
+
from .models import DecisionLedgerEntry, LedgerCheckpoint, LedgerHead
|
|
22
|
+
from .writer import CHECKPOINT_EVENT_TYPE, LedgerWriter
|
|
23
|
+
|
|
24
|
+
CHECKPOINT_ACTOR: dict[str, Any] = {"kind": "system", "sub": "ledger-checkpoint"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def merkle_root(leaf_hexdigests: Sequence[str]) -> str:
|
|
28
|
+
"""Binary Merkle root over hex SHA-256 leaves, returned as hex.
|
|
29
|
+
|
|
30
|
+
Pairs are hashed as ``sha256(left || right)`` over the raw 32-byte
|
|
31
|
+
digests. An odd node at any level is promoted unchanged rather than
|
|
32
|
+
duplicated, which avoids the known second-preimage weakness of the
|
|
33
|
+
duplicate-last-node variant. Raises ``ValueError`` on an empty input.
|
|
34
|
+
"""
|
|
35
|
+
if not leaf_hexdigests:
|
|
36
|
+
raise ValueError("merkle_root requires at least one leaf")
|
|
37
|
+
|
|
38
|
+
level = [bytes.fromhex(h) for h in leaf_hexdigests]
|
|
39
|
+
while len(level) > 1:
|
|
40
|
+
next_level = [
|
|
41
|
+
hashlib.sha256(level[i] + level[i + 1]).digest() for i in range(0, len(level) - 1, 2)
|
|
42
|
+
]
|
|
43
|
+
if len(level) % 2 == 1:
|
|
44
|
+
next_level.append(level[-1])
|
|
45
|
+
level = next_level
|
|
46
|
+
return level[0].hex()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CheckpointResult:
|
|
51
|
+
checkpoint: LedgerCheckpoint | None
|
|
52
|
+
skipped_reason: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_checkpoint(
|
|
56
|
+
session: Session,
|
|
57
|
+
*,
|
|
58
|
+
tenant_id: str = "default",
|
|
59
|
+
commit: bool = True,
|
|
60
|
+
) -> CheckpointResult:
|
|
61
|
+
"""Build one checkpoint over every entry since the last checkpoint.
|
|
62
|
+
|
|
63
|
+
The range is ``(last_checkpoint.to_seq, head.last_seq]``, or
|
|
64
|
+
``[1, head.last_seq]`` when no checkpoint exists yet. Writes the
|
|
65
|
+
``LedgerCheckpoint`` row and appends a ``checkpoint`` ledger entry
|
|
66
|
+
carrying the same ``{from_seq, to_seq, merkle_root,
|
|
67
|
+
prev_checkpoint_root}``. With ``commit=True`` (the default) both land
|
|
68
|
+
in one commit; pass ``commit=False`` to fold them into a larger
|
|
69
|
+
transaction you control.
|
|
70
|
+
|
|
71
|
+
Returns ``skipped_reason`` and no checkpoint when there is nothing new
|
|
72
|
+
to cover: an empty ledger, or a head that has not advanced past the
|
|
73
|
+
last checkpoint.
|
|
74
|
+
"""
|
|
75
|
+
head = session.execute(select(LedgerHead)).scalar_one_or_none()
|
|
76
|
+
if head is None:
|
|
77
|
+
return CheckpointResult(checkpoint=None, skipped_reason="ledger is empty (no genesis yet)")
|
|
78
|
+
|
|
79
|
+
last_checkpoint = (
|
|
80
|
+
session.execute(select(LedgerCheckpoint).order_by(LedgerCheckpoint.to_seq.desc()))
|
|
81
|
+
.scalars()
|
|
82
|
+
.first()
|
|
83
|
+
)
|
|
84
|
+
from_seq = 1 if last_checkpoint is None else last_checkpoint.to_seq + 1
|
|
85
|
+
to_seq = head.last_seq
|
|
86
|
+
|
|
87
|
+
if from_seq > to_seq:
|
|
88
|
+
covered = last_checkpoint.to_seq if last_checkpoint is not None else 0
|
|
89
|
+
return CheckpointResult(
|
|
90
|
+
checkpoint=None,
|
|
91
|
+
skipped_reason=f"nothing new since last checkpoint (to_seq={covered})",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
hashes = (
|
|
95
|
+
session.execute(
|
|
96
|
+
select(DecisionLedgerEntry.entry_hash)
|
|
97
|
+
.where(DecisionLedgerEntry.seq >= from_seq, DecisionLedgerEntry.seq <= to_seq)
|
|
98
|
+
.order_by(DecisionLedgerEntry.seq.asc())
|
|
99
|
+
)
|
|
100
|
+
.scalars()
|
|
101
|
+
.all()
|
|
102
|
+
)
|
|
103
|
+
root = merkle_root(hashes)
|
|
104
|
+
prev_root = last_checkpoint.merkle_root if last_checkpoint is not None else None
|
|
105
|
+
|
|
106
|
+
checkpoint = LedgerCheckpoint(
|
|
107
|
+
from_seq=from_seq,
|
|
108
|
+
to_seq=to_seq,
|
|
109
|
+
merkle_root=root,
|
|
110
|
+
prev_checkpoint_root=prev_root,
|
|
111
|
+
anchor_status="pending",
|
|
112
|
+
)
|
|
113
|
+
session.add(checkpoint)
|
|
114
|
+
session.flush()
|
|
115
|
+
|
|
116
|
+
LedgerWriter.append(
|
|
117
|
+
session,
|
|
118
|
+
event_type=CHECKPOINT_EVENT_TYPE,
|
|
119
|
+
payload={
|
|
120
|
+
"checkpoint_id": checkpoint.id,
|
|
121
|
+
"from_seq": from_seq,
|
|
122
|
+
"to_seq": to_seq,
|
|
123
|
+
"merkle_root": root,
|
|
124
|
+
"prev_checkpoint_root": prev_root,
|
|
125
|
+
},
|
|
126
|
+
actor=CHECKPOINT_ACTOR,
|
|
127
|
+
tenant_id=tenant_id,
|
|
128
|
+
subject_uri=f"ledger://checkpoints/{checkpoint.id}",
|
|
129
|
+
)
|
|
130
|
+
if commit:
|
|
131
|
+
session.commit()
|
|
132
|
+
return CheckpointResult(checkpoint=checkpoint)
|
ledgerchain/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""``ledgerchain`` command line.
|
|
2
|
+
|
|
3
|
+
Built for auditors and operators, not for the application: every
|
|
4
|
+
subcommand opens its own engine from ``--database-url`` (or
|
|
5
|
+
``LEDGER_DATABASE_URL``, then ``DATABASE_URL``), does one thing, prints
|
|
6
|
+
JSON, and exits non-zero on a failed verification so it composes in
|
|
7
|
+
scripts and CI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import create_engine
|
|
20
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
21
|
+
|
|
22
|
+
from .anchor import AnchorConfig, anchor_checkpoint, retry_failed_anchors, verify_anchor
|
|
23
|
+
from .checkpoint import build_checkpoint
|
|
24
|
+
from .immutability import create_all
|
|
25
|
+
from .models import LedgerCheckpoint
|
|
26
|
+
from .verify import verify_chain
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_database_url(explicit: str | None) -> str:
|
|
30
|
+
url = explicit or os.environ.get("LEDGER_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
|
31
|
+
if not url:
|
|
32
|
+
raise SystemExit(
|
|
33
|
+
"no database URL: pass --database-url or set LEDGER_DATABASE_URL / DATABASE_URL"
|
|
34
|
+
)
|
|
35
|
+
return url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _session(url: str) -> tuple[Any, Session]:
|
|
39
|
+
engine = create_engine(url)
|
|
40
|
+
return engine, sessionmaker(bind=engine)()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
44
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_init_db(args: argparse.Namespace) -> int:
|
|
48
|
+
engine = create_engine(resolve_database_url(args.database_url))
|
|
49
|
+
try:
|
|
50
|
+
create_all(engine, immutable=not args.no_triggers)
|
|
51
|
+
finally:
|
|
52
|
+
engine.dispose()
|
|
53
|
+
_emit({"ok": True, "triggers": not args.no_triggers})
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
58
|
+
engine, session = _session(resolve_database_url(args.database_url))
|
|
59
|
+
try:
|
|
60
|
+
result = verify_chain(session, from_seq=args.from_seq, to_seq=args.to_seq)
|
|
61
|
+
body = result.to_dict()
|
|
62
|
+
anchors_ok = True
|
|
63
|
+
if args.check_anchors:
|
|
64
|
+
anchor_reports = []
|
|
65
|
+
for summary in result.checkpoints:
|
|
66
|
+
if summary["anchor_status"] != "anchored":
|
|
67
|
+
continue
|
|
68
|
+
cp = session.get(LedgerCheckpoint, summary["id"])
|
|
69
|
+
if cp is None:
|
|
70
|
+
continue
|
|
71
|
+
report = verify_anchor(cp)
|
|
72
|
+
anchors_ok = anchors_ok and report.ok
|
|
73
|
+
anchor_reports.append(
|
|
74
|
+
{
|
|
75
|
+
"id": cp.id,
|
|
76
|
+
"anchor_uri": cp.anchor_uri,
|
|
77
|
+
"ok": report.ok,
|
|
78
|
+
"matched": report.matched,
|
|
79
|
+
"error": report.error,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
body["anchors"] = anchor_reports
|
|
83
|
+
body["anchors_valid"] = anchors_ok
|
|
84
|
+
finally:
|
|
85
|
+
session.close()
|
|
86
|
+
engine.dispose()
|
|
87
|
+
_emit(body)
|
|
88
|
+
return 0 if result.valid and anchors_ok else 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_checkpoint(args: argparse.Namespace) -> int:
|
|
92
|
+
engine, session = _session(resolve_database_url(args.database_url))
|
|
93
|
+
try:
|
|
94
|
+
result = build_checkpoint(session, tenant_id=args.tenant_id)
|
|
95
|
+
body: dict[str, Any] = {
|
|
96
|
+
"checkpoint": result.checkpoint.to_dict() if result.checkpoint else None,
|
|
97
|
+
"skipped_reason": result.skipped_reason,
|
|
98
|
+
}
|
|
99
|
+
if args.anchor and result.checkpoint is not None:
|
|
100
|
+
anchored = anchor_checkpoint(session, result.checkpoint, config=AnchorConfig.from_env())
|
|
101
|
+
body["anchor"] = anchored.__dict__
|
|
102
|
+
finally:
|
|
103
|
+
session.close()
|
|
104
|
+
engine.dispose()
|
|
105
|
+
_emit(body)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_anchor(args: argparse.Namespace) -> int:
|
|
110
|
+
engine, session = _session(resolve_database_url(args.database_url))
|
|
111
|
+
try:
|
|
112
|
+
results = retry_failed_anchors(session, limit=args.limit, config=AnchorConfig.from_env())
|
|
113
|
+
finally:
|
|
114
|
+
session.close()
|
|
115
|
+
engine.dispose()
|
|
116
|
+
_emit({"attempted": len(results), "results": [r.__dict__ for r in results]})
|
|
117
|
+
return 0 if all(r.ok or r.skipped for r in results) else 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(
|
|
122
|
+
prog="ledgerchain", description="Tamper-evident decision ledger tools."
|
|
123
|
+
)
|
|
124
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
125
|
+
|
|
126
|
+
def add_db(p: argparse.ArgumentParser) -> None:
|
|
127
|
+
p.add_argument(
|
|
128
|
+
"--database-url",
|
|
129
|
+
default=None,
|
|
130
|
+
help="SQLAlchemy URL. Overrides LEDGER_DATABASE_URL and DATABASE_URL.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
p = sub.add_parser("init-db", help="Create the ledger tables and immutability triggers.")
|
|
134
|
+
add_db(p)
|
|
135
|
+
p.add_argument("--no-triggers", action="store_true", help="Create tables only.")
|
|
136
|
+
p.set_defaults(func=cmd_init_db)
|
|
137
|
+
|
|
138
|
+
p = sub.add_parser("verify", help="Recompute the chain and report the first bad seq.")
|
|
139
|
+
add_db(p)
|
|
140
|
+
p.add_argument("--from-seq", type=int, default=None)
|
|
141
|
+
p.add_argument("--to-seq", type=int, default=None)
|
|
142
|
+
p.add_argument(
|
|
143
|
+
"--check-anchors",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="Also read each anchored checkpoint back from object storage and compare.",
|
|
146
|
+
)
|
|
147
|
+
p.set_defaults(func=cmd_verify)
|
|
148
|
+
|
|
149
|
+
p = sub.add_parser("checkpoint", help="Build a Merkle checkpoint over new entries.")
|
|
150
|
+
add_db(p)
|
|
151
|
+
p.add_argument("--tenant-id", default="default")
|
|
152
|
+
p.add_argument("--anchor", action="store_true", help="Anchor it immediately.")
|
|
153
|
+
p.set_defaults(func=cmd_checkpoint)
|
|
154
|
+
|
|
155
|
+
p = sub.add_parser("anchor", help="Retry pending and failed anchors.")
|
|
156
|
+
add_db(p)
|
|
157
|
+
p.add_argument("--limit", type=int, default=20)
|
|
158
|
+
p.set_defaults(func=cmd_anchor)
|
|
159
|
+
|
|
160
|
+
return parser
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
164
|
+
args = build_parser().parse_args(argv)
|
|
165
|
+
code: int = args.func(args)
|
|
166
|
+
return code
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__": # pragma: no cover
|
|
170
|
+
sys.exit(main())
|