provance 0.1.0__tar.gz
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.
- provance-0.1.0/.gitignore +80 -0
- provance-0.1.0/LICENSE +21 -0
- provance-0.1.0/PKG-INFO +304 -0
- provance-0.1.0/README.md +266 -0
- provance-0.1.0/pyproject.toml +56 -0
- provance-0.1.0/src/provance/__init__.py +103 -0
- provance-0.1.0/src/provance/_writer.py +249 -0
- provance-0.1.0/src/provance/checkpoints.py +101 -0
- provance-0.1.0/src/provance/cli.py +172 -0
- provance-0.1.0/src/provance/errors.py +33 -0
- provance-0.1.0/src/provance/format.py +130 -0
- provance-0.1.0/src/provance/http.py +339 -0
- provance-0.1.0/src/provance/ledger.py +176 -0
- provance-0.1.0/src/provance/models.py +167 -0
- provance-0.1.0/src/provance/options.py +92 -0
- provance-0.1.0/src/provance/payload.py +102 -0
- provance-0.1.0/src/provance/py.typed +0 -0
- provance-0.1.0/src/provance/signing.py +114 -0
- provance-0.1.0/src/provance/stores.py +569 -0
- provance-0.1.0/src/provance/verifier.py +203 -0
- provance-0.1.0/tests/conftest.py +51 -0
- provance-0.1.0/tests/test_cli.py +96 -0
- provance-0.1.0/tests/test_http.py +255 -0
- provance-0.1.0/tests/test_ledger.py +336 -0
- provance-0.1.0/tests/test_sql.py +120 -0
- provance-0.1.0/tests/test_sql_store.py +173 -0
- provance-0.1.0/tests/test_vectors.py +110 -0
- provance-0.1.0/tests/test_verifier.py +161 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Binary files
|
|
2
|
+
|
|
3
|
+
[Bb]in/
|
|
4
|
+
[Oo]bj/
|
|
5
|
+
|
|
6
|
+
VS Code, Rider, and IDE files
|
|
7
|
+
|
|
8
|
+
.vs/
|
|
9
|
+
*.user
|
|
10
|
+
*.suo
|
|
11
|
+
*.sln.docstates
|
|
12
|
+
*.vshost.exe
|
|
13
|
+
*.vshost.exe.config
|
|
14
|
+
*.vshost.exe.manifest
|
|
15
|
+
*.testsettings
|
|
16
|
+
*.trx
|
|
17
|
+
|
|
18
|
+
Visual Studio / Rider cache files
|
|
19
|
+
|
|
20
|
+
.idea/
|
|
21
|
+
.Rider/
|
|
22
|
+
*.log
|
|
23
|
+
*.filter
|
|
24
|
+
*.cache
|
|
25
|
+
*.pdb
|
|
26
|
+
|
|
27
|
+
NuGet packages
|
|
28
|
+
|
|
29
|
+
packages/
|
|
30
|
+
*.nupkg
|
|
31
|
+
|
|
32
|
+
Local NuGet cache
|
|
33
|
+
|
|
34
|
+
%USERPROFILE%.nuget\packages
|
|
35
|
+
|
|
36
|
+
Test results
|
|
37
|
+
|
|
38
|
+
TestResults/
|
|
39
|
+
coverlet_coverage.json
|
|
40
|
+
|
|
41
|
+
Tooling
|
|
42
|
+
|
|
43
|
+
.tool-cache/
|
|
44
|
+
|
|
45
|
+
-----------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
Multi-Language Prep (for Java/Rust/etc.)
|
|
48
|
+
|
|
49
|
+
-----------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
Java/Maven/Gradle build outputs (if we add a Java project)
|
|
52
|
+
|
|
53
|
+
/target/
|
|
54
|
+
|
|
55
|
+
Rust build outputs (if we add a Rust project)
|
|
56
|
+
|
|
57
|
+
/target/
|
|
58
|
+
|
|
59
|
+
Log files and database files
|
|
60
|
+
|
|
61
|
+
*.db
|
|
62
|
+
*.log
|
|
63
|
+
# Local outputs
|
|
64
|
+
TestResults/
|
|
65
|
+
nupkg/
|
|
66
|
+
src/dotnet/ProvanceProtocol/Provance.Example.Api/checkpoints/
|
|
67
|
+
|
|
68
|
+
*.db
|
|
69
|
+
*.db-wal
|
|
70
|
+
*.db-shm
|
|
71
|
+
|
|
72
|
+
# Java build outputs
|
|
73
|
+
src/java/**/target/
|
|
74
|
+
|
|
75
|
+
# Python build outputs
|
|
76
|
+
src/python/dist/
|
|
77
|
+
__pycache__/
|
|
78
|
+
.pytest_cache/
|
|
79
|
+
*.egg-info/
|
|
80
|
+
.venv/
|
provance-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 HakimBuilds
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
provance-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: provance
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tamper-evident audit trail: hash chained, signed ledger in your own database, with FastAPI, Flask and Django integration. Same format as the .NET and Java libraries.
|
|
5
|
+
Project-URL: Homepage, https://github.com/h-ibrahim365/ProvanceProtocol
|
|
6
|
+
Project-URL: Documentation, https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/src/python/README.md
|
|
7
|
+
Project-URL: Specification, https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/docs/SPEC.md
|
|
8
|
+
Project-URL: Changelog, https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/h-ibrahim365/ProvanceProtocol/issues
|
|
10
|
+
Author: Hakim Id Brahim
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: audit,audit-log,audit-trail,compliance,django,fastapi,flask,gdpr,hash-chain,ledger,rfc8785,tamper-evident
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: FastAPI
|
|
17
|
+
Classifier: Framework :: Flask
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Intended Audience :: Information Technology
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: Security :: Cryptography
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: rfc8785>=0.1.4
|
|
29
|
+
Provides-Extra: mysql
|
|
30
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
31
|
+
Provides-Extra: postgres
|
|
32
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
33
|
+
Provides-Extra: sqlserver
|
|
34
|
+
Requires-Dist: pymssql>=2.3; extra == 'sqlserver'
|
|
35
|
+
Provides-Extra: test
|
|
36
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# PROVANCE for Python
|
|
40
|
+
|
|
41
|
+
Python implementation of PROVANCE, the tamper-evident audit trail. It writes the same ledger format as the
|
|
42
|
+
[.NET](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/src/dotnet/README.md) and
|
|
43
|
+
[Java](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/src/java/README.md) libraries
|
|
44
|
+
([format specification](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/docs/SPEC.md)) and passes the
|
|
45
|
+
same test vectors: a ledger written in one language can be verified, and continued, in the others.
|
|
46
|
+
|
|
47
|
+
It also ships the `provance verify` command, so an auditor or a security team can check a ledger written by any of
|
|
48
|
+
the three without installing .NET or Java.
|
|
49
|
+
|
|
50
|
+
Canonical JSON comes from the [`rfc8785`](https://pypi.org/project/rfc8785/) package, an RFC 8785 implementation
|
|
51
|
+
independent of the .NET and Java code.
|
|
52
|
+
|
|
53
|
+
Python 3.10 or later.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install provance # SQLite, in-memory store, JSON Lines exports
|
|
59
|
+
pip install "provance[postgres]" # + PostgreSQL (psycopg)
|
|
60
|
+
pip install "provance[mysql]" # + MySQL, MariaDB (PyMySQL)
|
|
61
|
+
pip install "provance[sqlserver]" # + SQL Server, Azure SQL (pymssql)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Writing entries
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import os
|
|
68
|
+
from provance import AuditedPayload, ProvanceLedger, SqlLedgerStore
|
|
69
|
+
|
|
70
|
+
ledger = ProvanceLedger(
|
|
71
|
+
ledger_id="orders-api-prod",
|
|
72
|
+
secret_key=os.environ["PROVANCE_SECRET_KEY"],
|
|
73
|
+
store=SqlLedgerStore.from_url("postgresql://app@db.internal/audit"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
ledger.add_entry("ORDER_REFUNDED", AuditedPayload(
|
|
77
|
+
actor_id=user_id,
|
|
78
|
+
description="Refund issued",
|
|
79
|
+
data={"orderId": order_id, "amount": "49.90"},
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
ledger.close() # on shutdown: writes the queued entries
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`add_entry` returns once the entry is stored, with its sequence, hash and signature. `await ledger.add_entry_async(...)`
|
|
86
|
+
does the same in asyncio code, and `ledger.submit(...)` returns a `concurrent.futures.Future` without waiting.
|
|
87
|
+
|
|
88
|
+
The ledger refuses to start with a missing ledger id, an HMAC key shorter than 32 bytes or a placeholder, or no
|
|
89
|
+
store. There is no silent fallback to an in-memory store.
|
|
90
|
+
|
|
91
|
+
Create one `ProvanceLedger` per ledger and per process, and close it on shutdown (or use it as a context manager).
|
|
92
|
+
A background thread writes the entries; it does not survive `fork()`, so with gunicorn create the ledger in each
|
|
93
|
+
worker (in the `post_fork` hook, or lazily on first use), not before the fork with `--preload`. A ledger used after
|
|
94
|
+
a fork raises `LedgerUnavailableError` instead of hanging.
|
|
95
|
+
|
|
96
|
+
### Payloads
|
|
97
|
+
|
|
98
|
+
A payload can be a `dict`, a dataclass, a Pydantic model, an `AuditedPayload` or any object with public
|
|
99
|
+
attributes. `None` attributes are omitted, as in .NET and Java. Enums, `datetime`, `UUID`, `Decimal` and `bytes`
|
|
100
|
+
(Base64) are converted; for anything else, add a `__provance_payload__()` method or pass
|
|
101
|
+
`payload_serializer=` to the ledger.
|
|
102
|
+
|
|
103
|
+
Every field of the payload is covered by the hash. A payload that cannot be serialized raises `PayloadError` before
|
|
104
|
+
anything is queued.
|
|
105
|
+
|
|
106
|
+
Numbers are stored as IEEE 754 doubles, as RFC 8785 requires: write money amounts and integers above 2^53 as
|
|
107
|
+
strings.
|
|
108
|
+
|
|
109
|
+
### Settings
|
|
110
|
+
|
|
111
|
+
Settings have the same names (in snake_case) and defaults as in .NET and Java. Pass them as keyword arguments or as
|
|
112
|
+
a `ProvanceOptions` object.
|
|
113
|
+
|
|
114
|
+
| Setting | Default | Meaning |
|
|
115
|
+
| :------ | :------ | :------ |
|
|
116
|
+
| `ledger_id` | required | stable identifier, for example `orders-api-prod` |
|
|
117
|
+
| `secret_key` | required | HMAC secret of the current key, at least 32 bytes |
|
|
118
|
+
| `signing_key_id` | `k1` | id of the current key; change it on every rotation |
|
|
119
|
+
| `retired_keys` | `{}` | old keys (id to secret), to verify entries signed before a rotation |
|
|
120
|
+
| `queue_capacity` | `10000` | entries waiting to be written |
|
|
121
|
+
| `queue_full_behavior` | `WAIT` | `WAIT` blocks the caller, `FAIL_FAST` raises `LedgerOverloadedError` |
|
|
122
|
+
| `max_batch_size` | `256` | entries written in one transaction |
|
|
123
|
+
| `max_append_attempts` | `5` | attempts on a transient store error before callers get `LedgerUnavailableError` |
|
|
124
|
+
| `checkpoint_interval` | 5 minutes | interval of automatic checkpoints, when a sink is configured |
|
|
125
|
+
| `shutdown_timeout` | 30 seconds | how long `close()` waits for the queue to drain |
|
|
126
|
+
|
|
127
|
+
### Key rotation
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
ledger = ProvanceLedger(
|
|
131
|
+
ledger_id="orders-api-prod",
|
|
132
|
+
signing_key_id="k2",
|
|
133
|
+
secret_key=os.environ["PROVANCE_KEY_K2"],
|
|
134
|
+
retired_keys={"k1": os.environ["PROVANCE_KEY_K1"]},
|
|
135
|
+
store=store,
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
New entries are signed with `k2`; entries signed with `k1` still verify. To keep the key out of the application,
|
|
140
|
+
pass `signer=` with an object backed by your KMS: a `key_id` property, `sign(digest)` returning the signature
|
|
141
|
+
bytes, and `verify(key_id, digest, signature)` returning a `SignatureCheck` (see `provance.LedgerSigner`).
|
|
142
|
+
|
|
143
|
+
## One entry per HTTP request
|
|
144
|
+
|
|
145
|
+
`provance.http` records an `HTTP_REQUEST_COMPLETED`, `HTTP_REQUEST_FAILED` or `HTTP_REQUEST_ABORTED` entry after
|
|
146
|
+
each response, with the method, path, status code, duration, trace id and actor. Same payload and privacy defaults
|
|
147
|
+
as the ASP.NET Core middleware and the Spring filter: query strings, client IPs and user agents are off unless you
|
|
148
|
+
enable them, and exception messages are never recorded (only the exception type). `/health`, `/healthz`,
|
|
149
|
+
`/metrics` and `/favicon.ico` are skipped.
|
|
150
|
+
|
|
151
|
+
FastAPI, Starlette and other ASGI applications:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from provance.http import ProvanceAsgiMiddleware
|
|
155
|
+
|
|
156
|
+
app.add_middleware(ProvanceAsgiMiddleware, ledger=ledger)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Flask and other WSGI applications (the entry is written when the server closes the response, so the status code is
|
|
160
|
+
final; with Flask's test client, close the response or use `with client.get(...)`):
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from provance.http import ProvanceWsgiMiddleware
|
|
164
|
+
|
|
165
|
+
app.wsgi_app = ProvanceWsgiMiddleware(app.wsgi_app, ledger)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Django:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# myproject/audit.py
|
|
172
|
+
from provance.http import django_middleware
|
|
173
|
+
audit_middleware = django_middleware(ledger)
|
|
174
|
+
|
|
175
|
+
# settings.py, after AuthenticationMiddleware
|
|
176
|
+
MIDDLEWARE = [..., "myproject.audit.audit_middleware"]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Options go in `HttpAuditOptions`: `include_query_string`, `include_client_ip`, `include_user_agent`,
|
|
180
|
+
`excluded_paths`, `should_audit(method, path)`, `actor_id_resolver`, `audit_timeout` (seconds) and `failure_mode`.
|
|
181
|
+
With the default `LOG_AND_CONTINUE`, a request whose entry cannot be written still gets its response and the error
|
|
182
|
+
is logged; `RAISE` raises after the response (it does not undo what the handler did). Record the business events
|
|
183
|
+
that matter with `add_entry` in the handler itself, where a failure can stop the operation.
|
|
184
|
+
|
|
185
|
+
The actor is the authenticated user: `scope["user"]` in Starlette, `REMOTE_USER` in WSGI, `request.user.pk` in
|
|
186
|
+
Django. Pass `actor_id_resolver` for anything else.
|
|
187
|
+
|
|
188
|
+
## Databases
|
|
189
|
+
|
|
190
|
+
`SqlLedgerStore` uses the same table and columns as the .NET Entity Framework Core store and the Java JDBC store, so
|
|
191
|
+
the three libraries can write to the same ledger.
|
|
192
|
+
|
|
193
|
+
| Database | URL | Tested |
|
|
194
|
+
| :------- | :-- | :----- |
|
|
195
|
+
| SQLite | `sqlite:///audit.db` | Yes |
|
|
196
|
+
| PostgreSQL | `postgresql://user:pass@host/db` | Yes (CI) |
|
|
197
|
+
| MySQL, MariaDB | `mysql://user:pass@host/db` | No, same SQL as the Java store |
|
|
198
|
+
| SQL Server, Azure SQL | `mssql://user:pass@host/db` | No, same SQL as the Java store |
|
|
199
|
+
|
|
200
|
+
`from_url` opens a connection per operation. In production, pass a pooled factory instead, for example SQLAlchemy's:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
store = SqlLedgerStore(engine.raw_connection, "postgresql")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The table is created on first use. If your migrations create it, pass `create_table=False` and take the statements
|
|
207
|
+
from `store.create_table_statements()`. The unique index on `seq` is what keeps several writers consistent: do not
|
|
208
|
+
drop it.
|
|
209
|
+
|
|
210
|
+
`InMemoryLedgerStore` is for tests only.
|
|
211
|
+
|
|
212
|
+
## Several instances
|
|
213
|
+
|
|
214
|
+
Several processes and machines can write to the same ledger. The store rejects a sequence that already exists; the
|
|
215
|
+
instance that loses reloads the head and retries. There is no lock and no leader. Entries are batched under load.
|
|
216
|
+
|
|
217
|
+
When the database fails after writing a batch but before confirming it, the writer reloads the head, recognizes its
|
|
218
|
+
own entries by id and continues the chain without duplicates. Transient failures are retried with backoff
|
|
219
|
+
(`max_append_attempts`); after that, the callers get `LedgerUnavailableError` and the writer keeps serving new
|
|
220
|
+
entries.
|
|
221
|
+
|
|
222
|
+
## Verifying and checkpoints
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
result = ledger.verify()
|
|
226
|
+
print(result.valid, result.failure, result.failed_at_sequence, result.message)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Verification detects a modified field, a deleted, inserted or moved entry, and hashes recomputed without the key.
|
|
230
|
+
Entries removed from the end, and a full rewrite by someone holding the key, are only detected against a checkpoint
|
|
231
|
+
stored where those people cannot write.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
ledger = ProvanceLedger(..., checkpoint_directory="/mnt/immutable/provance") # or checkpoint_sinks=[my_sink]
|
|
235
|
+
|
|
236
|
+
checkpoint = LedgerCheckpoint.from_json(json_from_trusted_storage)
|
|
237
|
+
result = ledger.verify(checkpoint)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Checkpoints are published at `checkpoint_interval` when the head has moved. A sink is any object with a
|
|
241
|
+
`publish(checkpoint)` method. The file sink writes `{directory}/{ledger_id}/{sequence}.json` and never overwrites a
|
|
242
|
+
file, like the .NET and Java file sinks; checkpoint JSON is the same in the three languages.
|
|
243
|
+
|
|
244
|
+
`ledger.redact_payload(entry_id)` removes a payload (for a GDPR erasure request) and the ledger still verifies: only
|
|
245
|
+
the payload hash is chained.
|
|
246
|
+
|
|
247
|
+
## Verifying from the command line
|
|
248
|
+
|
|
249
|
+
For an auditor with read access to the database and the verification key. The HMAC secret is read from an
|
|
250
|
+
environment variable (default `PROVANCE_SECRET_KEY`) or a file, never from the command line, so it does not end up
|
|
251
|
+
in your shell history.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
export PROVANCE_SECRET_KEY="..."
|
|
255
|
+
provance verify --db "postgresql://auditor@db.internal/audit" --ledger-id orders-api-prod \
|
|
256
|
+
--checkpoint /mnt/immutable/provance
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```text
|
|
260
|
+
OK: 18342 entries verified up to sequence 18342.
|
|
261
|
+
Checkpoint at sequence 18300 matched.
|
|
262
|
+
Head: sequence 18342, hash 4f1c...
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
| Option | Meaning |
|
|
266
|
+
| :----- | :------ |
|
|
267
|
+
| `--db URL` | `sqlite:///audit.db` (opened read-only), `postgresql://...`, `mysql://...`, `mssql://...` |
|
|
268
|
+
| `--file PATH` | a JSON Lines export instead of a database |
|
|
269
|
+
| `--table`, `--schema` | ledger table, default `provance_ledger` |
|
|
270
|
+
| `--ledger-id` | the ledger id configured in the application (required) |
|
|
271
|
+
| `--key-id`, `--key-env`, `--key-file` | current key id (default `k1`) and where its secret is |
|
|
272
|
+
| `--retired-key ID=VAR` | a retired key and the environment variable holding its secret (repeatable) |
|
|
273
|
+
| `--checkpoint PATH` | a trusted checkpoint file, or a checkpoint directory (the latest one is used) |
|
|
274
|
+
| `--from-checkpoint` | verify only from the checkpoint onward |
|
|
275
|
+
| `--json` | machine readable output |
|
|
276
|
+
|
|
277
|
+
Exit code: `0` valid, `1` verification failed, `2` usage or connection error, so it can run on a schedule and alert
|
|
278
|
+
on failure. Use a database account that can only read the ledger table.
|
|
279
|
+
|
|
280
|
+
To audit offline, export the table once and verify the file anywhere:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
provance export --db "postgresql://auditor@db.internal/audit" -o ledger.jsonl
|
|
284
|
+
provance verify --file ledger.jsonl --ledger-id orders-api-prod --checkpoint checkpoint.json
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The export has one entry per line, with the field names of the .NET and Java `LedgerEntry`.
|
|
288
|
+
|
|
289
|
+
The same checks are available without a ledger instance, for an audit script:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from provance import HmacVerifier, LedgerVerifier, SqlLedgerReader, connect
|
|
293
|
+
|
|
294
|
+
connection, dialect = connect("postgresql://auditor@db.internal/audit") # read only
|
|
295
|
+
verifier = LedgerVerifier(SqlLedgerReader(connection, dialect),
|
|
296
|
+
HmacVerifier({"k2": current_secret, "k1": retired_secret}),
|
|
297
|
+
ledger_id="orders-api-prod")
|
|
298
|
+
result = verifier.verify(checkpoint=checkpoint)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Status
|
|
302
|
+
|
|
303
|
+
Version 0.1.0, format 1. Same behavior as the .NET and Java 0.1.0 libraries. Tested on Python 3.10 to 3.13, with
|
|
304
|
+
SQLite and PostgreSQL.
|
provance-0.1.0/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# PROVANCE for Python
|
|
2
|
+
|
|
3
|
+
Python implementation of PROVANCE, the tamper-evident audit trail. It writes the same ledger format as the
|
|
4
|
+
[.NET](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/src/dotnet/README.md) and
|
|
5
|
+
[Java](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/src/java/README.md) libraries
|
|
6
|
+
([format specification](https://github.com/h-ibrahim365/ProvanceProtocol/blob/main/docs/SPEC.md)) and passes the
|
|
7
|
+
same test vectors: a ledger written in one language can be verified, and continued, in the others.
|
|
8
|
+
|
|
9
|
+
It also ships the `provance verify` command, so an auditor or a security team can check a ledger written by any of
|
|
10
|
+
the three without installing .NET or Java.
|
|
11
|
+
|
|
12
|
+
Canonical JSON comes from the [`rfc8785`](https://pypi.org/project/rfc8785/) package, an RFC 8785 implementation
|
|
13
|
+
independent of the .NET and Java code.
|
|
14
|
+
|
|
15
|
+
Python 3.10 or later.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install provance # SQLite, in-memory store, JSON Lines exports
|
|
21
|
+
pip install "provance[postgres]" # + PostgreSQL (psycopg)
|
|
22
|
+
pip install "provance[mysql]" # + MySQL, MariaDB (PyMySQL)
|
|
23
|
+
pip install "provance[sqlserver]" # + SQL Server, Azure SQL (pymssql)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Writing entries
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from provance import AuditedPayload, ProvanceLedger, SqlLedgerStore
|
|
31
|
+
|
|
32
|
+
ledger = ProvanceLedger(
|
|
33
|
+
ledger_id="orders-api-prod",
|
|
34
|
+
secret_key=os.environ["PROVANCE_SECRET_KEY"],
|
|
35
|
+
store=SqlLedgerStore.from_url("postgresql://app@db.internal/audit"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
ledger.add_entry("ORDER_REFUNDED", AuditedPayload(
|
|
39
|
+
actor_id=user_id,
|
|
40
|
+
description="Refund issued",
|
|
41
|
+
data={"orderId": order_id, "amount": "49.90"},
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
ledger.close() # on shutdown: writes the queued entries
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`add_entry` returns once the entry is stored, with its sequence, hash and signature. `await ledger.add_entry_async(...)`
|
|
48
|
+
does the same in asyncio code, and `ledger.submit(...)` returns a `concurrent.futures.Future` without waiting.
|
|
49
|
+
|
|
50
|
+
The ledger refuses to start with a missing ledger id, an HMAC key shorter than 32 bytes or a placeholder, or no
|
|
51
|
+
store. There is no silent fallback to an in-memory store.
|
|
52
|
+
|
|
53
|
+
Create one `ProvanceLedger` per ledger and per process, and close it on shutdown (or use it as a context manager).
|
|
54
|
+
A background thread writes the entries; it does not survive `fork()`, so with gunicorn create the ledger in each
|
|
55
|
+
worker (in the `post_fork` hook, or lazily on first use), not before the fork with `--preload`. A ledger used after
|
|
56
|
+
a fork raises `LedgerUnavailableError` instead of hanging.
|
|
57
|
+
|
|
58
|
+
### Payloads
|
|
59
|
+
|
|
60
|
+
A payload can be a `dict`, a dataclass, a Pydantic model, an `AuditedPayload` or any object with public
|
|
61
|
+
attributes. `None` attributes are omitted, as in .NET and Java. Enums, `datetime`, `UUID`, `Decimal` and `bytes`
|
|
62
|
+
(Base64) are converted; for anything else, add a `__provance_payload__()` method or pass
|
|
63
|
+
`payload_serializer=` to the ledger.
|
|
64
|
+
|
|
65
|
+
Every field of the payload is covered by the hash. A payload that cannot be serialized raises `PayloadError` before
|
|
66
|
+
anything is queued.
|
|
67
|
+
|
|
68
|
+
Numbers are stored as IEEE 754 doubles, as RFC 8785 requires: write money amounts and integers above 2^53 as
|
|
69
|
+
strings.
|
|
70
|
+
|
|
71
|
+
### Settings
|
|
72
|
+
|
|
73
|
+
Settings have the same names (in snake_case) and defaults as in .NET and Java. Pass them as keyword arguments or as
|
|
74
|
+
a `ProvanceOptions` object.
|
|
75
|
+
|
|
76
|
+
| Setting | Default | Meaning |
|
|
77
|
+
| :------ | :------ | :------ |
|
|
78
|
+
| `ledger_id` | required | stable identifier, for example `orders-api-prod` |
|
|
79
|
+
| `secret_key` | required | HMAC secret of the current key, at least 32 bytes |
|
|
80
|
+
| `signing_key_id` | `k1` | id of the current key; change it on every rotation |
|
|
81
|
+
| `retired_keys` | `{}` | old keys (id to secret), to verify entries signed before a rotation |
|
|
82
|
+
| `queue_capacity` | `10000` | entries waiting to be written |
|
|
83
|
+
| `queue_full_behavior` | `WAIT` | `WAIT` blocks the caller, `FAIL_FAST` raises `LedgerOverloadedError` |
|
|
84
|
+
| `max_batch_size` | `256` | entries written in one transaction |
|
|
85
|
+
| `max_append_attempts` | `5` | attempts on a transient store error before callers get `LedgerUnavailableError` |
|
|
86
|
+
| `checkpoint_interval` | 5 minutes | interval of automatic checkpoints, when a sink is configured |
|
|
87
|
+
| `shutdown_timeout` | 30 seconds | how long `close()` waits for the queue to drain |
|
|
88
|
+
|
|
89
|
+
### Key rotation
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
ledger = ProvanceLedger(
|
|
93
|
+
ledger_id="orders-api-prod",
|
|
94
|
+
signing_key_id="k2",
|
|
95
|
+
secret_key=os.environ["PROVANCE_KEY_K2"],
|
|
96
|
+
retired_keys={"k1": os.environ["PROVANCE_KEY_K1"]},
|
|
97
|
+
store=store,
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
New entries are signed with `k2`; entries signed with `k1` still verify. To keep the key out of the application,
|
|
102
|
+
pass `signer=` with an object backed by your KMS: a `key_id` property, `sign(digest)` returning the signature
|
|
103
|
+
bytes, and `verify(key_id, digest, signature)` returning a `SignatureCheck` (see `provance.LedgerSigner`).
|
|
104
|
+
|
|
105
|
+
## One entry per HTTP request
|
|
106
|
+
|
|
107
|
+
`provance.http` records an `HTTP_REQUEST_COMPLETED`, `HTTP_REQUEST_FAILED` or `HTTP_REQUEST_ABORTED` entry after
|
|
108
|
+
each response, with the method, path, status code, duration, trace id and actor. Same payload and privacy defaults
|
|
109
|
+
as the ASP.NET Core middleware and the Spring filter: query strings, client IPs and user agents are off unless you
|
|
110
|
+
enable them, and exception messages are never recorded (only the exception type). `/health`, `/healthz`,
|
|
111
|
+
`/metrics` and `/favicon.ico` are skipped.
|
|
112
|
+
|
|
113
|
+
FastAPI, Starlette and other ASGI applications:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from provance.http import ProvanceAsgiMiddleware
|
|
117
|
+
|
|
118
|
+
app.add_middleware(ProvanceAsgiMiddleware, ledger=ledger)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Flask and other WSGI applications (the entry is written when the server closes the response, so the status code is
|
|
122
|
+
final; with Flask's test client, close the response or use `with client.get(...)`):
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from provance.http import ProvanceWsgiMiddleware
|
|
126
|
+
|
|
127
|
+
app.wsgi_app = ProvanceWsgiMiddleware(app.wsgi_app, ledger)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Django:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# myproject/audit.py
|
|
134
|
+
from provance.http import django_middleware
|
|
135
|
+
audit_middleware = django_middleware(ledger)
|
|
136
|
+
|
|
137
|
+
# settings.py, after AuthenticationMiddleware
|
|
138
|
+
MIDDLEWARE = [..., "myproject.audit.audit_middleware"]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Options go in `HttpAuditOptions`: `include_query_string`, `include_client_ip`, `include_user_agent`,
|
|
142
|
+
`excluded_paths`, `should_audit(method, path)`, `actor_id_resolver`, `audit_timeout` (seconds) and `failure_mode`.
|
|
143
|
+
With the default `LOG_AND_CONTINUE`, a request whose entry cannot be written still gets its response and the error
|
|
144
|
+
is logged; `RAISE` raises after the response (it does not undo what the handler did). Record the business events
|
|
145
|
+
that matter with `add_entry` in the handler itself, where a failure can stop the operation.
|
|
146
|
+
|
|
147
|
+
The actor is the authenticated user: `scope["user"]` in Starlette, `REMOTE_USER` in WSGI, `request.user.pk` in
|
|
148
|
+
Django. Pass `actor_id_resolver` for anything else.
|
|
149
|
+
|
|
150
|
+
## Databases
|
|
151
|
+
|
|
152
|
+
`SqlLedgerStore` uses the same table and columns as the .NET Entity Framework Core store and the Java JDBC store, so
|
|
153
|
+
the three libraries can write to the same ledger.
|
|
154
|
+
|
|
155
|
+
| Database | URL | Tested |
|
|
156
|
+
| :------- | :-- | :----- |
|
|
157
|
+
| SQLite | `sqlite:///audit.db` | Yes |
|
|
158
|
+
| PostgreSQL | `postgresql://user:pass@host/db` | Yes (CI) |
|
|
159
|
+
| MySQL, MariaDB | `mysql://user:pass@host/db` | No, same SQL as the Java store |
|
|
160
|
+
| SQL Server, Azure SQL | `mssql://user:pass@host/db` | No, same SQL as the Java store |
|
|
161
|
+
|
|
162
|
+
`from_url` opens a connection per operation. In production, pass a pooled factory instead, for example SQLAlchemy's:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
store = SqlLedgerStore(engine.raw_connection, "postgresql")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The table is created on first use. If your migrations create it, pass `create_table=False` and take the statements
|
|
169
|
+
from `store.create_table_statements()`. The unique index on `seq` is what keeps several writers consistent: do not
|
|
170
|
+
drop it.
|
|
171
|
+
|
|
172
|
+
`InMemoryLedgerStore` is for tests only.
|
|
173
|
+
|
|
174
|
+
## Several instances
|
|
175
|
+
|
|
176
|
+
Several processes and machines can write to the same ledger. The store rejects a sequence that already exists; the
|
|
177
|
+
instance that loses reloads the head and retries. There is no lock and no leader. Entries are batched under load.
|
|
178
|
+
|
|
179
|
+
When the database fails after writing a batch but before confirming it, the writer reloads the head, recognizes its
|
|
180
|
+
own entries by id and continues the chain without duplicates. Transient failures are retried with backoff
|
|
181
|
+
(`max_append_attempts`); after that, the callers get `LedgerUnavailableError` and the writer keeps serving new
|
|
182
|
+
entries.
|
|
183
|
+
|
|
184
|
+
## Verifying and checkpoints
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
result = ledger.verify()
|
|
188
|
+
print(result.valid, result.failure, result.failed_at_sequence, result.message)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Verification detects a modified field, a deleted, inserted or moved entry, and hashes recomputed without the key.
|
|
192
|
+
Entries removed from the end, and a full rewrite by someone holding the key, are only detected against a checkpoint
|
|
193
|
+
stored where those people cannot write.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
ledger = ProvanceLedger(..., checkpoint_directory="/mnt/immutable/provance") # or checkpoint_sinks=[my_sink]
|
|
197
|
+
|
|
198
|
+
checkpoint = LedgerCheckpoint.from_json(json_from_trusted_storage)
|
|
199
|
+
result = ledger.verify(checkpoint)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Checkpoints are published at `checkpoint_interval` when the head has moved. A sink is any object with a
|
|
203
|
+
`publish(checkpoint)` method. The file sink writes `{directory}/{ledger_id}/{sequence}.json` and never overwrites a
|
|
204
|
+
file, like the .NET and Java file sinks; checkpoint JSON is the same in the three languages.
|
|
205
|
+
|
|
206
|
+
`ledger.redact_payload(entry_id)` removes a payload (for a GDPR erasure request) and the ledger still verifies: only
|
|
207
|
+
the payload hash is chained.
|
|
208
|
+
|
|
209
|
+
## Verifying from the command line
|
|
210
|
+
|
|
211
|
+
For an auditor with read access to the database and the verification key. The HMAC secret is read from an
|
|
212
|
+
environment variable (default `PROVANCE_SECRET_KEY`) or a file, never from the command line, so it does not end up
|
|
213
|
+
in your shell history.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
export PROVANCE_SECRET_KEY="..."
|
|
217
|
+
provance verify --db "postgresql://auditor@db.internal/audit" --ledger-id orders-api-prod \
|
|
218
|
+
--checkpoint /mnt/immutable/provance
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
OK: 18342 entries verified up to sequence 18342.
|
|
223
|
+
Checkpoint at sequence 18300 matched.
|
|
224
|
+
Head: sequence 18342, hash 4f1c...
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Option | Meaning |
|
|
228
|
+
| :----- | :------ |
|
|
229
|
+
| `--db URL` | `sqlite:///audit.db` (opened read-only), `postgresql://...`, `mysql://...`, `mssql://...` |
|
|
230
|
+
| `--file PATH` | a JSON Lines export instead of a database |
|
|
231
|
+
| `--table`, `--schema` | ledger table, default `provance_ledger` |
|
|
232
|
+
| `--ledger-id` | the ledger id configured in the application (required) |
|
|
233
|
+
| `--key-id`, `--key-env`, `--key-file` | current key id (default `k1`) and where its secret is |
|
|
234
|
+
| `--retired-key ID=VAR` | a retired key and the environment variable holding its secret (repeatable) |
|
|
235
|
+
| `--checkpoint PATH` | a trusted checkpoint file, or a checkpoint directory (the latest one is used) |
|
|
236
|
+
| `--from-checkpoint` | verify only from the checkpoint onward |
|
|
237
|
+
| `--json` | machine readable output |
|
|
238
|
+
|
|
239
|
+
Exit code: `0` valid, `1` verification failed, `2` usage or connection error, so it can run on a schedule and alert
|
|
240
|
+
on failure. Use a database account that can only read the ledger table.
|
|
241
|
+
|
|
242
|
+
To audit offline, export the table once and verify the file anywhere:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
provance export --db "postgresql://auditor@db.internal/audit" -o ledger.jsonl
|
|
246
|
+
provance verify --file ledger.jsonl --ledger-id orders-api-prod --checkpoint checkpoint.json
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The export has one entry per line, with the field names of the .NET and Java `LedgerEntry`.
|
|
250
|
+
|
|
251
|
+
The same checks are available without a ledger instance, for an audit script:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from provance import HmacVerifier, LedgerVerifier, SqlLedgerReader, connect
|
|
255
|
+
|
|
256
|
+
connection, dialect = connect("postgresql://auditor@db.internal/audit") # read only
|
|
257
|
+
verifier = LedgerVerifier(SqlLedgerReader(connection, dialect),
|
|
258
|
+
HmacVerifier({"k2": current_secret, "k1": retired_secret}),
|
|
259
|
+
ledger_id="orders-api-prod")
|
|
260
|
+
result = verifier.verify(checkpoint=checkpoint)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Status
|
|
264
|
+
|
|
265
|
+
Version 0.1.0, format 1. Same behavior as the .NET and Java 0.1.0 libraries. Tested on Python 3.10 to 3.13, with
|
|
266
|
+
SQLite and PostgreSQL.
|