persistence-kit 3.10.1__tar.gz → 3.12.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.
Files changed (107) hide show
  1. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/PKG-INFO +72 -6
  2. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/README.md +68 -4
  3. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/__init__.py +47 -0
  4. persistence_kit-3.12.0/persistence_kit/api/__init__.py +55 -0
  5. persistence_kit-3.12.0/persistence_kit/api/encrypted_middleware.py +65 -0
  6. persistence_kit-3.12.0/persistence_kit/api/encrypted_routes.py +335 -0
  7. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/contracts.py +2 -0
  8. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/memory.py +6 -0
  9. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/mongo.py +14 -0
  10. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/namespaced.py +3 -0
  11. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/__init__.py +25 -1
  12. persistence_kit-3.12.0/persistence_kit/security/encrypted/__init__.py +19 -0
  13. persistence_kit-3.12.0/persistence_kit/security/encrypted/envelope.py +79 -0
  14. persistence_kit-3.12.0/persistence_kit/security/encrypted/errors.py +2 -0
  15. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/factory.py +35 -0
  16. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/ports.py +26 -1
  17. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/__init__.py +19 -1
  18. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/cognito_identity_provider.py +26 -21
  19. persistence_kit-3.12.0/persistence_kit/security/providers/encryption_provider.py +103 -0
  20. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/memory_security_provider.py +20 -10
  21. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/__init__.py +2 -0
  22. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/app_settings.py +9 -0
  23. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/pyproject.toml +5 -3
  24. persistence_kit-3.10.1/persistence_kit/api/__init__.py +0 -26
  25. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/LICENSE +0 -0
  26. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/common.py +0 -0
  27. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/error_handlers.py +0 -0
  28. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/exceptions.py +0 -0
  29. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/rate_limit.py +0 -0
  30. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/route_loader.py +0 -0
  31. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/authenticated_user.py +0 -0
  32. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/__init__.py +0 -0
  33. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/configuration.py +0 -0
  34. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/seeders.py +0 -0
  35. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/startup.py +0 -0
  36. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/__init__.py +0 -0
  37. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/dynamodb.py +0 -0
  38. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/factory.py +0 -0
  39. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/__init__.py +0 -0
  40. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/repository.py +0 -0
  41. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/view_repository.py +0 -0
  42. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/py.typed +0 -0
  43. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/__init__.py +0 -0
  44. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  45. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  46. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/filter_ops.py +0 -0
  47. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  48. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  49. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  50. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  51. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  52. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  53. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  54. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  55. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  56. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  57. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  58. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/__init__.py +0 -0
  59. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  60. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  61. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  62. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  63. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  64. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  65. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/resilience/__init__.py +0 -0
  66. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/resilience/circuit.py +0 -0
  67. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/__init__.py +0 -0
  68. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/aggregate.py +0 -0
  69. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  70. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  71. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/base.py +0 -0
  72. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/basic.py +0 -0
  73. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  74. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/login.py +0 -0
  75. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  76. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/caching.py +0 -0
  77. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/client.py +0 -0
  78. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/config.py +0 -0
  79. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/contracts.py +0 -0
  80. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/errors.py +0 -0
  81. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/factory.py +0 -0
  82. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/mapping.py +0 -0
  83. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/memory.py +0 -0
  84. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/payload.py +0 -0
  85. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/populate.py +0 -0
  86. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/provider.py +0 -0
  87. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/registry.py +0 -0
  88. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/resolver.py +0 -0
  89. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/retry.py +0 -0
  90. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/registration.py +0 -0
  91. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  92. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  93. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  94. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/cache_settings.py +0 -0
  95. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/constants.py +0 -0
  96. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/parsers.py +0 -0
  97. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/repo_settings.py +0 -0
  98. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/__init__.py +0 -0
  99. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/contracts.py +0 -0
  100. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/errors.py +0 -0
  101. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/factory.py +0 -0
  102. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/local.py +0 -0
  103. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/media.py +0 -0
  104. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/routes.py +0 -0
  105. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/s3.py +0 -0
  106. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/utils/__init__.py +0 -0
  107. {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.10.1
3
+ Version: 3.12.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -21,6 +21,7 @@ Classifier: Typing :: Typed
21
21
  Provides-Extra: all
22
22
  Provides-Extra: api
23
23
  Provides-Extra: dynamodb
24
+ Provides-Extra: encrypted
24
25
  Provides-Extra: restclient
25
26
  Provides-Extra: security
26
27
  Provides-Extra: security-cognito
@@ -29,7 +30,8 @@ Provides-Extra: storage-s3
29
30
  Provides-Extra: testing
30
31
  Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
31
32
  Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
32
- Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
33
+ Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
34
+ Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "encrypted" or extra == "testing" or extra == "all"
33
35
  Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
34
36
  Requires-Dist: motor (>=3.7.1,<4.0.0)
35
37
  Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
@@ -149,7 +151,8 @@ pip install "persistence-kit[api,security,restclient]"
149
151
  | `storage-routes` | FastAPI route to serve local exports | fastapi, python-multipart |
150
152
  | `dynamodb` | DynamoDB repository backend | boto3 |
151
153
  | `restclient` | The REST client and its cache | httpx |
152
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart |
154
+ | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
155
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography |
153
156
  | `all` | Every optional capability | all of the above |
154
157
 
155
158
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -165,11 +168,11 @@ add, not an obscure `ImportError`.
165
168
  | `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
166
169
  | `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
167
170
  | `storage/` | Object storage: local directory or S3, with presigned URLs |
168
- | `security/` | Identity providers and JWT verifiers, memory or Cognito |
171
+ | `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
169
172
  | `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
170
173
  | `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
171
174
  | `resilience/` | Circuit breaker, shared by anything that calls out |
172
- | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
175
+ | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
173
176
  | `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
174
177
  | `utils/` | Small transversal helpers such as upserts |
175
178
 
@@ -231,6 +234,65 @@ application. The kit only answers "who is this".
231
234
 
232
235
  Needs `[security]`, or `[security-cognito]` for Cognito.
233
236
 
237
+ ## Encrypted payloads
238
+
239
+ For data that should not be readable between the browser and your handler, not
240
+ even to a proxy that terminates TLS.
241
+
242
+ ```python
243
+ from fastapi import APIRouter, FastAPI
244
+
245
+ from persistence_kit import (
246
+ EncryptedResponseMiddleware,
247
+ build_encrypted_route,
248
+ encrypted,
249
+ )
250
+
251
+ router = APIRouter(route_class=build_encrypted_route(get_settings))
252
+
253
+
254
+ @router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
255
+ async def login(payload: LoginRequest) -> LoginResponse:
256
+ ...
257
+
258
+
259
+ app = FastAPI()
260
+ app.add_middleware(EncryptedResponseMiddleware)
261
+ app.include_router(router)
262
+ ```
263
+
264
+ The client generates one AES-256-GCM key per request, wraps it with the server's
265
+ public key, and sends it inside the envelope. The route unwraps it and opens the
266
+ payload before your handler runs; the middleware encrypts whatever comes back with
267
+ the same key, including the errors your own `exception_handler` builds. Your
268
+ handler never sees an envelope, and both pieces are required.
269
+
270
+ Two modes. `encrypted()` encrypts the whole body and the whole response.
271
+ `encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
272
+ stays readable; paths may be nested and may cross lists, as in `items[].price`.
273
+ Those paths are validated when the route is mounted — against your Pydantic body
274
+ when there is one — so a typo fails at startup rather than quietly leaving the
275
+ field unencrypted.
276
+
277
+ By default the private key is generated in memory at startup, so encrypted routes
278
+ work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
279
+ from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
280
+ KMS. Clients fetch the matching public key from your own endpoint, built on
281
+ `public_key_der_b64(provider)`.
282
+
283
+ Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
284
+ `CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
285
+ share. It warns and starts anyway.
286
+
287
+ Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
288
+ accepted once, so a captured request cannot be replayed. Anything malformed,
289
+ expired, replayed or tampered with comes back as a 400.
290
+
291
+ In `local` stage a plain body is allowed through, so you can call your API from
292
+ Swagger while developing.
293
+
294
+ Needs `[encrypted]`.
295
+
234
296
  ## REST client and cache
235
297
 
236
298
  For calling somebody else's HTTP API without writing the same client again.
@@ -310,6 +372,9 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
310
372
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
311
373
  | `POSTGRES_SSL` | Set when the server requires TLS |
312
374
  | `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
375
+ | `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
376
+ | `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
377
+ | `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
313
378
 
314
379
  ## Wiring it into an application
315
380
 
@@ -433,7 +498,7 @@ poetry install --with dev --all-extras
433
498
  poetry run pytest -q
434
499
  ```
435
500
 
436
- Current baseline: **434 tests passing** (version 3.9.1). Async tests use
501
+ Current baseline: **555 tests passing** (version 3.12.0). Async tests use
437
502
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
438
503
 
439
504
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
@@ -471,6 +536,7 @@ every iteration.
471
536
 
472
537
  - [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
473
538
  - [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
539
+ - [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
474
540
 
475
541
  Author: Andres Felipe Serrano Barrios · [github.com/AndresFSerrano/persistence-kit](https://github.com/AndresFSerrano/persistence-kit)
476
542
 
@@ -105,7 +105,8 @@ pip install "persistence-kit[api,security,restclient]"
105
105
  | `storage-routes` | FastAPI route to serve local exports | fastapi, python-multipart |
106
106
  | `dynamodb` | DynamoDB repository backend | boto3 |
107
107
  | `restclient` | The REST client and its cache | httpx |
108
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart |
108
+ | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
109
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography |
109
110
  | `all` | Every optional capability | all of the above |
110
111
 
111
112
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -121,11 +122,11 @@ add, not an obscure `ImportError`.
121
122
  | `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
122
123
  | `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
123
124
  | `storage/` | Object storage: local directory or S3, with presigned URLs |
124
- | `security/` | Identity providers and JWT verifiers, memory or Cognito |
125
+ | `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
125
126
  | `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
126
127
  | `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
127
128
  | `resilience/` | Circuit breaker, shared by anything that calls out |
128
- | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
129
+ | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
129
130
  | `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
130
131
  | `utils/` | Small transversal helpers such as upserts |
131
132
 
@@ -187,6 +188,65 @@ application. The kit only answers "who is this".
187
188
 
188
189
  Needs `[security]`, or `[security-cognito]` for Cognito.
189
190
 
191
+ ## Encrypted payloads
192
+
193
+ For data that should not be readable between the browser and your handler, not
194
+ even to a proxy that terminates TLS.
195
+
196
+ ```python
197
+ from fastapi import APIRouter, FastAPI
198
+
199
+ from persistence_kit import (
200
+ EncryptedResponseMiddleware,
201
+ build_encrypted_route,
202
+ encrypted,
203
+ )
204
+
205
+ router = APIRouter(route_class=build_encrypted_route(get_settings))
206
+
207
+
208
+ @router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
209
+ async def login(payload: LoginRequest) -> LoginResponse:
210
+ ...
211
+
212
+
213
+ app = FastAPI()
214
+ app.add_middleware(EncryptedResponseMiddleware)
215
+ app.include_router(router)
216
+ ```
217
+
218
+ The client generates one AES-256-GCM key per request, wraps it with the server's
219
+ public key, and sends it inside the envelope. The route unwraps it and opens the
220
+ payload before your handler runs; the middleware encrypts whatever comes back with
221
+ the same key, including the errors your own `exception_handler` builds. Your
222
+ handler never sees an envelope, and both pieces are required.
223
+
224
+ Two modes. `encrypted()` encrypts the whole body and the whole response.
225
+ `encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
226
+ stays readable; paths may be nested and may cross lists, as in `items[].price`.
227
+ Those paths are validated when the route is mounted — against your Pydantic body
228
+ when there is one — so a typo fails at startup rather than quietly leaving the
229
+ field unencrypted.
230
+
231
+ By default the private key is generated in memory at startup, so encrypted routes
232
+ work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
233
+ from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
234
+ KMS. Clients fetch the matching public key from your own endpoint, built on
235
+ `public_key_der_b64(provider)`.
236
+
237
+ Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
238
+ `CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
239
+ share. It warns and starts anyway.
240
+
241
+ Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
242
+ accepted once, so a captured request cannot be replayed. Anything malformed,
243
+ expired, replayed or tampered with comes back as a 400.
244
+
245
+ In `local` stage a plain body is allowed through, so you can call your API from
246
+ Swagger while developing.
247
+
248
+ Needs `[encrypted]`.
249
+
190
250
  ## REST client and cache
191
251
 
192
252
  For calling somebody else's HTTP API without writing the same client again.
@@ -266,6 +326,9 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
266
326
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
267
327
  | `POSTGRES_SSL` | Set when the server requires TLS |
268
328
  | `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
329
+ | `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
330
+ | `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
331
+ | `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
269
332
 
270
333
  ## Wiring it into an application
271
334
 
@@ -389,7 +452,7 @@ poetry install --with dev --all-extras
389
452
  poetry run pytest -q
390
453
  ```
391
454
 
392
- Current baseline: **434 tests passing** (version 3.9.1). Async tests use
455
+ Current baseline: **555 tests passing** (version 3.12.0). Async tests use
393
456
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
394
457
 
395
458
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
@@ -427,5 +490,6 @@ every iteration.
427
490
 
428
491
  - [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
429
492
  - [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
493
+ - [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
430
494
 
431
495
  Author: Andres Felipe Serrano Barrios · [github.com/AndresFSerrano/persistence-kit](https://github.com/AndresFSerrano/persistence-kit)
@@ -14,6 +14,7 @@ from .settings import (
14
14
  DeploymentStage,
15
15
  ExportStorageProvider,
16
16
  MediaStorageProvider,
17
+ EncryptedType,
17
18
  PersistenceKitSettings,
18
19
  )
19
20
  from .settings.constants import Database
@@ -96,6 +97,51 @@ _OPTIONAL_EXPORTS = {
96
97
  "get_token_verifier",
97
98
  "security",
98
99
  ),
100
+ "KeyProvider": ("persistence_kit.security.ports", "KeyProvider", "security"),
101
+ "get_key_provider": ("persistence_kit.security.factory", "get_key_provider", "security"),
102
+ "public_key_der_b64": (
103
+ "persistence_kit.security.ports",
104
+ "public_key_der_b64",
105
+ "encrypted",
106
+ ),
107
+ "LocalKeyProvider": (
108
+ "persistence_kit.security.providers.encryption_provider",
109
+ "LocalKeyProvider",
110
+ "encrypted",
111
+ ),
112
+ "MemoryKeyProvider": (
113
+ "persistence_kit.security.providers.encryption_provider",
114
+ "MemoryKeyProvider",
115
+ "encrypted",
116
+ ),
117
+ "KmsKeyProvider": (
118
+ "persistence_kit.security.providers.encryption_provider",
119
+ "KmsKeyProvider",
120
+ "encrypted",
121
+ ),
122
+ "EncryptedPayloadError": (
123
+ "persistence_kit.security.encrypted.errors",
124
+ "EncryptedPayloadError",
125
+ "encrypted",
126
+ ),
127
+ "encrypted": ("persistence_kit.api.encrypted_routes", "encrypted", "encrypted"),
128
+ "build_encrypted_route": (
129
+ "persistence_kit.api.encrypted_routes",
130
+ "build_encrypted_route",
131
+ "encrypted",
132
+ ),
133
+ "EncryptedResponseMiddleware": (
134
+ "persistence_kit.api.encrypted_middleware",
135
+ "EncryptedResponseMiddleware",
136
+ "encrypted",
137
+ ),
138
+ "declare_key_header": (
139
+ "persistence_kit.api.encrypted_routes",
140
+ "declare_key_header",
141
+ "encrypted",
142
+ ),
143
+ "ENCRYPTED_FLAG": ("persistence_kit.api.encrypted_routes", "ENCRYPTED_FLAG", "encrypted"),
144
+ "KEY_HEADER": ("persistence_kit.api.encrypted_routes", "KEY_HEADER", "encrypted"),
99
145
  "RegistrationResult": (
100
146
  "persistence_kit.security.registration",
101
147
  "RegistrationResult",
@@ -286,6 +332,7 @@ __all__ = [
286
332
  "AuthProvider",
287
333
  "ExportStorageProvider",
288
334
  "MediaStorageProvider",
335
+ "EncryptedType",
289
336
  "DeploymentStage",
290
337
  "LOCAL_DEFAULT_JOB_SERVICE_API_KEY",
291
338
  "DEFAULT_MEMORY_JWT_SECRET",
@@ -0,0 +1,55 @@
1
+ from .common import ApiError, pagination_params
2
+ from .error_handlers import handle_repository_errors, handle_service_errors
3
+ from .exceptions import (
4
+ BaseAPIException,
5
+ BusinessRuleException,
6
+ DatabaseException,
7
+ NotFoundException,
8
+ ValidationException,
9
+ )
10
+ from .rate_limit import InMemoryRateLimiter, build_auth_rate_limit_dependency
11
+ from .route_loader import build_api_router
12
+
13
+ _LAZY = {
14
+ "KEY_HEADER": ("persistence_kit.api.encrypted_routes", "KEY_HEADER"),
15
+ "ENCRYPTED_FLAG": ("persistence_kit.api.encrypted_routes", "ENCRYPTED_FLAG"),
16
+ "build_encrypted_route": ("persistence_kit.api.encrypted_routes", "build_encrypted_route"),
17
+ "declare_key_header": ("persistence_kit.api.encrypted_routes", "declare_key_header"),
18
+ "encrypted": ("persistence_kit.api.encrypted_routes", "encrypted"),
19
+ "EncryptedResponseMiddleware": (
20
+ "persistence_kit.api.encrypted_middleware",
21
+ "EncryptedResponseMiddleware",
22
+ ),
23
+ }
24
+
25
+ __all__ = [
26
+ "ApiError",
27
+ "pagination_params",
28
+ "BaseAPIException",
29
+ "NotFoundException",
30
+ "ValidationException",
31
+ "BusinessRuleException",
32
+ "DatabaseException",
33
+ "handle_service_errors",
34
+ "handle_repository_errors",
35
+ "build_api_router",
36
+ "InMemoryRateLimiter",
37
+ "build_auth_rate_limit_dependency",
38
+ "build_encrypted_route",
39
+ "EncryptedResponseMiddleware",
40
+ "encrypted",
41
+ "declare_key_header",
42
+ "ENCRYPTED_FLAG",
43
+ "KEY_HEADER",
44
+ ]
45
+
46
+
47
+ def __getattr__(name: str):
48
+ if name in _LAZY:
49
+ from importlib import import_module
50
+
51
+ module_name, attr = _LAZY[name]
52
+ value = getattr(import_module(module_name), attr)
53
+ globals()[name] = value
54
+ return value
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,65 @@
1
+ import json
2
+ from collections.abc import Callable
3
+
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+ from starlette.requests import Request
6
+ from starlette.responses import Response
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from persistence_kit.api.encrypted_routes import (
10
+ KEY_STATE,
11
+ RESPONSE_FIELDS_STATE,
12
+ locate_all,
13
+ )
14
+ from persistence_kit.security.encrypted.envelope import encrypt
15
+ from persistence_kit.security.encrypted.errors import EncryptedPayloadError
16
+
17
+
18
+ def _encrypt_fields(payload: dict | list, fields: list[str], data_key: bytes) -> dict | list:
19
+ if isinstance(payload, list):
20
+ return [_encrypt_fields(item, fields, data_key) for item in payload]
21
+ if not isinstance(payload, dict):
22
+ raise EncryptedPayloadError("La respuesta debe ser un objeto o una lista de objetos")
23
+ for field in fields:
24
+ for container, key in locate_all(payload, field):
25
+ if key in container:
26
+ container[key] = encrypt(json.dumps(container[key]).encode(), data_key)
27
+ return payload
28
+
29
+
30
+ def _encrypted_response(payload, source: Response) -> JSONResponse:
31
+ response = JSONResponse(payload, status_code=source.status_code)
32
+ response.raw_headers += [
33
+ (name, value)
34
+ for name, value in source.raw_headers
35
+ if name not in (b"content-length", b"content-type")
36
+ ]
37
+ return response
38
+
39
+
40
+ class EncryptedResponseMiddleware(BaseHTTPMiddleware):
41
+ """Encrypts the response of routes marked with ``encrypted()``.
42
+
43
+ Runs outside the ``ExceptionMiddleware``, so it also covers the responses
44
+ built by the app's own exception handlers. The route only leaves the key on
45
+ ``request.state``.
46
+ """
47
+
48
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
49
+ response = await call_next(request)
50
+ data_key = getattr(request.state, KEY_STATE, None)
51
+ if data_key is None:
52
+ return response
53
+
54
+ body = b"".join([chunk async for chunk in response.body_iterator])
55
+ if not body:
56
+ empty = Response(status_code=response.status_code)
57
+ empty.raw_headers = list(response.raw_headers)
58
+ return empty
59
+
60
+ response_fields = getattr(request.state, RESPONSE_FIELDS_STATE, None)
61
+ if response_fields:
62
+ payload = _encrypt_fields(json.loads(body), response_fields, data_key)
63
+ else:
64
+ payload = encrypt(body, data_key)
65
+ return _encrypted_response(payload, response)