persistence-kit 3.11.0__tar.gz → 3.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.
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/PKG-INFO +147 -8
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/README.md +141 -6
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/__init__.py +47 -0
- persistence_kit-3.13.0/persistence_kit/api/__init__.py +55 -0
- persistence_kit-3.13.0/persistence_kit/api/encrypted_middleware.py +65 -0
- persistence_kit-3.13.0/persistence_kit/api/encrypted_routes.py +335 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/contracts.py +2 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/memory.py +6 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/mongo.py +14 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/namespaced.py +3 -0
- persistence_kit-3.13.0/persistence_kit/mail/__init__.py +57 -0
- persistence_kit-3.13.0/persistence_kit/mail/contracts.py +9 -0
- persistence_kit-3.13.0/persistence_kit/mail/errors.py +26 -0
- persistence_kit-3.13.0/persistence_kit/mail/factory.py +91 -0
- persistence_kit-3.13.0/persistence_kit/mail/mailer.py +74 -0
- persistence_kit-3.13.0/persistence_kit/mail/memory.py +28 -0
- persistence_kit-3.13.0/persistence_kit/mail/message.py +121 -0
- persistence_kit-3.13.0/persistence_kit/mail/mime.py +64 -0
- persistence_kit-3.13.0/persistence_kit/mail/preview.py +84 -0
- persistence_kit-3.13.0/persistence_kit/mail/smtp.py +57 -0
- persistence_kit-3.13.0/persistence_kit/mail/templates.py +133 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/__init__.py +25 -1
- persistence_kit-3.13.0/persistence_kit/security/encrypted/__init__.py +19 -0
- persistence_kit-3.13.0/persistence_kit/security/encrypted/envelope.py +79 -0
- persistence_kit-3.13.0/persistence_kit/security/encrypted/errors.py +2 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/factory.py +35 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/ports.py +24 -1
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/__init__.py +19 -1
- persistence_kit-3.13.0/persistence_kit/security/providers/encryption_provider.py +103 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/__init__.py +2 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/app_settings.py +9 -0
- persistence_kit-3.13.0/persistence_kit/settings/mail_settings.py +24 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/pyproject.toml +7 -3
- persistence_kit-3.11.0/persistence_kit/api/__init__.py +0 -26
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/LICENSE +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/rate_limit.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/authenticated_user.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/dynamodb.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/factory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/resilience/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/resilience/circuit.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/aggregate.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/api_key.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/base.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/basic.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/bearer.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/login.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/caching.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/client.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/config.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/contracts.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/errors.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/factory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/mapping.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/memory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/payload.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/populate.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/provider.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/registry.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/resolver.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/retry.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/registration.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/cache_settings.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/constants.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/repo_settings.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/contracts.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/errors.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/factory.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/local.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/media.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/routes.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/s3.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-3.11.0 → persistence_kit-3.13.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.13.0
|
|
4
4
|
Summary: Reusable persistence and repository toolkit
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -21,6 +21,8 @@ Classifier: Typing :: Typed
|
|
|
21
21
|
Provides-Extra: all
|
|
22
22
|
Provides-Extra: api
|
|
23
23
|
Provides-Extra: dynamodb
|
|
24
|
+
Provides-Extra: encrypted
|
|
25
|
+
Provides-Extra: mail
|
|
24
26
|
Provides-Extra: restclient
|
|
25
27
|
Provides-Extra: security
|
|
26
28
|
Provides-Extra: security-cognito
|
|
@@ -29,8 +31,10 @@ Provides-Extra: storage-s3
|
|
|
29
31
|
Provides-Extra: testing
|
|
30
32
|
Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
|
|
31
33
|
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:
|
|
34
|
+
Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
|
|
35
|
+
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
36
|
Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
|
|
37
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
|
|
34
38
|
Requires-Dist: motor (>=3.7.1,<4.0.0)
|
|
35
39
|
Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
|
|
36
40
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
|
|
@@ -149,7 +153,9 @@ pip install "persistence-kit[api,security,restclient]"
|
|
|
149
153
|
| `storage-routes` | FastAPI route to serve local exports | fastapi, python-multipart |
|
|
150
154
|
| `dynamodb` | DynamoDB repository backend | boto3 |
|
|
151
155
|
| `restclient` | The REST client and its cache | httpx |
|
|
152
|
-
| `
|
|
156
|
+
| `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
|
|
157
|
+
| `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
|
|
158
|
+
| `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
|
|
153
159
|
| `all` | Every optional capability | all of the above |
|
|
154
160
|
|
|
155
161
|
Importing `persistence_kit` never loads an optional dependency by itself. Ask for
|
|
@@ -165,11 +171,12 @@ add, not an obscure `ImportError`.
|
|
|
165
171
|
| `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
|
|
166
172
|
| `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
|
|
167
173
|
| `storage/` | Object storage: local directory or S3, with presigned URLs |
|
|
168
|
-
| `security/` | Identity providers and JWT verifiers, memory or Cognito |
|
|
174
|
+
| `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
|
|
169
175
|
| `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
|
|
170
176
|
| `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
|
|
177
|
+
| `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
|
|
171
178
|
| `resilience/` | Circuit breaker, shared by anything that calls out |
|
|
172
|
-
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
|
|
179
|
+
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
|
|
173
180
|
| `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
|
|
174
181
|
| `utils/` | Small transversal helpers such as upserts |
|
|
175
182
|
|
|
@@ -231,6 +238,65 @@ application. The kit only answers "who is this".
|
|
|
231
238
|
|
|
232
239
|
Needs `[security]`, or `[security-cognito]` for Cognito.
|
|
233
240
|
|
|
241
|
+
## Encrypted payloads
|
|
242
|
+
|
|
243
|
+
For data that should not be readable between the browser and your handler, not
|
|
244
|
+
even to a proxy that terminates TLS.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from fastapi import APIRouter, FastAPI
|
|
248
|
+
|
|
249
|
+
from persistence_kit import (
|
|
250
|
+
EncryptedResponseMiddleware,
|
|
251
|
+
build_encrypted_route,
|
|
252
|
+
encrypted,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
router = APIRouter(route_class=build_encrypted_route(get_settings))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
|
|
259
|
+
async def login(payload: LoginRequest) -> LoginResponse:
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
app = FastAPI()
|
|
264
|
+
app.add_middleware(EncryptedResponseMiddleware)
|
|
265
|
+
app.include_router(router)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The client generates one AES-256-GCM key per request, wraps it with the server's
|
|
269
|
+
public key, and sends it inside the envelope. The route unwraps it and opens the
|
|
270
|
+
payload before your handler runs; the middleware encrypts whatever comes back with
|
|
271
|
+
the same key, including the errors your own `exception_handler` builds. Your
|
|
272
|
+
handler never sees an envelope, and both pieces are required.
|
|
273
|
+
|
|
274
|
+
Two modes. `encrypted()` encrypts the whole body and the whole response.
|
|
275
|
+
`encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
|
|
276
|
+
stays readable; paths may be nested and may cross lists, as in `items[].price`.
|
|
277
|
+
Those paths are validated when the route is mounted — against your Pydantic body
|
|
278
|
+
when there is one — so a typo fails at startup rather than quietly leaving the
|
|
279
|
+
field unencrypted.
|
|
280
|
+
|
|
281
|
+
By default the private key is generated in memory at startup, so encrypted routes
|
|
282
|
+
work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
|
|
283
|
+
from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
|
|
284
|
+
KMS. Clients fetch the matching public key from your own endpoint, built on
|
|
285
|
+
`public_key_der_b64(provider)`.
|
|
286
|
+
|
|
287
|
+
Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
|
|
288
|
+
`CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
|
|
289
|
+
share. It warns and starts anyway.
|
|
290
|
+
|
|
291
|
+
Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
|
|
292
|
+
accepted once, so a captured request cannot be replayed. Anything malformed,
|
|
293
|
+
expired, replayed or tampered with comes back as a 400.
|
|
294
|
+
|
|
295
|
+
In `local` stage a plain body is allowed through, so you can call your API from
|
|
296
|
+
Swagger while developing.
|
|
297
|
+
|
|
298
|
+
Needs `[encrypted]`.
|
|
299
|
+
|
|
234
300
|
## REST client and cache
|
|
235
301
|
|
|
236
302
|
For calling somebody else's HTTP API without writing the same client again.
|
|
@@ -271,6 +337,69 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
|
|
|
271
337
|
|
|
272
338
|
Needs `[restclient]`.
|
|
273
339
|
|
|
340
|
+
## Mail
|
|
341
|
+
|
|
342
|
+
Sending mail from an application without it knowing which relay carries it.
|
|
343
|
+
The kit ships the machinery and no templates: the words belong to the app that
|
|
344
|
+
sends them.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
from persistence_kit.mail import MailMessage, build_mailer
|
|
348
|
+
|
|
349
|
+
mailer = build_mailer("app/templates/mail")
|
|
350
|
+
|
|
351
|
+
message = mailer.compose(
|
|
352
|
+
"user_roles_changed",
|
|
353
|
+
to="ana@udea.edu.co",
|
|
354
|
+
cc=["coordinacion@udea.edu.co"],
|
|
355
|
+
bcc="auditoria@udea.edu.co",
|
|
356
|
+
context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
|
|
357
|
+
)
|
|
358
|
+
mailer.dispatch(message)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
|
|
362
|
+
(required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
|
|
363
|
+
They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
|
|
364
|
+
and a variable missing from the context fails instead of rendering blank.
|
|
365
|
+
|
|
366
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
367
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
368
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
369
|
+
|
|
370
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
371
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
372
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
373
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
374
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
375
|
+
|
|
376
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
377
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
378
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
379
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
380
|
+
does the same for images the caller builds.
|
|
381
|
+
|
|
382
|
+
A template can carry a fourth file, `user_roles_changed.sample.json`, with an
|
|
383
|
+
example context. `MailTemplates.names()` lists the templates and
|
|
384
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
385
|
+
The same sample drives the preview, which renders without sending:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
392
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
393
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
394
|
+
|
|
395
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
396
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
397
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
398
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
399
|
+
|
|
400
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
401
|
+
library.
|
|
402
|
+
|
|
274
403
|
## Resilience
|
|
275
404
|
|
|
276
405
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -295,7 +424,7 @@ class Settings(PersistenceKitSettings):
|
|
|
295
424
|
```
|
|
296
425
|
|
|
297
426
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
298
|
-
`get_export_storage`, `get_cache`.
|
|
427
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
299
428
|
|
|
300
429
|
### Environment variables
|
|
301
430
|
|
|
@@ -310,6 +439,15 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
310
439
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
|
|
311
440
|
| `POSTGRES_SSL` | Set when the server requires TLS |
|
|
312
441
|
| `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
|
|
442
|
+
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
443
|
+
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
444
|
+
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
445
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
446
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
447
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
448
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
449
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
450
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
313
451
|
|
|
314
452
|
## Wiring it into an application
|
|
315
453
|
|
|
@@ -433,11 +571,11 @@ poetry install --with dev --all-extras
|
|
|
433
571
|
poetry run pytest -q
|
|
434
572
|
```
|
|
435
573
|
|
|
436
|
-
Current baseline: **
|
|
574
|
+
Current baseline: **621 tests passing** (version 3.13.0). Async tests use
|
|
437
575
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
438
576
|
|
|
439
577
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
440
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
578
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
441
579
|
|
|
442
580
|
## Releasing
|
|
443
581
|
|
|
@@ -471,6 +609,7 @@ every iteration.
|
|
|
471
609
|
|
|
472
610
|
- [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
|
|
473
611
|
- [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
|
|
612
|
+
- [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
|
|
474
613
|
|
|
475
614
|
Author: Andres Felipe Serrano Barrios · [github.com/AndresFSerrano/persistence-kit](https://github.com/AndresFSerrano/persistence-kit)
|
|
476
615
|
|
|
@@ -105,7 +105,9 @@ 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
|
+
| `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
|
|
110
|
+
| `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
|
|
109
111
|
| `all` | Every optional capability | all of the above |
|
|
110
112
|
|
|
111
113
|
Importing `persistence_kit` never loads an optional dependency by itself. Ask for
|
|
@@ -121,11 +123,12 @@ add, not an obscure `ImportError`.
|
|
|
121
123
|
| `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
|
|
122
124
|
| `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
|
|
123
125
|
| `storage/` | Object storage: local directory or S3, with presigned URLs |
|
|
124
|
-
| `security/` | Identity providers and JWT verifiers, memory or Cognito |
|
|
126
|
+
| `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
|
|
125
127
|
| `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
|
|
126
128
|
| `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
|
|
129
|
+
| `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
|
|
127
130
|
| `resilience/` | Circuit breaker, shared by anything that calls out |
|
|
128
|
-
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
|
|
131
|
+
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
|
|
129
132
|
| `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
|
|
130
133
|
| `utils/` | Small transversal helpers such as upserts |
|
|
131
134
|
|
|
@@ -187,6 +190,65 @@ application. The kit only answers "who is this".
|
|
|
187
190
|
|
|
188
191
|
Needs `[security]`, or `[security-cognito]` for Cognito.
|
|
189
192
|
|
|
193
|
+
## Encrypted payloads
|
|
194
|
+
|
|
195
|
+
For data that should not be readable between the browser and your handler, not
|
|
196
|
+
even to a proxy that terminates TLS.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from fastapi import APIRouter, FastAPI
|
|
200
|
+
|
|
201
|
+
from persistence_kit import (
|
|
202
|
+
EncryptedResponseMiddleware,
|
|
203
|
+
build_encrypted_route,
|
|
204
|
+
encrypted,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
router = APIRouter(route_class=build_encrypted_route(get_settings))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
|
|
211
|
+
async def login(payload: LoginRequest) -> LoginResponse:
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
app = FastAPI()
|
|
216
|
+
app.add_middleware(EncryptedResponseMiddleware)
|
|
217
|
+
app.include_router(router)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The client generates one AES-256-GCM key per request, wraps it with the server's
|
|
221
|
+
public key, and sends it inside the envelope. The route unwraps it and opens the
|
|
222
|
+
payload before your handler runs; the middleware encrypts whatever comes back with
|
|
223
|
+
the same key, including the errors your own `exception_handler` builds. Your
|
|
224
|
+
handler never sees an envelope, and both pieces are required.
|
|
225
|
+
|
|
226
|
+
Two modes. `encrypted()` encrypts the whole body and the whole response.
|
|
227
|
+
`encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
|
|
228
|
+
stays readable; paths may be nested and may cross lists, as in `items[].price`.
|
|
229
|
+
Those paths are validated when the route is mounted — against your Pydantic body
|
|
230
|
+
when there is one — so a typo fails at startup rather than quietly leaving the
|
|
231
|
+
field unencrypted.
|
|
232
|
+
|
|
233
|
+
By default the private key is generated in memory at startup, so encrypted routes
|
|
234
|
+
work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
|
|
235
|
+
from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
|
|
236
|
+
KMS. Clients fetch the matching public key from your own endpoint, built on
|
|
237
|
+
`public_key_der_b64(provider)`.
|
|
238
|
+
|
|
239
|
+
Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
|
|
240
|
+
`CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
|
|
241
|
+
share. It warns and starts anyway.
|
|
242
|
+
|
|
243
|
+
Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
|
|
244
|
+
accepted once, so a captured request cannot be replayed. Anything malformed,
|
|
245
|
+
expired, replayed or tampered with comes back as a 400.
|
|
246
|
+
|
|
247
|
+
In `local` stage a plain body is allowed through, so you can call your API from
|
|
248
|
+
Swagger while developing.
|
|
249
|
+
|
|
250
|
+
Needs `[encrypted]`.
|
|
251
|
+
|
|
190
252
|
## REST client and cache
|
|
191
253
|
|
|
192
254
|
For calling somebody else's HTTP API without writing the same client again.
|
|
@@ -227,6 +289,69 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
|
|
|
227
289
|
|
|
228
290
|
Needs `[restclient]`.
|
|
229
291
|
|
|
292
|
+
## Mail
|
|
293
|
+
|
|
294
|
+
Sending mail from an application without it knowing which relay carries it.
|
|
295
|
+
The kit ships the machinery and no templates: the words belong to the app that
|
|
296
|
+
sends them.
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from persistence_kit.mail import MailMessage, build_mailer
|
|
300
|
+
|
|
301
|
+
mailer = build_mailer("app/templates/mail")
|
|
302
|
+
|
|
303
|
+
message = mailer.compose(
|
|
304
|
+
"user_roles_changed",
|
|
305
|
+
to="ana@udea.edu.co",
|
|
306
|
+
cc=["coordinacion@udea.edu.co"],
|
|
307
|
+
bcc="auditoria@udea.edu.co",
|
|
308
|
+
context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
|
|
309
|
+
)
|
|
310
|
+
mailer.dispatch(message)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
|
|
314
|
+
(required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
|
|
315
|
+
They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
|
|
316
|
+
and a variable missing from the context fails instead of rendering blank.
|
|
317
|
+
|
|
318
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
319
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
320
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
321
|
+
|
|
322
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
323
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
324
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
325
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
326
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
327
|
+
|
|
328
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
329
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
330
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
331
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
332
|
+
does the same for images the caller builds.
|
|
333
|
+
|
|
334
|
+
A template can carry a fourth file, `user_roles_changed.sample.json`, with an
|
|
335
|
+
example context. `MailTemplates.names()` lists the templates and
|
|
336
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
337
|
+
The same sample drives the preview, which renders without sending:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
344
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
345
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
346
|
+
|
|
347
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
348
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
349
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
350
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
351
|
+
|
|
352
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
353
|
+
library.
|
|
354
|
+
|
|
230
355
|
## Resilience
|
|
231
356
|
|
|
232
357
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -251,7 +376,7 @@ class Settings(PersistenceKitSettings):
|
|
|
251
376
|
```
|
|
252
377
|
|
|
253
378
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
254
|
-
`get_export_storage`, `get_cache`.
|
|
379
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
255
380
|
|
|
256
381
|
### Environment variables
|
|
257
382
|
|
|
@@ -266,6 +391,15 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
266
391
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
|
|
267
392
|
| `POSTGRES_SSL` | Set when the server requires TLS |
|
|
268
393
|
| `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
|
|
394
|
+
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
395
|
+
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
396
|
+
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
397
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
398
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
399
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
400
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
401
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
402
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
269
403
|
|
|
270
404
|
## Wiring it into an application
|
|
271
405
|
|
|
@@ -389,11 +523,11 @@ poetry install --with dev --all-extras
|
|
|
389
523
|
poetry run pytest -q
|
|
390
524
|
```
|
|
391
525
|
|
|
392
|
-
Current baseline: **
|
|
526
|
+
Current baseline: **621 tests passing** (version 3.13.0). Async tests use
|
|
393
527
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
394
528
|
|
|
395
529
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
396
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
530
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
397
531
|
|
|
398
532
|
## Releasing
|
|
399
533
|
|
|
@@ -427,5 +561,6 @@ every iteration.
|
|
|
427
561
|
|
|
428
562
|
- [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
|
|
429
563
|
- [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
|
|
564
|
+
- [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
|
|
430
565
|
|
|
431
566
|
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)
|