babelqueue 1.12.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.12.0 → babelqueue-1.13.0}/CHANGELOG.md +31 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/PKG-INFO +62 -1
- {babelqueue-1.12.0 → babelqueue-1.13.0}/README.md +59 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/pyproject.toml +4 -1
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/__init__.py +9 -3
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/exceptions.py +12 -0
- babelqueue-1.13.0/src/babelqueue/gdpr.py +245 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/schema.py +59 -1
- babelqueue-1.13.0/tests/test_gdpr.py +328 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/.gitignore +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/LICENSE +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_app.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_celery.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_codec.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_django.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_headers.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_otel.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_replay.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_schema.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_sqs_transport.py +0 -0
|
@@ -9,6 +9,37 @@ 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
|
+
|
|
12
43
|
## [1.12.0] - 2026-06-21
|
|
13
44
|
|
|
14
45
|
### 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
|
|
@@ -311,6 +313,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
311
313
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
312
314
|
correlation with no error.
|
|
313
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
|
+
|
|
314
375
|
## What's here
|
|
315
376
|
|
|
316
377
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -259,6 +259,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
259
259
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
260
260
|
correlation with no error.
|
|
261
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
|
+
|
|
262
321
|
## What's here
|
|
263
322
|
|
|
264
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,10 +11,11 @@ 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, outbox, 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
21
|
from .outbox import (
|
|
@@ -25,12 +26,12 @@ from .outbox import (
|
|
|
25
26
|
OutboxRelayResult,
|
|
26
27
|
OutboxStore,
|
|
27
28
|
)
|
|
28
|
-
from .exceptions import BabelQueueError, UnknownUrnError
|
|
29
|
+
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
|
|
29
30
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
30
31
|
from .routing import UnknownUrnStrategy
|
|
31
32
|
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
32
33
|
|
|
33
|
-
__version__ = "1.
|
|
34
|
+
__version__ = "1.13.0"
|
|
34
35
|
|
|
35
36
|
__all__ = [
|
|
36
37
|
"BabelQueue",
|
|
@@ -46,6 +47,11 @@ __all__ = [
|
|
|
46
47
|
"HeaderPublisher",
|
|
47
48
|
"BabelQueueError",
|
|
48
49
|
"UnknownUrnError",
|
|
50
|
+
"DecryptError",
|
|
51
|
+
"gdpr",
|
|
52
|
+
"Cipher",
|
|
53
|
+
"protect",
|
|
54
|
+
"unprotect",
|
|
49
55
|
"dead_letter",
|
|
50
56
|
"headers",
|
|
51
57
|
"idempotency",
|
|
@@ -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
|
|
@@ -35,7 +35,7 @@ import functools
|
|
|
35
35
|
import json
|
|
36
36
|
import os
|
|
37
37
|
import threading
|
|
38
|
-
from typing import Any, Callable, Mapping, Optional, Protocol, runtime_checkable
|
|
38
|
+
from typing import Any, Callable, List, Mapping, NamedTuple, Optional, Protocol, runtime_checkable
|
|
39
39
|
|
|
40
40
|
from .exceptions import InvalidPayloadError
|
|
41
41
|
|
|
@@ -259,3 +259,61 @@ def _violation(path: str, reason: str) -> str:
|
|
|
259
259
|
|
|
260
260
|
def _join(path: str, key: str) -> str:
|
|
261
261
|
return key if path == "" else f"{path}.{key}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class SensitivePath(NamedTuple):
|
|
265
|
+
"""One property a schema marked ``x-gdpr-sensitive`` (ADR-0030), located by its dotted path
|
|
266
|
+
from the schema root. Array elements use the ``field[]`` segment the validator and registry
|
|
267
|
+
``compat`` linter use (e.g. ``addresses[].line``). ``category`` is the optional
|
|
268
|
+
``"x-gdpr-sensitive": "<category>"`` string, or ``""`` when the keyword was the boolean
|
|
269
|
+
``true``. A mark on the root schema itself is reported with ``path == ""``."""
|
|
270
|
+
|
|
271
|
+
path: str
|
|
272
|
+
category: str
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _gdpr_mark(schema: Mapping[str, Any]) -> Optional[str]:
|
|
276
|
+
"""Read the ``x-gdpr-sensitive`` extension keyword (ADR-0030) from one schema node.
|
|
277
|
+
|
|
278
|
+
Returns the category string for a marked node (``""`` when the keyword was the boolean
|
|
279
|
+
``true``, a non-empty category when it was a non-empty string), or ``None`` when the node is
|
|
280
|
+
not marked. The keyword is **validation-neutral**: it never makes a value valid or invalid, so
|
|
281
|
+
annotating a schema is never a breaking change (GR-1). Any other shape — ``false``, ``""``, a
|
|
282
|
+
number — leaves the node unmarked. Mirrors the Go ``fromMap`` and the registry's parser.
|
|
283
|
+
"""
|
|
284
|
+
mark = schema.get("x-gdpr-sensitive")
|
|
285
|
+
if mark is True:
|
|
286
|
+
return ""
|
|
287
|
+
if isinstance(mark, str) and mark != "":
|
|
288
|
+
return mark
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def sensitive_paths(schema: Mapping[str, Any]) -> List[SensitivePath]:
|
|
293
|
+
"""Every property a schema marked ``x-gdpr-sensitive``, in sorted path order (ADR-0030).
|
|
294
|
+
|
|
295
|
+
Descends nested objects (dotted paths like ``profile.full_name``) and array item schemas
|
|
296
|
+
(``addresses[].line``); a mark on the root schema itself is reported with ``path == ""``. It is
|
|
297
|
+
the value-level counterpart to the registry's inventory: :mod:`babelqueue.gdpr` uses these paths
|
|
298
|
+
to locate the leaves it encrypts on produce and decrypts on consume. The Python mirror of the
|
|
299
|
+
Go ``Schema.SensitivePaths``. Non-mapping / malformed nodes are skipped, never raised on.
|
|
300
|
+
"""
|
|
301
|
+
out: List[SensitivePath] = []
|
|
302
|
+
_collect_sensitive(schema, "", out)
|
|
303
|
+
out.sort(key=lambda sp: sp.path)
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _collect_sensitive(schema: Any, path: str, out: List[SensitivePath]) -> None:
|
|
308
|
+
if not isinstance(schema, Mapping):
|
|
309
|
+
return
|
|
310
|
+
category = _gdpr_mark(schema)
|
|
311
|
+
if category is not None:
|
|
312
|
+
out.append(SensitivePath(path, category))
|
|
313
|
+
properties = schema.get("properties")
|
|
314
|
+
if isinstance(properties, Mapping):
|
|
315
|
+
for name, sub in properties.items():
|
|
316
|
+
_collect_sensitive(sub, _join(path, str(name)), out)
|
|
317
|
+
items = schema.get("items")
|
|
318
|
+
if isinstance(items, Mapping):
|
|
319
|
+
_collect_sensitive(items, path + "[]", out)
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Tests for the optional runtime GDPR field encryption (ADR-0030).
|
|
2
|
+
|
|
3
|
+
The cipher used here is a STDLIB-ONLY fake — it deliberately imports no ``cryptography`` library,
|
|
4
|
+
proving the core needs no crypto dependency (GR-7). It still honours the full :class:`Cipher`
|
|
5
|
+
contract: a random per-call nonce so the same plaintext encrypts differently each time, a
|
|
6
|
+
byte-for-byte inverse, and a wrong-key ``decrypt`` that raises. A production cipher (AES-256-GCM via
|
|
7
|
+
``cryptography``) is documented in ``babelqueue/gdpr.py``; the contract these tests pin is the same.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import unittest
|
|
17
|
+
from typing import Any, Dict
|
|
18
|
+
|
|
19
|
+
from babelqueue import EnvelopeCodec
|
|
20
|
+
from babelqueue.exceptions import DecryptError
|
|
21
|
+
from babelqueue.gdpr import Cipher, protect, unprotect
|
|
22
|
+
from babelqueue.schema import SensitivePath, sensitive_paths
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class XorCipher:
|
|
26
|
+
"""A reversible, authenticated, STDLIB-ONLY reference :class:`Cipher` for tests.
|
|
27
|
+
|
|
28
|
+
NOT cryptographically strong — it is a keystream XOR with a keyed digest tag — but it exercises
|
|
29
|
+
every property the contract depends on: a random per-call nonce (same plaintext → different
|
|
30
|
+
ciphertext), a byte-exact inverse, base64 output that drops into JSON, and a wrong-key/tampered
|
|
31
|
+
``decrypt`` that raises (the tag check fails). This is the seam :func:`protect`/:func:`unprotect`
|
|
32
|
+
rely on; the production cipher is the caller's AES-GCM.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_NONCE = 16
|
|
36
|
+
|
|
37
|
+
def __init__(self, key: bytes) -> None:
|
|
38
|
+
self._key = key
|
|
39
|
+
|
|
40
|
+
def _keystream(self, nonce: bytes, length: int) -> bytes:
|
|
41
|
+
out = bytearray()
|
|
42
|
+
counter = 0
|
|
43
|
+
while len(out) < length:
|
|
44
|
+
out += hashlib.sha256(self._key + nonce + counter.to_bytes(4, "big")).digest()
|
|
45
|
+
counter += 1
|
|
46
|
+
return bytes(out[:length])
|
|
47
|
+
|
|
48
|
+
def _tag(self, nonce: bytes, ciphertext: bytes) -> bytes:
|
|
49
|
+
return hashlib.sha256(self._key + b"tag" + nonce + ciphertext).digest()[:16]
|
|
50
|
+
|
|
51
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
52
|
+
nonce = os.urandom(self._NONCE)
|
|
53
|
+
ks = self._keystream(nonce, len(plaintext))
|
|
54
|
+
ct = bytes(p ^ k for p, k in zip(plaintext, ks))
|
|
55
|
+
sealed = nonce + ct + self._tag(nonce, ct)
|
|
56
|
+
return base64.b64encode(sealed).decode("ascii")
|
|
57
|
+
|
|
58
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
59
|
+
raw = base64.b64decode(ciphertext)
|
|
60
|
+
nonce, body, tag = raw[: self._NONCE], raw[self._NONCE : -16], raw[-16:]
|
|
61
|
+
if self._tag(nonce, body) != tag:
|
|
62
|
+
raise ValueError("authentication failed (wrong key or tampered ciphertext)")
|
|
63
|
+
ks = self._keystream(nonce, len(body))
|
|
64
|
+
return bytes(c ^ k for c, k in zip(body, ks))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
KEY = b"0123456789abcdef0123456789abcdef"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cipher() -> Cipher:
|
|
71
|
+
return XorCipher(KEY)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A schema marking the leaf `email`, the nested `profile.full_name`, the array-item
|
|
75
|
+
# `addresses[].line`, and a container `secret_blob` (a whole object marked sensitive).
|
|
76
|
+
SCHEMA: Dict[str, Any] = {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"order_id": {"type": "integer"},
|
|
80
|
+
"email": {"type": "string", "x-gdpr-sensitive": "email"},
|
|
81
|
+
"profile": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"full_name": {"type": "string", "x-gdpr-sensitive": True},
|
|
85
|
+
"tier": {"type": "string"},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"addresses": {
|
|
89
|
+
"type": "array",
|
|
90
|
+
"items": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"line": {"type": "string", "x-gdpr-sensitive": True},
|
|
94
|
+
"city": {"type": "string"},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"secret_blob": {"type": "object", "x-gdpr-sensitive": True},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _data() -> Dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"order_id": 1042,
|
|
106
|
+
"email": "alice@example.com",
|
|
107
|
+
"profile": {"full_name": "Alice Liddell", "tier": "gold"},
|
|
108
|
+
"addresses": [
|
|
109
|
+
{"line": "1 Rabbit Hole", "city": "Wonderland"},
|
|
110
|
+
{"line": "2 Looking Glass", "city": "Mirror"},
|
|
111
|
+
],
|
|
112
|
+
"secret_blob": {"pan": "4111111111111111", "exp": 1230},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SensitivePathsTest(unittest.TestCase):
|
|
117
|
+
def test_walks_nested_object_array_item_and_container(self) -> None:
|
|
118
|
+
paths = sensitive_paths(SCHEMA)
|
|
119
|
+
# Sorted by path, with the array-item using the "field[]" segment.
|
|
120
|
+
self.assertEqual(
|
|
121
|
+
paths,
|
|
122
|
+
[
|
|
123
|
+
SensitivePath("addresses[].line", ""),
|
|
124
|
+
SensitivePath("email", "email"),
|
|
125
|
+
SensitivePath("profile.full_name", ""),
|
|
126
|
+
SensitivePath("secret_blob", ""),
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def test_root_mark_reports_empty_path(self) -> None:
|
|
131
|
+
self.assertEqual(sensitive_paths({"x-gdpr-sensitive": True}), [SensitivePath("", "")])
|
|
132
|
+
|
|
133
|
+
def test_validation_neutral_shapes_are_ignored(self) -> None:
|
|
134
|
+
# false, "", and a number never mark a property (mirrors the Go parser).
|
|
135
|
+
schema = {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"a": {"type": "string", "x-gdpr-sensitive": False},
|
|
139
|
+
"b": {"type": "string", "x-gdpr-sensitive": ""},
|
|
140
|
+
"c": {"type": "string", "x-gdpr-sensitive": 1},
|
|
141
|
+
"d": {"type": "string"},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
self.assertEqual(sensitive_paths(schema), [])
|
|
145
|
+
|
|
146
|
+
def test_malformed_schema_is_skipped_not_raised(self) -> None:
|
|
147
|
+
self.assertEqual(sensitive_paths({"properties": "nope", "items": 5}), [])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ProtectRoundTripTest(unittest.TestCase):
|
|
151
|
+
def test_round_trip_restores_data_byte_for_byte(self) -> None:
|
|
152
|
+
original = _data()
|
|
153
|
+
data = _data()
|
|
154
|
+
protect(data, SCHEMA, _cipher())
|
|
155
|
+
|
|
156
|
+
# Marked leaves became ciphertext STRINGS; everything else is untouched.
|
|
157
|
+
self.assertIsInstance(data["email"], str)
|
|
158
|
+
self.assertNotEqual(data["email"], original["email"])
|
|
159
|
+
self.assertIsInstance(data["profile"]["full_name"], str)
|
|
160
|
+
self.assertIsInstance(data["addresses"][0]["line"], str)
|
|
161
|
+
self.assertIsInstance(data["secret_blob"], str) # the whole container is one ciphertext
|
|
162
|
+
# Non-sensitive siblings are bit-identical.
|
|
163
|
+
self.assertEqual(data["order_id"], 1042)
|
|
164
|
+
self.assertEqual(data["profile"]["tier"], "gold")
|
|
165
|
+
self.assertEqual(data["addresses"][0]["city"], "Wonderland")
|
|
166
|
+
|
|
167
|
+
unprotect(data, SCHEMA, _cipher())
|
|
168
|
+
self.assertEqual(data, original)
|
|
169
|
+
|
|
170
|
+
def test_nested_object_only(self) -> None:
|
|
171
|
+
schema = {"type": "object", "properties": {"profile": {"type": "object",
|
|
172
|
+
"properties": {"full_name": {"type": "string", "x-gdpr-sensitive": True}}}}}
|
|
173
|
+
data = {"profile": {"full_name": "Bob", "tier": "silver"}}
|
|
174
|
+
protect(data, schema, _cipher())
|
|
175
|
+
self.assertNotEqual(data["profile"]["full_name"], "Bob")
|
|
176
|
+
self.assertEqual(data["profile"]["tier"], "silver")
|
|
177
|
+
unprotect(data, schema, _cipher())
|
|
178
|
+
self.assertEqual(data, {"profile": {"full_name": "Bob", "tier": "silver"}})
|
|
179
|
+
|
|
180
|
+
def test_array_items(self) -> None:
|
|
181
|
+
schema = {"type": "object", "properties": {"addresses": {"type": "array", "items":
|
|
182
|
+
{"type": "object", "properties": {"line": {"type": "string", "x-gdpr-sensitive": True}}}}}}
|
|
183
|
+
data = {"addresses": [{"line": "a", "city": "x"}, {"line": "b", "city": "y"}]}
|
|
184
|
+
protect(data, schema, _cipher())
|
|
185
|
+
self.assertNotEqual(data["addresses"][0]["line"], "a")
|
|
186
|
+
self.assertNotEqual(data["addresses"][1]["line"], "b")
|
|
187
|
+
self.assertEqual(data["addresses"][0]["city"], "x")
|
|
188
|
+
unprotect(data, schema, _cipher())
|
|
189
|
+
self.assertEqual(data, {"addresses": [{"line": "a", "city": "x"}, {"line": "b", "city": "y"}]})
|
|
190
|
+
|
|
191
|
+
def test_array_leaf_directly_marked(self) -> None:
|
|
192
|
+
# A path ending in "[]" marks the array ELEMENTS themselves (e.g. a list of phone strings).
|
|
193
|
+
schema = {"type": "object", "properties":
|
|
194
|
+
{"phones": {"type": "array", "items": {"type": "string", "x-gdpr-sensitive": True}}}}
|
|
195
|
+
self.assertEqual(sensitive_paths(schema), [SensitivePath("phones[]", "")])
|
|
196
|
+
data = {"phones": ["+1-555-0100", "+1-555-0199"]}
|
|
197
|
+
protect(data, schema, _cipher())
|
|
198
|
+
self.assertNotEqual(data["phones"][0], "+1-555-0100")
|
|
199
|
+
self.assertNotEqual(data["phones"][1], "+1-555-0199")
|
|
200
|
+
unprotect(data, schema, _cipher())
|
|
201
|
+
self.assertEqual(data, {"phones": ["+1-555-0100", "+1-555-0199"]})
|
|
202
|
+
|
|
203
|
+
def test_non_string_values_round_trip_exactly(self) -> None:
|
|
204
|
+
# A sensitive leaf need not be a string: number/bool/null/object restore to the same type.
|
|
205
|
+
schema = {"type": "object", "properties": {k: {"x-gdpr-sensitive": True}
|
|
206
|
+
for k in ("n", "b", "z", "o")}}
|
|
207
|
+
data: Dict[str, Any] = {"n": 1230, "b": True, "z": None, "o": {"k": [1, 2, 3]}}
|
|
208
|
+
protect(data, schema, _cipher())
|
|
209
|
+
for k in ("n", "b", "z", "o"):
|
|
210
|
+
self.assertIsInstance(data[k], str)
|
|
211
|
+
unprotect(data, schema, _cipher())
|
|
212
|
+
self.assertEqual(data, {"n": 1230, "b": True, "z": None, "o": {"k": [1, 2, 3]}})
|
|
213
|
+
|
|
214
|
+
def test_root_mark_is_skipped_no_addressable_leaf(self) -> None:
|
|
215
|
+
# A mark on the root schema has no addressable leaf inside data — a no-op, not an error.
|
|
216
|
+
data = {"a": 1}
|
|
217
|
+
protect(data, {"x-gdpr-sensitive": True}, _cipher())
|
|
218
|
+
self.assertEqual(data, {"a": 1})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ProtectSkipsTest(unittest.TestCase):
|
|
222
|
+
def test_absent_marked_field_is_skipped(self) -> None:
|
|
223
|
+
data = {"order_id": 7} # no `email`, `profile`, etc.
|
|
224
|
+
protect(data, SCHEMA, _cipher())
|
|
225
|
+
self.assertEqual(data, {"order_id": 7})
|
|
226
|
+
|
|
227
|
+
def test_type_mismatch_is_skipped(self) -> None:
|
|
228
|
+
# Schema says addresses is an array of objects, but the message carries a scalar — skip.
|
|
229
|
+
data = {"addresses": "not-an-array", "profile": "not-an-object"}
|
|
230
|
+
protect(data, SCHEMA, _cipher())
|
|
231
|
+
self.assertEqual(data, {"addresses": "not-an-array", "profile": "not-an-object"})
|
|
232
|
+
|
|
233
|
+
def test_no_marks_or_none_schema_is_noop(self) -> None:
|
|
234
|
+
data = _data()
|
|
235
|
+
snapshot = json.dumps(data, sort_keys=True)
|
|
236
|
+
protect(data, {"type": "object", "properties": {"email": {"type": "string"}}}, _cipher())
|
|
237
|
+
protect(data, None, _cipher())
|
|
238
|
+
self.assertEqual(json.dumps(data, sort_keys=True), snapshot)
|
|
239
|
+
|
|
240
|
+
def test_empty_data_is_noop(self) -> None:
|
|
241
|
+
data: Dict[str, Any] = {}
|
|
242
|
+
protect(data, SCHEMA, _cipher())
|
|
243
|
+
self.assertEqual(data, {})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class UnprotectEdgeTest(unittest.TestCase):
|
|
247
|
+
def test_non_string_leaf_left_untouched_idempotent(self) -> None:
|
|
248
|
+
# Re-running unprotect is a no-op for NON-STRING leaves: once restored to number/bool/null/
|
|
249
|
+
# object they are no longer ciphertext candidates, so a second pass leaves them as-is.
|
|
250
|
+
schema = {"type": "object", "properties": {k: {"x-gdpr-sensitive": True}
|
|
251
|
+
for k in ("n", "b", "z", "o")}}
|
|
252
|
+
data: Dict[str, Any] = {"n": 1230, "b": True, "z": None, "o": {"k": 1}}
|
|
253
|
+
protect(data, schema, _cipher())
|
|
254
|
+
unprotect(data, schema, _cipher()) # first restore
|
|
255
|
+
restored = json.dumps(data, sort_keys=True)
|
|
256
|
+
unprotect(data, schema, _cipher()) # second pass is a no-op (non-string leaves left alone)
|
|
257
|
+
unprotect(data, schema, _cipher()) # and again — still stable
|
|
258
|
+
self.assertEqual(json.dumps(data, sort_keys=True), restored)
|
|
259
|
+
|
|
260
|
+
def test_rerun_on_restored_string_leaf_raises(self) -> None:
|
|
261
|
+
# A restored STRING leaf IS a ciphertext candidate, so a naive double-unprotect on a string
|
|
262
|
+
# field fails cleanly (it is not the cipher's output) rather than silently corrupting data —
|
|
263
|
+
# callers run unprotect exactly once, matching the documented contract.
|
|
264
|
+
data = {"email": "alice@example.com"}
|
|
265
|
+
protect(data, SCHEMA, _cipher())
|
|
266
|
+
unprotect(data, SCHEMA, _cipher())
|
|
267
|
+
self.assertEqual(data, {"email": "alice@example.com"})
|
|
268
|
+
with self.assertRaises(DecryptError):
|
|
269
|
+
unprotect(data, SCHEMA, _cipher())
|
|
270
|
+
|
|
271
|
+
def test_wrong_key_raises_decrypt_error(self) -> None:
|
|
272
|
+
data = _data()
|
|
273
|
+
protect(data, SCHEMA, XorCipher(KEY))
|
|
274
|
+
with self.assertRaises(DecryptError):
|
|
275
|
+
unprotect(data, SCHEMA, XorCipher(b"f" * 32)) # different key → tag check fails
|
|
276
|
+
|
|
277
|
+
def test_decrypted_non_json_raises_decrypt_error(self) -> None:
|
|
278
|
+
# A cipher that opens but yields bytes that are not JSON also fails cleanly as DecryptError —
|
|
279
|
+
# the consumer must never feed corrupt-but-decrypted bytes to the handler.
|
|
280
|
+
class GarbageCipher:
|
|
281
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
282
|
+
return "garbage"
|
|
283
|
+
|
|
284
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
285
|
+
return b"\xff\xfe not json"
|
|
286
|
+
|
|
287
|
+
data = {"email": "ct"}
|
|
288
|
+
with self.assertRaises(DecryptError):
|
|
289
|
+
unprotect(data, SCHEMA, GarbageCipher())
|
|
290
|
+
|
|
291
|
+
def test_non_ciphertext_string_raises_decrypt_error(self) -> None:
|
|
292
|
+
# A marked leaf that is a plain (never-protected) string is still a string, so unprotect
|
|
293
|
+
# tries to open it and fails cleanly rather than silently passing garbage through.
|
|
294
|
+
data = {"email": "alice@example.com"}
|
|
295
|
+
with self.assertRaises(DecryptError):
|
|
296
|
+
unprotect(data, SCHEMA, _cipher())
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class FrozenEnvelopeTest(unittest.TestCase):
|
|
300
|
+
def test_protected_envelope_still_decodes_and_preserves_frame(self) -> None:
|
|
301
|
+
data = _data()
|
|
302
|
+
protect(data, SCHEMA, _cipher())
|
|
303
|
+
|
|
304
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", data,
|
|
305
|
+
queue="orders", trace_id="trace-xyz")
|
|
306
|
+
body = EnvelopeCodec.encode(envelope)
|
|
307
|
+
|
|
308
|
+
# data stays pure JSON (the ciphertext is just a string) — encode/decode round-trips.
|
|
309
|
+
decoded = EnvelopeCodec.decode(body)
|
|
310
|
+
self.assertTrue(EnvelopeCodec.accepts(decoded))
|
|
311
|
+
self.assertEqual(decoded["meta"]["schema_version"], 1)
|
|
312
|
+
self.assertEqual(decoded["trace_id"], "trace-xyz") # GR-4: trace_id untouched
|
|
313
|
+
self.assertEqual(decoded["job"], "urn:babel:orders:created")
|
|
314
|
+
|
|
315
|
+
# The consumer decrypts in place and recovers the original cleartext exactly.
|
|
316
|
+
unprotect(decoded["data"], SCHEMA, _cipher())
|
|
317
|
+
self.assertEqual(decoded["data"], _data())
|
|
318
|
+
|
|
319
|
+
def test_ciphertext_is_a_json_string(self) -> None:
|
|
320
|
+
# GR-3: a protected value is a JSON string, so an SDK without the key still carries the frame.
|
|
321
|
+
data = {"email": "alice@example.com"}
|
|
322
|
+
protect(data, SCHEMA, _cipher())
|
|
323
|
+
reparsed = json.loads(json.dumps(data))
|
|
324
|
+
self.assertIsInstance(reparsed["email"], str)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/schema/message-envelope.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|