babelqueue 1.11.0__tar.gz → 1.13.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.
- {babelqueue-1.11.0 → babelqueue-1.13.0}/CHANGELOG.md +56 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/PKG-INFO +103 -1
- {babelqueue-1.11.0 → babelqueue-1.13.0}/README.md +100 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/pyproject.toml +4 -1
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/__init__.py +24 -3
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/exceptions.py +12 -0
- babelqueue-1.13.0/src/babelqueue/gdpr.py +245 -0
- babelqueue-1.13.0/src/babelqueue/outbox.py +360 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/schema.py +59 -1
- babelqueue-1.13.0/tests/test_gdpr.py +328 -0
- babelqueue-1.13.0/tests/test_outbox.py +263 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/.gitignore +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/LICENSE +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_app.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_celery.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_codec.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_django.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_headers.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_otel.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_replay.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_schema.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_sqs_transport.py +0 -0
|
@@ -9,6 +9,62 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.13.0] - 2026-06-21
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Runtime GDPR field encryption** (ADR-0030) — the optional `babelqueue.gdpr` module ports the Go
|
|
16
|
+
reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only
|
|
17
|
+
**declares** and **audits** `x-gdpr-sensitive` fields (and can **mask** them for safe logging);
|
|
18
|
+
this module **enforces** them on the wire — a producer encrypts each marked leaf before publish, a
|
|
19
|
+
consumer decrypts it after decode. Strictly **opt-in**: a producer/consumer that never calls
|
|
20
|
+
`protect`/`unprotect` is unchanged. The **envelope stays frozen** (GR-1): only **values inside
|
|
21
|
+
`data`** change — a sensitive leaf's value becomes a ciphertext **string** — so `data` stays pure
|
|
22
|
+
JSON (an SDK without the key still carries the envelope), `meta.schema_version` stays `1`, and
|
|
23
|
+
`trace_id` is untouched (GR-4). The core stays **stdlib-only** (GR-7): Python's standard library
|
|
24
|
+
has **no AES-GCM**, so the module ships **no** concrete cipher and pulls **no** crypto dependency
|
|
25
|
+
— `Cipher` is a caller-provided `typing.Protocol` (`encrypt(bytes) -> str` / `decrypt(str) ->
|
|
26
|
+
bytes`) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the
|
|
27
|
+
optional `cryptography` library (new `babelqueue[gdpr]` extra, documented in the README — still
|
|
28
|
+
**not** a core dependency). `protect(data, schema, cipher)` canonically JSON-encodes each marked
|
|
29
|
+
leaf and replaces it with the cipher's ciphertext string **in place**; `unprotect(...)` is the
|
|
30
|
+
exact inverse, restoring `data` **byte-for-byte**. The marked paths come from the **same per-URN
|
|
31
|
+
schema** the validator already loads — `babelqueue.schema.sensitive_paths` walks nested objects
|
|
32
|
+
(`profile.full_name`), array items (`addresses[].line`), container, and root marks, and the
|
|
33
|
+
`x-gdpr-sensitive` keyword is **validation-neutral** (annotating a schema is never breaking). An
|
|
34
|
+
absent marked field is skipped (not an error); a re-run `unprotect` on already-cleartext data is a
|
|
35
|
+
no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the
|
|
36
|
+
new `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
37
|
+
handle unreadable PII. Validate cleartext **before** `protect` / **after** `unprotect`, since a
|
|
38
|
+
schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any
|
|
39
|
+
crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across
|
|
40
|
+
nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and
|
|
41
|
+
wrong-key failure. The envelope is unchanged (`schema_version: 1`); this is purely additive.
|
|
42
|
+
|
|
43
|
+
## [1.12.0] - 2026-06-21
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- **Transactional outbox helper** (ADR-0029) — the optional `babelqueue.outbox` module ports the
|
|
47
|
+
PHP `BabelQueue\Outbox` helper to Python, removing the producer **dual write**: the message is
|
|
48
|
+
persisted **into your database, in the same transaction** as the business data (so it commits or
|
|
49
|
+
rolls back atomically with it), and a separate **relay** publishes the durable rows afterwards.
|
|
50
|
+
No distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the wire
|
|
51
|
+
(the consumer dedupes on `meta.id` via the idempotency helper, the consumer-side mirror, ADR-0022).
|
|
52
|
+
The core stays **stdlib-only** (GR-7): `OutboxStore` is an abstract `typing.Protocol` the caller
|
|
53
|
+
binds to their own DB — the module ships only the in-memory `InMemoryOutboxStore` reference and
|
|
54
|
+
pulls in **no** DB driver. `Outbox.write(envelope)` encodes via the frozen `EnvelopeCodec` and
|
|
55
|
+
delegates to `OutboxStore.save` **inside the transaction the caller already opened** — it does not
|
|
56
|
+
begin/commit anything (the caller owns the transaction boundary). `OutboxRelay.flush()` publishes
|
|
57
|
+
one batch through the existing publish-only `Transport`, marking each row published **only after**
|
|
58
|
+
the transport accepts it, or failed (caught → `mark_failed`, row left pending, with a bounded
|
|
59
|
+
linear backoff via an injectable sleeper) so one poison row never blocks the batch;
|
|
60
|
+
`OutboxRelay.drain()` loops while a pass makes progress, with a safety ceiling. The relay
|
|
61
|
+
publishes the **stored bytes verbatim** — it never decodes, rebuilds or re-encodes the envelope —
|
|
62
|
+
so `trace_id` is preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5).
|
|
63
|
+
Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via
|
|
64
|
+
a fake `Transport` + marks published; a raising publish → `mark_failed`, row still pending, batch
|
|
65
|
+
continues; `drain` loops to empty and stops on no-progress; backoff grows linearly and caps via the
|
|
66
|
+
injected sleeper). The envelope is unchanged (`schema_version: 1`); this is purely additive.
|
|
67
|
+
|
|
12
68
|
## [1.11.0] - 2026-06-21
|
|
13
69
|
|
|
14
70
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.13.0
|
|
4
4
|
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
5
|
Project-URL: Homepage, https://babelqueue.com
|
|
6
6
|
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
@@ -38,6 +38,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
|
|
|
38
38
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
39
39
|
Provides-Extra: django
|
|
40
40
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
41
|
+
Provides-Extra: gdpr
|
|
42
|
+
Requires-Dist: cryptography>=42; extra == 'gdpr'
|
|
41
43
|
Provides-Extra: kafka
|
|
42
44
|
Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
|
|
43
45
|
Provides-Extra: otel
|
|
@@ -234,6 +236,47 @@ def on_created(data, meta): ...
|
|
|
234
236
|
python manage.py babelqueue_worker --queue orders # run the consumer
|
|
235
237
|
```
|
|
236
238
|
|
|
239
|
+
## Transactional outbox (optional)
|
|
240
|
+
|
|
241
|
+
The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
|
|
242
|
+
business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
|
|
243
|
+
message is persisted **into your database, in the same transaction** as the business data — so it
|
|
244
|
+
commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
|
|
245
|
+
afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
|
|
246
|
+
on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
|
|
247
|
+
|
|
248
|
+
The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
|
|
249
|
+
(the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
|
|
250
|
+
unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
|
|
251
|
+
rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from babelqueue import BabelQueue, EnvelopeCodec
|
|
255
|
+
from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
|
|
256
|
+
|
|
257
|
+
store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
|
|
258
|
+
outbox = Outbox(store)
|
|
259
|
+
|
|
260
|
+
# write side — YOU own the transaction boundary (this is the whole point):
|
|
261
|
+
with db.transaction(): # your own open transaction
|
|
262
|
+
db.insert_order(order) # the business write
|
|
263
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
|
|
264
|
+
outbox.write(envelope) # same connection, same tx — both, or neither
|
|
265
|
+
|
|
266
|
+
# read/publish side — run on a short interval, after the business tx commits:
|
|
267
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
268
|
+
relay = OutboxRelay(app.transport, store)
|
|
269
|
+
relay.drain() # publish all pending rows; flush() does one batch
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
|
|
273
|
+
A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
|
|
274
|
+
marks a row published only **after** the transport accepts it; a publish that raises is recorded via
|
|
275
|
+
`mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
|
|
276
|
+
poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
|
|
277
|
+
ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
|
|
278
|
+
is the reference for tests and single-process demos (no real transaction).
|
|
279
|
+
|
|
237
280
|
## OpenTelemetry tracing (optional)
|
|
238
281
|
|
|
239
282
|
`pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
|
|
@@ -270,6 +313,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
270
313
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
271
314
|
correlation with no error.
|
|
272
315
|
|
|
316
|
+
## GDPR field encryption (optional)
|
|
317
|
+
|
|
318
|
+
The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
|
|
319
|
+
encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
|
|
320
|
+
**declares** and **audits** sensitivity (and can **mask** for safe logging); this module
|
|
321
|
+
**enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
|
|
322
|
+
decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
|
|
323
|
+
nothing changes.
|
|
324
|
+
|
|
325
|
+
The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
|
|
326
|
+
sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
|
|
327
|
+
the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
|
|
328
|
+
never touched. Which fields are sensitive lives in the **schema**, not the message.
|
|
329
|
+
|
|
330
|
+
Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
|
|
331
|
+
crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
|
|
332
|
+
tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
|
|
333
|
+
(`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
import base64, os
|
|
337
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
338
|
+
|
|
339
|
+
class AesGcmCipher:
|
|
340
|
+
def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
|
|
341
|
+
self._aead = AESGCM(key)
|
|
342
|
+
|
|
343
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
344
|
+
nonce = os.urandom(12)
|
|
345
|
+
return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
|
|
346
|
+
|
|
347
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
348
|
+
raw = base64.b64decode(ciphertext)
|
|
349
|
+
return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
|
|
353
|
+
schema that constrains a sensitive field would reject the ciphertext string:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
from babelqueue import EnvelopeCodec
|
|
357
|
+
from babelqueue.gdpr import protect, unprotect
|
|
358
|
+
|
|
359
|
+
# producer: encrypt marked leaves in place, then encode
|
|
360
|
+
protect(data, schema, cipher)
|
|
361
|
+
body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
|
|
362
|
+
|
|
363
|
+
# consumer: decode, then decrypt marked leaves in place before the handler reads data
|
|
364
|
+
envelope = EnvelopeCodec.decode(body)
|
|
365
|
+
unprotect(envelope["data"], schema, cipher)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
|
|
369
|
+
locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
|
|
370
|
+
root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
|
|
371
|
+
(non-string leaves are left alone); a wrong key / tampered ciphertext raises
|
|
372
|
+
`babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
373
|
+
process unreadable PII. The round-trip restores `data` **byte-for-byte**.
|
|
374
|
+
|
|
273
375
|
## What's here
|
|
274
376
|
|
|
275
377
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -182,6 +182,47 @@ def on_created(data, meta): ...
|
|
|
182
182
|
python manage.py babelqueue_worker --queue orders # run the consumer
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
## Transactional outbox (optional)
|
|
186
|
+
|
|
187
|
+
The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
|
|
188
|
+
business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
|
|
189
|
+
message is persisted **into your database, in the same transaction** as the business data — so it
|
|
190
|
+
commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
|
|
191
|
+
afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
|
|
192
|
+
on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
|
|
193
|
+
|
|
194
|
+
The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
|
|
195
|
+
(the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
|
|
196
|
+
unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
|
|
197
|
+
rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from babelqueue import BabelQueue, EnvelopeCodec
|
|
201
|
+
from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
|
|
202
|
+
|
|
203
|
+
store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
|
|
204
|
+
outbox = Outbox(store)
|
|
205
|
+
|
|
206
|
+
# write side — YOU own the transaction boundary (this is the whole point):
|
|
207
|
+
with db.transaction(): # your own open transaction
|
|
208
|
+
db.insert_order(order) # the business write
|
|
209
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
|
|
210
|
+
outbox.write(envelope) # same connection, same tx — both, or neither
|
|
211
|
+
|
|
212
|
+
# read/publish side — run on a short interval, after the business tx commits:
|
|
213
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
214
|
+
relay = OutboxRelay(app.transport, store)
|
|
215
|
+
relay.drain() # publish all pending rows; flush() does one batch
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
|
|
219
|
+
A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
|
|
220
|
+
marks a row published only **after** the transport accepts it; a publish that raises is recorded via
|
|
221
|
+
`mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
|
|
222
|
+
poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
|
|
223
|
+
ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
|
|
224
|
+
is the reference for tests and single-process demos (no real transaction).
|
|
225
|
+
|
|
185
226
|
## OpenTelemetry tracing (optional)
|
|
186
227
|
|
|
187
228
|
`pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
|
|
@@ -218,6 +259,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
218
259
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
219
260
|
correlation with no error.
|
|
220
261
|
|
|
262
|
+
## GDPR field encryption (optional)
|
|
263
|
+
|
|
264
|
+
The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
|
|
265
|
+
encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
|
|
266
|
+
**declares** and **audits** sensitivity (and can **mask** for safe logging); this module
|
|
267
|
+
**enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
|
|
268
|
+
decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
|
|
269
|
+
nothing changes.
|
|
270
|
+
|
|
271
|
+
The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
|
|
272
|
+
sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
|
|
273
|
+
the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
|
|
274
|
+
never touched. Which fields are sensitive lives in the **schema**, not the message.
|
|
275
|
+
|
|
276
|
+
Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
|
|
277
|
+
crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
|
|
278
|
+
tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
|
|
279
|
+
(`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
import base64, os
|
|
283
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
284
|
+
|
|
285
|
+
class AesGcmCipher:
|
|
286
|
+
def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
|
|
287
|
+
self._aead = AESGCM(key)
|
|
288
|
+
|
|
289
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
290
|
+
nonce = os.urandom(12)
|
|
291
|
+
return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
|
|
292
|
+
|
|
293
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
294
|
+
raw = base64.b64decode(ciphertext)
|
|
295
|
+
return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
|
|
299
|
+
schema that constrains a sensitive field would reject the ciphertext string:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
from babelqueue import EnvelopeCodec
|
|
303
|
+
from babelqueue.gdpr import protect, unprotect
|
|
304
|
+
|
|
305
|
+
# producer: encrypt marked leaves in place, then encode
|
|
306
|
+
protect(data, schema, cipher)
|
|
307
|
+
body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
|
|
308
|
+
|
|
309
|
+
# consumer: decode, then decrypt marked leaves in place before the handler reads data
|
|
310
|
+
envelope = EnvelopeCodec.decode(body)
|
|
311
|
+
unprotect(envelope["data"], schema, cipher)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
|
|
315
|
+
locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
|
|
316
|
+
root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
|
|
317
|
+
(non-string leaves are left alone); a wrong key / tampered ciphertext raises
|
|
318
|
+
`babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
319
|
+
process unreadable PII. The round-trip restores `data` **byte-for-byte**.
|
|
320
|
+
|
|
221
321
|
## What's here
|
|
222
322
|
|
|
223
323
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.13.0"
|
|
8
8
|
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -37,6 +37,9 @@ pulsar = ["pulsar-client>=3.4"]
|
|
|
37
37
|
kafka = ["confluent-kafka>=2.3"]
|
|
38
38
|
artemis = ["python-qpid-proton>=0.39"]
|
|
39
39
|
otel = ["opentelemetry-api>=1.20"]
|
|
40
|
+
# Only for the documented bring-your-own AES-256-GCM reference cipher (ADR-0030). The core never
|
|
41
|
+
# imports it; protect/unprotect take a caller-provided Cipher Protocol, so this is NOT a core dep.
|
|
42
|
+
gdpr = ["cryptography>=42"]
|
|
40
43
|
celery = ["celery>=5"]
|
|
41
44
|
django = ["django>=4.2"]
|
|
42
45
|
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
|
|
@@ -11,18 +11,27 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
from . import dead_letter, headers, idempotency, redrive, replay
|
|
14
|
+
from . import dead_letter, gdpr, headers, idempotency, outbox, redrive, replay
|
|
15
15
|
from .app import BabelQueue
|
|
16
16
|
from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
|
|
17
17
|
from .contracts import HasTraceId, PolyglotMessage
|
|
18
|
+
from .gdpr import Cipher, protect, unprotect
|
|
18
19
|
from .headers import headers_from_context
|
|
19
20
|
from .idempotency import IdempotencyStore, InMemoryStore
|
|
20
|
-
from .
|
|
21
|
+
from .outbox import (
|
|
22
|
+
InMemoryOutboxStore,
|
|
23
|
+
Outbox,
|
|
24
|
+
OutboxRecord,
|
|
25
|
+
OutboxRelay,
|
|
26
|
+
OutboxRelayResult,
|
|
27
|
+
OutboxStore,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
|
|
21
30
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
22
31
|
from .routing import UnknownUrnStrategy
|
|
23
32
|
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
24
33
|
|
|
25
|
-
__version__ = "1.
|
|
34
|
+
__version__ = "1.13.0"
|
|
26
35
|
|
|
27
36
|
__all__ = [
|
|
28
37
|
"BabelQueue",
|
|
@@ -38,9 +47,21 @@ __all__ = [
|
|
|
38
47
|
"HeaderPublisher",
|
|
39
48
|
"BabelQueueError",
|
|
40
49
|
"UnknownUrnError",
|
|
50
|
+
"DecryptError",
|
|
51
|
+
"gdpr",
|
|
52
|
+
"Cipher",
|
|
53
|
+
"protect",
|
|
54
|
+
"unprotect",
|
|
41
55
|
"dead_letter",
|
|
42
56
|
"headers",
|
|
43
57
|
"idempotency",
|
|
58
|
+
"outbox",
|
|
59
|
+
"Outbox",
|
|
60
|
+
"OutboxStore",
|
|
61
|
+
"OutboxRecord",
|
|
62
|
+
"OutboxRelay",
|
|
63
|
+
"OutboxRelayResult",
|
|
64
|
+
"InMemoryOutboxStore",
|
|
44
65
|
"redrive",
|
|
45
66
|
"replay",
|
|
46
67
|
"is_replay",
|
|
@@ -23,3 +23,15 @@ class InvalidPayloadError(BabelQueueError):
|
|
|
23
23
|
super().__init__(f"data for {urn!r} does not match its URN schema: {violation}")
|
|
24
24
|
self.urn = urn
|
|
25
25
|
self.violation = violation
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DecryptError(BabelQueueError):
|
|
29
|
+
"""A protected ``x-gdpr-sensitive`` field could not be restored on the consume side (ADR-0030).
|
|
30
|
+
|
|
31
|
+
Raised by :func:`babelqueue.gdpr.unprotect` when a marked leaf is a ciphertext string the
|
|
32
|
+
:class:`~babelqueue.gdpr.Cipher` cannot open — a wrong key, a tampered/garbled ciphertext, or a
|
|
33
|
+
value whose decrypted bytes are not the JSON the producer encoded. The Python mirror of the Go
|
|
34
|
+
``gdpr.ErrDecrypt``: a missing field is skipped (not an error), but an *unreadable* one stops
|
|
35
|
+
``unprotect`` so the consumer fails the message (retry / dead-letter) rather than handle
|
|
36
|
+
unreadable PII.
|
|
37
|
+
"""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Optional runtime GDPR field encryption (ADR-0030): the SDK-enforcement half.
|
|
2
|
+
|
|
3
|
+
The Python mirror of the Go ``gdpr`` package — the reference the SDKs share. The
|
|
4
|
+
babelqueue-registry only **declares** and **audits** which ``data`` fields are sensitive (the
|
|
5
|
+
``x-gdpr-sensitive`` schema keyword) and can **mask** them for safe logging; this module
|
|
6
|
+
**enforces** that on the wire — a producer encrypts each marked leaf before publish, a consumer
|
|
7
|
+
decrypts it after decode. It is strictly **opt-in**: a producer/consumer that never calls
|
|
8
|
+
:func:`protect`/:func:`unprotect` behaves exactly as before.
|
|
9
|
+
|
|
10
|
+
The contract is deliberately tight so every SDK stays byte-compatible:
|
|
11
|
+
|
|
12
|
+
- **The envelope stays frozen (GR-1).** :func:`protect` mutates only **values inside ``data``**: a
|
|
13
|
+
sensitive leaf's value becomes a ciphertext **string**. It never adds, renames, removes or
|
|
14
|
+
retypes an envelope field; ``meta.schema_version`` stays ``1``; ``trace_id`` is untouched (GR-4).
|
|
15
|
+
``data`` stays **pure JSON** (GR-3) — a JSON string is still pure JSON, so any SDK can carry the
|
|
16
|
+
envelope even without the key (it just can't read the protected fields).
|
|
17
|
+
- **Zero heavy dependencies (GR-7).** Python's standard library has **no AES-GCM**, so the core
|
|
18
|
+
ships **no** concrete cipher and pulls **no** crypto dependency. :class:`Cipher` is a
|
|
19
|
+
caller-provided :class:`typing.Protocol` — bind it to a KMS, Vault transit, an HSM, a
|
|
20
|
+
tokenisation service, or a local AES-GCM built on the optional ``cryptography`` library (see the
|
|
21
|
+
bring-your-own-cipher example below). The crypto dependency lives in the *caller's* code, never
|
|
22
|
+
in this core.
|
|
23
|
+
|
|
24
|
+
The sensitive paths come from the **same per-URN schema** the produce/consume validation path
|
|
25
|
+
already loads (:func:`babelqueue.schema.sensitive_paths`) — the ``x-gdpr-sensitive`` marks ride on
|
|
26
|
+
it. Validate **cleartext**: run :func:`babelqueue.schema.validate` **before** :func:`protect` on
|
|
27
|
+
the producer and **after** :func:`unprotect` on the consumer, because a schema that constrains a
|
|
28
|
+
sensitive field (``minLength``, ``enum``, …) would otherwise reject the ciphertext string.
|
|
29
|
+
|
|
30
|
+
Typical wiring (producer)::
|
|
31
|
+
|
|
32
|
+
from babelqueue import EnvelopeCodec
|
|
33
|
+
from babelqueue.gdpr import protect
|
|
34
|
+
|
|
35
|
+
data = {"order_id": 1042, "email": "alice@example.com"}
|
|
36
|
+
# validate(provider, urn, data) # optional: validate cleartext first
|
|
37
|
+
protect(data, schema, cipher) # encrypt marked leaves IN PLACE
|
|
38
|
+
body = EnvelopeCodec.encode(EnvelopeCodec.make(urn, data)) # ciphertext rides inside data
|
|
39
|
+
|
|
40
|
+
and the inverse on the consumer, after decode and before the handler reads ``data``::
|
|
41
|
+
|
|
42
|
+
from babelqueue.gdpr import unprotect
|
|
43
|
+
|
|
44
|
+
envelope = EnvelopeCodec.decode(body)
|
|
45
|
+
unprotect(envelope["data"], schema, cipher) # decrypt marked leaves IN PLACE
|
|
46
|
+
# validate(provider, urn, envelope["data"]) # optional: validate cleartext after
|
|
47
|
+
|
|
48
|
+
Bring-your-own AES-256-GCM cipher (NOT part of this core — ``pip install cryptography``)::
|
|
49
|
+
|
|
50
|
+
import base64, os
|
|
51
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
52
|
+
|
|
53
|
+
class AesGcmCipher:
|
|
54
|
+
'''A reference Cipher: AES-256-GCM, random 12-byte nonce prepended, base64 out.
|
|
55
|
+
The 32-byte key is the CALLER's; this does no key management or rotation.'''
|
|
56
|
+
|
|
57
|
+
def __init__(self, key: bytes) -> None:
|
|
58
|
+
self._aead = AESGCM(key) # key must be 16, 24, or 32 bytes
|
|
59
|
+
|
|
60
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
61
|
+
nonce = os.urandom(12)
|
|
62
|
+
sealed = self._aead.encrypt(nonce, plaintext, None)
|
|
63
|
+
return base64.b64encode(nonce + sealed).decode("ascii")
|
|
64
|
+
|
|
65
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
66
|
+
raw = base64.b64decode(ciphertext)
|
|
67
|
+
nonce, sealed = raw[:12], raw[12:]
|
|
68
|
+
return self._aead.decrypt(nonce, sealed, None) # raises on wrong key/tamper
|
|
69
|
+
|
|
70
|
+
The same plaintext MAY encrypt to a different string each call (the random nonce is expected and
|
|
71
|
+
good); :meth:`Cipher.decrypt` restores the exact bytes :meth:`Cipher.encrypt` was given, and a
|
|
72
|
+
wrong-key / tampered input MUST raise (GCM authentication) — :func:`unprotect` turns any such raise
|
|
73
|
+
into :class:`~babelqueue.exceptions.DecryptError`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import json
|
|
79
|
+
from typing import Any, Callable, List, MutableMapping, MutableSequence, Optional, Protocol, Tuple, runtime_checkable
|
|
80
|
+
|
|
81
|
+
from .exceptions import DecryptError
|
|
82
|
+
from .schema import sensitive_paths
|
|
83
|
+
|
|
84
|
+
__all__ = ["Cipher", "protect", "unprotect"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@runtime_checkable
|
|
88
|
+
class Cipher(Protocol):
|
|
89
|
+
"""The field-level protection primitive the **caller** provides — a seam onto a KMS, Vault
|
|
90
|
+
transit, an HSM, a tokenisation service, or a local AES-GCM (see the module docstring). Keeping
|
|
91
|
+
this a Protocol is what holds GR-7: this core never imports a crypto library; only the caller's
|
|
92
|
+
concrete cipher does.
|
|
93
|
+
|
|
94
|
+
Contract for an implementation:
|
|
95
|
+
|
|
96
|
+
- :meth:`encrypt` takes the canonical JSON bytes of one field value (see :func:`protect`) and
|
|
97
|
+
returns the ciphertext as a **str** that is valid for placement inside a JSON document (e.g.
|
|
98
|
+
base64). The same plaintext MAY encrypt to a different string each call (a random nonce/IV is
|
|
99
|
+
expected and good).
|
|
100
|
+
- :meth:`decrypt` is the exact inverse: given a string :meth:`encrypt` produced it returns the
|
|
101
|
+
original JSON bytes byte-for-byte. A string it did not produce, or one produced under a
|
|
102
|
+
different key, MUST **raise** rather than return silent garbage, so a wrong-key consume fails
|
|
103
|
+
loudly (:func:`unprotect` wraps the raise in :class:`~babelqueue.exceptions.DecryptError`).
|
|
104
|
+
- Both SHOULD be safe for concurrent use; a producer/consumer may fan one cipher across threads.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
108
|
+
"""Protect one field value (its canonical JSON bytes) → a JSON-safe ciphertext string."""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
112
|
+
"""Reverse :meth:`encrypt`, returning the original field-value JSON bytes."""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def protect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
|
|
117
|
+
"""Encrypt, in place, every value in ``data`` at a path the schema marked ``x-gdpr-sensitive``.
|
|
118
|
+
|
|
119
|
+
The producer-side step — run it after building ``data`` and before encode/publish. Each marked
|
|
120
|
+
leaf's value is canonically JSON-encoded and replaced by ``cipher.encrypt``'s ciphertext
|
|
121
|
+
**string**; the envelope frame, non-sensitive fields, and everything else are untouched (GR-1).
|
|
122
|
+
|
|
123
|
+
A marked path absent from ``data`` is skipped (not an error) — schemas evolve and a message need
|
|
124
|
+
not carry every optional field. ``data`` may be empty (no-op). A ``None`` schema or one with no
|
|
125
|
+
marks is a no-op (nothing is sensitive). A container mark (a whole object/array marked
|
|
126
|
+
sensitive) is supported: the entire sub-value is encoded and encrypted as one ciphertext string.
|
|
127
|
+
|
|
128
|
+
On any cipher error this propagates and leaves ``data`` partially protected; treat a raised
|
|
129
|
+
error as fatal for that message (do not publish it).
|
|
130
|
+
"""
|
|
131
|
+
_walk(data, schema, cipher, _encrypt_leaf)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def unprotect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
|
|
135
|
+
"""Decrypt, in place, every value in ``data`` at an ``x-gdpr-sensitive`` path — the consumer-side
|
|
136
|
+
inverse of :func:`protect`. Run it after decode and before the handler reads ``data``.
|
|
137
|
+
|
|
138
|
+
An absent path is skipped. A leaf that is **not a string** — it was never protected, or this is
|
|
139
|
+
a re-run after a successful :func:`unprotect` — is left as-is, so re-invoking on already-cleartext
|
|
140
|
+
data is safe (idempotent for non-string leaves). A string the cipher cannot open (wrong key,
|
|
141
|
+
tampered, or not a ciphertext) raises :class:`~babelqueue.exceptions.DecryptError` — the consumer
|
|
142
|
+
should fail the message (retry / dead-letter) rather than process unreadable PII.
|
|
143
|
+
"""
|
|
144
|
+
_walk(data, schema, cipher, _decrypt_leaf)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A leaf op transforms one sensitive leaf value (encrypt or decrypt). It returns ``(new_value, ok)``:
|
|
148
|
+
# ``ok=False`` means the value should be left in place untouched.
|
|
149
|
+
_LeafOp = Callable[[Any, Cipher], Tuple[Any, bool]]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _walk(data: Any, schema: Any, cipher: Optional[Cipher], op: _LeafOp) -> None:
|
|
153
|
+
"""Drive ``op`` over every ``x-gdpr-sensitive`` path the schema declares, resolving each path
|
|
154
|
+
against ``data`` itself (NOT by re-walking the schema over the value), so the operation touches
|
|
155
|
+
exactly the declared leaves — non-sensitive siblings are never read or copied."""
|
|
156
|
+
if data is None or schema is None or cipher is None:
|
|
157
|
+
return
|
|
158
|
+
for sp in sensitive_paths(schema):
|
|
159
|
+
_apply_at_path(data, _parse_path(sp.path), cipher, op)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# One step of a sensitive path: ``(key, is_array)``. ``"addresses[].line"`` parses to
|
|
163
|
+
# ``[("addresses", True), ("line", False)]``; the ``[]`` marker means array-descent into every
|
|
164
|
+
# element before the next segment.
|
|
165
|
+
_Segment = Tuple[str, bool]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_path(path: str) -> List[_Segment]:
|
|
169
|
+
"""Split a sensitive path (``"email"``, ``"profile.full_name"``, ``"addresses[].line"``) into
|
|
170
|
+
segments. A trailing ``[]`` on a part binds to it as array-descent. A root mark (path ``""``)
|
|
171
|
+
yields no segments — there is no addressable leaf for it inside ``data``, so it is skipped."""
|
|
172
|
+
if path == "":
|
|
173
|
+
return []
|
|
174
|
+
segments: List[_Segment] = []
|
|
175
|
+
for part in path.split("."):
|
|
176
|
+
if len(part) >= 2 and part.endswith("[]"):
|
|
177
|
+
segments.append((part[:-2], True))
|
|
178
|
+
else:
|
|
179
|
+
segments.append((part, False))
|
|
180
|
+
return segments
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _apply_at_path(node: Any, segments: List[_Segment], cipher: Cipher, op: _LeafOp) -> None:
|
|
184
|
+
"""Resolve ``segments`` against ``node`` and run ``op`` on the leaf(s). It descends objects by
|
|
185
|
+
key and, when a segment is an array, fans out over every element. An absent key or a type
|
|
186
|
+
mismatch (a path that does not exist in this particular message) is skipped silently — schemas
|
|
187
|
+
describe the union of possible shapes; a given message need not contain every field."""
|
|
188
|
+
if not segments:
|
|
189
|
+
return # root mark or exhausted path with no leaf key — nothing addressable in data
|
|
190
|
+
key, is_array = segments[0]
|
|
191
|
+
if not isinstance(node, MutableMapping):
|
|
192
|
+
return # expected an object here but the message has something else — skip
|
|
193
|
+
if key not in node:
|
|
194
|
+
return # absent field — skip (not an error)
|
|
195
|
+
|
|
196
|
+
child = node[key]
|
|
197
|
+
last = len(segments) == 1
|
|
198
|
+
|
|
199
|
+
if is_array:
|
|
200
|
+
if not isinstance(child, MutableSequence):
|
|
201
|
+
return # declared array but message has a non-array — skip
|
|
202
|
+
for i, elem in enumerate(child):
|
|
203
|
+
if last:
|
|
204
|
+
new_value, ok = op(elem, cipher)
|
|
205
|
+
if ok:
|
|
206
|
+
child[i] = new_value
|
|
207
|
+
else:
|
|
208
|
+
_apply_at_path(elem, segments[1:], cipher, op)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if last:
|
|
212
|
+
new_value, ok = op(child, cipher)
|
|
213
|
+
if ok:
|
|
214
|
+
node[key] = new_value
|
|
215
|
+
return
|
|
216
|
+
_apply_at_path(child, segments[1:], cipher, op)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _encrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
|
|
220
|
+
"""Canonically JSON-encode one field value and replace it with the cipher's ciphertext string.
|
|
221
|
+
The JSON encoding is what makes the round-trip exact: :func:`unprotect`'s ``json.loads`` restores
|
|
222
|
+
the same decoded-JSON value (``float`` for numbers, ``dict`` for objects, …) the codec would
|
|
223
|
+
have produced, so protect → unprotect is byte-for-byte. ``sort_keys`` + compact separators give
|
|
224
|
+
one canonical encoding, mirroring the Go reference's ``json.Marshal``."""
|
|
225
|
+
plaintext = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
226
|
+
return cipher.encrypt(plaintext), True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _decrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
|
|
230
|
+
"""Reverse :func:`_encrypt_leaf`. A non-string leaf is left untouched (``ok=False``) so
|
|
231
|
+
:func:`unprotect` is safe to re-run on already-cleartext data; a string that fails to open or to
|
|
232
|
+
JSON-decode raises :class:`~babelqueue.exceptions.DecryptError` so the consumer fails the message
|
|
233
|
+
rather than handle unreadable PII."""
|
|
234
|
+
if not isinstance(value, str):
|
|
235
|
+
# Not a ciphertext string (already cleartext, or never protected) — leave as-is.
|
|
236
|
+
return None, False
|
|
237
|
+
try:
|
|
238
|
+
plaintext = cipher.decrypt(value)
|
|
239
|
+
except Exception as exc: # noqa: BLE001 — any cipher failure is a decrypt failure for the caller
|
|
240
|
+
raise DecryptError(f"cannot decrypt a protected field: {exc}") from exc
|
|
241
|
+
try:
|
|
242
|
+
restored = json.loads(plaintext)
|
|
243
|
+
except (ValueError, TypeError) as exc:
|
|
244
|
+
raise DecryptError(f"decrypted plaintext is not JSON: {exc}") from exc
|
|
245
|
+
return restored, True
|