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.
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/PKG-INFO +72 -6
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/README.md +68 -4
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/__init__.py +47 -0
- persistence_kit-3.12.0/persistence_kit/api/__init__.py +55 -0
- persistence_kit-3.12.0/persistence_kit/api/encrypted_middleware.py +65 -0
- persistence_kit-3.12.0/persistence_kit/api/encrypted_routes.py +335 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/contracts.py +2 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/memory.py +6 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/mongo.py +14 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/namespaced.py +3 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/__init__.py +25 -1
- persistence_kit-3.12.0/persistence_kit/security/encrypted/__init__.py +19 -0
- persistence_kit-3.12.0/persistence_kit/security/encrypted/envelope.py +79 -0
- persistence_kit-3.12.0/persistence_kit/security/encrypted/errors.py +2 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/factory.py +35 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/ports.py +26 -1
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/__init__.py +19 -1
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/cognito_identity_provider.py +26 -21
- persistence_kit-3.12.0/persistence_kit/security/providers/encryption_provider.py +103 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/providers/memory_security_provider.py +20 -10
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/__init__.py +2 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/app_settings.py +9 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/pyproject.toml +5 -3
- persistence_kit-3.10.1/persistence_kit/api/__init__.py +0 -26
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/LICENSE +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/rate_limit.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/authenticated_user.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/dynamodb.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/cache/factory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/resilience/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/resilience/circuit.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/aggregate.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/api_key.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/base.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/basic.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/bearer.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/login.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/caching.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/client.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/config.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/contracts.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/errors.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/factory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/mapping.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/memory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/payload.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/populate.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/provider.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/registry.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/resolver.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/restclient/retry.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/registration.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/cache_settings.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/constants.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/settings/repo_settings.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/__init__.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/contracts.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/errors.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/factory.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/local.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/media.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/routes.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/storage/s3.py +0 -0
- {persistence_kit-3.10.1 → persistence_kit-3.12.0}/persistence_kit/utils/__init__.py +0 -0
- {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.
|
|
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:
|
|
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
|
-
| `
|
|
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: **
|
|
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
|
-
| `
|
|
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: **
|
|
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)
|