From 551b42eab55985087762c80f2d972bbd7c65ebb1 Mon Sep 17 00:00:00 2001 From: jcv-dev Date: Thu, 24 Sep 2026 10:41:21 -0500 Subject: [PATCH] feat: production-hardening pass across the kit Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users//permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts --- .coverage | Bin 86016 -> 0 bytes .github/workflows/ci.yml | 3 + .gitignore | 32 ++ .../invoice_INV-2026-000002.pdf | 74 --- .../invoice_INV-2026-000001.pdf | 74 --- .../invoice_INV-2026-000001.pdf | 74 --- .../invoice_INV-2026-000001.pdf | 74 --- AGENTS.md | 88 +++- CHANGELOG.md | 62 +++ PLAN.md | 440 ++++++++++++++-- README.md | 90 ++++ .../invoice_INV-2026-000001.pdf | 74 --- config/__pycache__/__init__.cpython-314.pyc | Bin 240 -> 0 bytes config/__pycache__/celery.cpython-314.pyc | Bin 585 -> 0 bytes config/__pycache__/urls.cpython-314.pyc | Bin 1150 -> 0 bytes config/celery.py | 5 +- .../__pycache__/__init__.cpython-314.pyc | Bin 369 -> 0 bytes .../settings/__pycache__/base.cpython-314.pyc | Bin 8717 -> 0 bytes .../settings/__pycache__/dev.cpython-314.pyc | Bin 580 -> 0 bytes .../settings/__pycache__/test.cpython-314.pyc | Bin 800 -> 0 bytes config/settings/base.py | 109 +++- config/settings/test.py | 25 + config/urls.py | 20 +- .../invoice_INV-2026-000001.pdf | 74 --- docs/04dd318c64774fb08ece1060dbf8c7d0/img.png | Bin 1061 -> 0 bytes docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png | Bin 1061 -> 0 bytes .../2e5d7892b2d345dba141852e5b9d88f2/test.txt | 1 - docs/380be0205e7d4da7b718e96cb62ae2b4/img.png | Bin 1061 -> 0 bytes .../4b2e6d2c2c044354a29b53dd6b29998c/test.txt | 1 - .../513a00e428e848e8a77d007243a0f88e/test.txt | 1 - docs/569c1d2348dd42348e16221e92268ded/img.png | Bin 1061 -> 0 bytes .../5a05d7c2edca4d509722b05f57114874/test.txt | 1 - docs/60ac6c34c5c4442f9b2db03580f3073e/img.png | Bin 1061 -> 0 bytes docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png | Bin 1061 -> 0 bytes docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png | Bin 1061 -> 0 bytes docs/98fd1b7317fc46668f945560910d0d77/img.png | Bin 1061 -> 0 bytes .../a3f2d4ebbc87485d80544891363ca5b6/test.txt | 1 - .../c008ebbc2a974fa69a459e9d002f2ca8/test.txt | 1 - .../cfcb245ce6114826bd0063bef7b58075/test.txt | 1 - .../e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt | 1 - .../f454df5e834c4db2a9824a6387d7115d/test.txt | 1 - .../__pycache__/__init__.cpython-314.pyc | Bin 245 -> 0 bytes infrasynth/api/__init__.py | 11 + infrasynth/api/exceptions.py | 117 +++++ infrasynth/api/idempotency.py | 56 ++ infrasynth/api/middleware.py | 40 ++ infrasynth/api/pagination.py | 34 ++ infrasynth/api/renderers.py | 58 ++ infrasynth/api/throttling.py | 58 ++ infrasynth/api/urls.py | 13 + infrasynth/api/webhooks.py | 34 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 174 -> 0 bytes .../audit/__pycache__/apps.cpython-314.pyc | Bin 1091 -> 0 bytes .../audit/__pycache__/filters.cpython-314.pyc | Bin 2051 -> 0 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 4227 -> 0 bytes .../audit/__pycache__/models.cpython-314.pyc | Bin 4125 -> 0 bytes .../__pycache__/receivers.cpython-314.pyc | Bin 5851 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 2035 -> 0 bytes .../audit/__pycache__/signals.cpython-314.pyc | Bin 305 -> 0 bytes .../audit/__pycache__/urls.cpython-314.pyc | Bin 835 -> 0 bytes .../audit/__pycache__/views.cpython-314.pyc | Bin 3518 -> 0 bytes infrasynth/audit/middleware.py | 36 +- infrasynth/audit/migrations/0001_initial.py | 78 ++- .../__pycache__/0001_initial.cpython-314.pyc | Bin 4314 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 185 -> 0 bytes infrasynth/audit/models.py | 38 +- infrasynth/audit/receivers.py | 46 +- infrasynth/audit/tasks.py | 38 ++ infrasynth/audit/views.py | 51 +- .../__pycache__/__init__.cpython-314.pyc | Bin 176 -> 0 bytes .../billing/__pycache__/apps.cpython-314.pyc | Bin 1056 -> 0 bytes .../__pycache__/filters.cpython-314.pyc | Bin 2752 -> 0 bytes .../invoice_generator.cpython-314.pyc | Bin 6289 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 8339 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 7551 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 9564 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 512 -> 0 bytes .../billing/__pycache__/urls.cpython-314.pyc | Bin 1267 -> 0 bytes .../billing/__pycache__/views.cpython-314.pyc | Bin 10362 -> 0 bytes infrasynth/billing/entitlements.py | 101 ++++ infrasynth/billing/filters.py | 43 +- .../__pycache__/__init__.cpython-314.pyc | Bin 185 -> 0 bytes .../gateways/__pycache__/base.cpython-314.pyc | Bin 3551 -> 0 bytes .../__pycache__/mercadopago.cpython-314.pyc | Bin 9525 -> 0 bytes .../__pycache__/stripe.cpython-314.pyc | Bin 9481 -> 0 bytes .../__pycache__/wompi.cpython-314.pyc | Bin 10627 -> 0 bytes infrasynth/billing/gateways/base.py | 2 +- infrasynth/billing/gateways/mercadopago.py | 35 +- infrasynth/billing/gateways/stripe.py | 9 +- infrasynth/billing/gateways/wompi.py | 4 +- infrasynth/billing/invoice_generator.py | 66 +-- infrasynth/billing/migrations/0001_initial.py | 300 +++++++---- .../__pycache__/0001_initial.cpython-314.pyc | Bin 7962 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 187 -> 0 bytes infrasynth/billing/models.py | 149 ++++-- infrasynth/billing/serializers.py | 131 +++-- infrasynth/billing/services.py | 269 ++++++++-- infrasynth/billing/signals.py | 20 +- infrasynth/billing/tasks.py | 156 ++++++ infrasynth/billing/urls.py | 18 +- infrasynth/billing/views.py | 247 +++++---- .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes .../features/__pycache__/apps.cpython-314.pyc | Bin 1028 -> 0 bytes .../__pycache__/decorators.cpython-314.pyc | Bin 1330 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 2712 -> 0 bytes .../__pycache__/registry.cpython-314.pyc | Bin 2874 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 1471 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 6421 -> 0 bytes .../features/__pycache__/urls.cpython-314.pyc | Bin 697 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 2800 -> 0 bytes infrasynth/features/apps.py | 6 + .../features/migrations/0001_initial.py | 46 +- .../__pycache__/0001_initial.cpython-314.pyc | Bin 3255 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 188 -> 0 bytes infrasynth/features/models.py | 24 +- infrasynth/features/services.py | 89 +++- infrasynth/features/views.py | 28 +- .../__pycache__/__init__.cpython-314.pyc | Bin 174 -> 0 bytes .../files/__pycache__/apps.cpython-314.pyc | Bin 1021 -> 0 bytes .../files/__pycache__/models.cpython-314.pyc | Bin 5144 -> 0 bytes .../__pycache__/processing.cpython-314.pyc | Bin 9908 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 2693 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 12803 -> 0 bytes .../files/__pycache__/signals.cpython-314.pyc | Bin 330 -> 0 bytes .../files/__pycache__/storage.cpython-314.pyc | Bin 14071 -> 0 bytes .../files/__pycache__/urls.cpython-314.pyc | Bin 803 -> 0 bytes .../files/__pycache__/views.cpython-314.pyc | Bin 4204 -> 0 bytes infrasynth/files/migrations/0001_initial.py | 67 ++- .../__pycache__/0001_initial.cpython-314.pyc | Bin 4975 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 185 -> 0 bytes infrasynth/files/models.py | 28 +- infrasynth/files/processing.py | 19 +- infrasynth/files/scanner.py | 83 +++ infrasynth/files/services.py | 9 +- .../__pycache__/__init__.cpython-314.pyc | Bin 182 -> 0 bytes .../__pycache__/apps.cpython-314.pyc | Bin 1086 -> 0 bytes .../__pycache__/filters.cpython-314.pyc | Bin 2069 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 4527 -> 0 bytes .../__pycache__/resolvers.cpython-314.pyc | Bin 3477 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 2225 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 14869 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 314 -> 0 bytes .../__pycache__/tasks.cpython-314.pyc | Bin 2528 -> 0 bytes .../__pycache__/urls.cpython-314.pyc | Bin 873 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 2702 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 191 -> 0 bytes .../channels/__pycache__/base.cpython-314.pyc | Bin 2525 -> 0 bytes .../email_sendgrid.cpython-314.pyc | Bin 4854 -> 0 bytes .../__pycache__/email_smtp.cpython-314.pyc | Bin 5281 -> 0 bytes .../__pycache__/sms_twilio.cpython-314.pyc | Bin 4317 -> 0 bytes .../__pycache__/telegram.cpython-314.pyc | Bin 4378 -> 0 bytes .../notifications/migrations/0001_initial.py | 85 ++- .../__pycache__/0001_initial.cpython-314.pyc | Bin 4287 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 193 -> 0 bytes infrasynth/notifications/models.py | 28 +- infrasynth/notifications/services.py | 51 +- infrasynth/notifications/tasks.py | 96 +++- .../__pycache__/__init__.cpython-314.pyc | Bin 178 -> 0 bytes .../__pycache__/apps.cpython-314.pyc | Bin 1044 -> 0 bytes .../__pycache__/filters.cpython-314.pyc | Bin 1385 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 3934 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 1976 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 8911 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 369 -> 0 bytes .../__pycache__/urls.cpython-314.pyc | Bin 830 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 5241 -> 0 bytes .../scheduler/migrations/0001_initial.py | 34 +- .../__pycache__/0001_initial.cpython-314.pyc | Bin 3027 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 189 -> 0 bytes infrasynth/scheduler/models.py | 10 +- infrasynth/scheduler/services.py | 34 +- .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes .../security/__pycache__/apps.cpython-314.pyc | Bin 1056 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 7159 -> 0 bytes .../__pycache__/permissions.cpython-314.pyc | Bin 2320 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 4945 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 6096 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 566 -> 0 bytes .../security/__pycache__/urls.cpython-314.pyc | Bin 2300 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 19786 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 184 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 3695 -> 0 bytes infrasynth/security/altcha/services.py | 31 +- .../auth/__pycache__/__init__.cpython-314.pyc | Bin 182 -> 0 bytes .../auth/__pycache__/api_keys.cpython-314.pyc | Bin 3334 -> 0 bytes .../auth/__pycache__/backends.cpython-314.pyc | Bin 1558 -> 0 bytes .../auth/__pycache__/cookies.cpython-314.pyc | Bin 1573 -> 0 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 1279 -> 0 bytes infrasynth/security/auth/api_keys.py | 18 +- .../security/migrations/0001_initial.py | 156 ++++-- .../migrations/0002_add_role_users_m2m.py | 21 - .../__pycache__/0001_initial.cpython-314.pyc | Bin 6454 -> 0 bytes .../0002_add_role_users_m2m.cpython-314.pyc | Bin 1080 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 188 -> 0 bytes infrasynth/security/models.py | 38 +- infrasynth/security/password_validation.py | 51 ++ infrasynth/security/permissions.py | 75 ++- infrasynth/security/services.py | 23 +- infrasynth/security/throttling.py | 82 +++ .../__pycache__/__init__.cpython-314.pyc | Bin 188 -> 0 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 1910 -> 0 bytes .../__pycache__/services.cpython-314.pyc | Bin 7149 -> 0 bytes .../__pycache__/utils.cpython-314.pyc | Bin 1108 -> 0 bytes infrasynth/security/two_factor/middleware.py | 49 +- infrasynth/security/urls.py | 21 + infrasynth/security/views.py | 495 +++++++++++++----- .../__pycache__/__init__.cpython-314.pyc | Bin 175 -> 0 bytes .../shared/__pycache__/crypto.cpython-314.pyc | Bin 3950 -> 0 bytes .../shared/__pycache__/enums.cpython-314.pyc | Bin 2857 -> 0 bytes .../__pycache__/protocols.cpython-314.pyc | Bin 1937 -> 0 bytes .../__pycache__/results.cpython-314.pyc | Bin 2501 -> 0 bytes .../settings_utils.cpython-314.pyc | Bin 872 -> 0 bytes infrasynth/shared/enums.py | 22 + infrasynth/shared/exceptions.py | 147 ++++++ infrasynth/shared/protocols.py | 9 +- .../tenancy/__init__.py | 0 infrasynth/tenancy/apps.py | 25 + infrasynth/tenancy/context.py | 54 ++ infrasynth/tenancy/filters.py | 35 ++ infrasynth/tenancy/managers.py | 69 +++ infrasynth/tenancy/middleware.py | 137 +++++ infrasynth/tenancy/migrations/0001_initial.py | 131 +++++ infrasynth/tenancy/migrations/__init__.py | 0 infrasynth/tenancy/mixins.py | 71 +++ infrasynth/tenancy/models.py | 119 +++++ infrasynth/tenancy/serializers.py | 37 ++ infrasynth/tenancy/services.py | 215 ++++++++ infrasynth/tenancy/signals.py | 27 + infrasynth/tenancy/urls.py | 13 + infrasynth/tenancy/views.py | 102 ++++ .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes .../webhooks/__pycache__/apps.cpython-314.pyc | Bin 1010 -> 0 bytes .../__pycache__/dispatch.cpython-314.pyc | Bin 7077 -> 0 bytes .../__pycache__/filters.cpython-314.pyc | Bin 2881 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 7652 -> 0 bytes .../__pycache__/registry.cpython-314.pyc | Bin 3965 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 3740 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 370 -> 0 bytes .../__pycache__/signature.cpython-314.pyc | Bin 2429 -> 0 bytes .../webhooks/__pycache__/urls.cpython-314.pyc | Bin 1531 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 5026 -> 0 bytes infrasynth/webhooks/dispatch.py | 71 ++- .../__pycache__/__init__.cpython-314.pyc | Bin 185 -> 0 bytes .../__pycache__/handlers.cpython-314.pyc | Bin 1324 -> 0 bytes infrasynth/webhooks/inbound/handlers.py | 56 +- .../webhooks/migrations/0001_initial.py | 112 +++- ...xternal_id_inboundevent_result_and_more.py | 36 ++ .../__pycache__/0001_initial.cpython-314.pyc | Bin 5591 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 188 -> 0 bytes infrasynth/webhooks/models.py | 38 +- infrasynth/webhooks/registry.py | 26 +- infrasynth/webhooks/signature.py | 9 +- infrasynth/webhooks/views.py | 126 ++++- .../__pycache__/__init__.cpython-314.pyc | Bin 178 -> 0 bytes .../__pycache__/apps.cpython-314.pyc | Bin 1050 -> 0 bytes .../__pycache__/engine.cpython-314.pyc | Bin 15609 -> 0 bytes .../__pycache__/filters.cpython-314.pyc | Bin 3347 -> 0 bytes .../__pycache__/models.cpython-314.pyc | Bin 9493 -> 0 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 8071 -> 0 bytes .../__pycache__/signals.cpython-314.pyc | Bin 427 -> 0 bytes .../__pycache__/urls.cpython-314.pyc | Bin 1210 -> 0 bytes .../__pycache__/validators.cpython-314.pyc | Bin 1880 -> 0 bytes .../__pycache__/views.cpython-314.pyc | Bin 13292 -> 0 bytes infrasynth/workflows/engine.py | 41 +- .../workflows/migrations/0001_initial.py | 142 +++-- .../__pycache__/0001_initial.cpython-314.pyc | Bin 7270 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 189 -> 0 bytes infrasynth/workflows/models.py | 32 +- infrasynth/workflows/views.py | 16 +- infrasynth_base.egg-info/PKG-INFO | 37 -- infrasynth_base.egg-info/SOURCES.txt | 150 ------ infrasynth_base.egg-info/requires.txt | 32 -- infrasynth_base.egg-info/top_level.txt | 1 - pyproject.toml | 65 ++- tests/__pycache__/__init__.cpython-314.pyc | Bin 163 -> 0 bytes .../conftest.cpython-314-pytest-9.1.1.pyc | Bin 4234 -> 0 bytes tests/__pycache__/helpers.cpython-314.pyc | Bin 3944 -> 0 bytes ...est_debug_tmp.cpython-314-pytest-9.1.1.pyc | Bin 2223 -> 0 bytes .../test_e2e.cpython-314-pytest-9.1.1.pyc | Bin 33747 -> 0 bytes tests/conftest.py | 63 ++- tests/helpers.py | 5 + tests/test_api/test_conventions.py | 94 ++++ tests/test_api/test_renderers.py | 64 +++ .../__pycache__/__init__.cpython-314.pyc | Bin 174 -> 0 bytes ...st_middleware.cpython-314-pytest-9.1.1.pyc | Bin 20085 -> 0 bytes ...est_receivers.cpython-314-pytest-9.1.1.pyc | Bin 25846 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 22060 -> 0 bytes tests/test_audit/test_middleware.py | 38 +- tests/test_audit/test_receivers.py | 9 +- tests/test_audit/test_views.py | 46 +- .../__pycache__/__init__.cpython-314.pyc | Bin 176 -> 0 bytes ...test_gateways.cpython-314-pytest-9.1.1.pyc | Bin 52079 -> 0 bytes ...t_integration.cpython-314-pytest-9.1.1.pyc | Bin 28357 -> 0 bytes ...ice_generator.cpython-314-pytest-9.1.1.pyc | Bin 10709 -> 0 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 18958 -> 0 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 43285 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 37675 -> 0 bytes tests/test_billing/test_entitlements.py | 108 ++++ tests/test_billing/test_gateways.py | 2 +- tests/test_billing/test_integration.py | 51 +- tests/test_billing/test_invoice_generator.py | 45 +- tests/test_billing/test_models.py | 112 ++-- tests/test_billing/test_services.py | 170 ++++-- tests/test_billing/test_views.py | 98 ++-- tests/test_e2e.py | 2 +- .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes ...st_decorators.cpython-314-pytest-9.1.1.pyc | Bin 4296 -> 0 bytes ...test_registry.cpython-314-pytest-9.1.1.pyc | Bin 8544 -> 0 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 24062 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 13171 -> 0 bytes tests/test_features/test_services.py | 51 +- tests/test_features/test_views.py | 30 +- .../__pycache__/__init__.cpython-314.pyc | Bin 174 -> 0 bytes ...st_processing.cpython-314-pytest-9.1.1.pyc | Bin 20607 -> 0 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 45273 -> 0 bytes .../test_storage.cpython-314-pytest-9.1.1.pyc | Bin 16144 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 25182 -> 0 bytes tests/test_files/test_scanner.py | 34 ++ tests/test_files/test_services.py | 2 +- tests/test_files/test_views.py | 6 +- .../__pycache__/__init__.cpython-314.pyc | Bin 182 -> 0 bytes ...test_channels.cpython-314-pytest-9.1.1.pyc | Bin 41201 -> 0 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 12337 -> 0 bytes ...est_resolvers.cpython-314-pytest-9.1.1.pyc | Bin 14631 -> 0 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 36125 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 25095 -> 0 bytes tests/test_notifications/test_models.py | 5 +- tests/test_notifications/test_tasks.py | 57 ++ tests/test_notifications/test_views.py | 6 +- .../__pycache__/__init__.cpython-314.pyc | Bin 178 -> 0 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 33091 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 28346 -> 0 bytes tests/test_scheduler/test_views.py | 6 +- .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes .../test_altcha.cpython-314-pytest-9.1.1.pyc | Bin 26650 -> 0 bytes ...test_api_keys.cpython-314-pytest-9.1.1.pyc | Bin 17437 -> 0 bytes .../test_auth.cpython-314-pytest-9.1.1.pyc | Bin 50570 -> 0 bytes ...authorization.cpython-314-pytest-9.1.1.pyc | Bin 80328 -> 0 bytes ...st_two_factor.cpython-314-pytest-9.1.1.pyc | Bin 54552 -> 0 bytes ...or_middleware.cpython-314-pytest-9.1.1.pyc | Bin 17202 -> 0 bytes tests/test_security/test_altcha.py | 33 +- tests/test_security/test_api_keys.py | 35 +- tests/test_security/test_auth.py | 28 +- tests/test_security/test_authorization.py | 76 ++- tests/test_security/test_two_factor.py | 87 ++- .../test_two_factor_middleware.py | 43 +- tests/test_security/test_workspace.py | 83 +++ .../__pycache__/__init__.cpython-314.pyc | Bin 175 -> 0 bytes .../test_crypto.cpython-314-pytest-9.1.1.pyc | Bin 22328 -> 0 bytes .../test_enums.cpython-314-pytest-9.1.1.pyc | Bin 34706 -> 0 bytes ...est_protocols.cpython-314-pytest-9.1.1.pyc | Bin 20532 -> 0 bytes .../test_results.cpython-314-pytest-9.1.1.pyc | Bin 19724 -> 0 bytes ...ettings_utils.cpython-314-pytest-9.1.1.pyc | Bin 7268 -> 0 bytes tests/test_tenancy/test_isolation.py | 81 +++ tests/test_tenancy/test_managers.py | 80 +++ tests/test_tenancy/test_middleware.py | 78 +++ tests/test_tenancy/test_services.py | 93 ++++ .../__pycache__/__init__.cpython-314.pyc | Bin 177 -> 0 bytes ...test_dispatch.cpython-314-pytest-9.1.1.pyc | Bin 25591 -> 0 bytes .../test_inbound.cpython-314-pytest-9.1.1.pyc | Bin 5066 -> 0 bytes ...t_integration.cpython-314-pytest-9.1.1.pyc | Bin 38576 -> 0 bytes ...test_registry.cpython-314-pytest-9.1.1.pyc | Bin 19191 -> 0 bytes ...est_signature.cpython-314-pytest-9.1.1.pyc | Bin 15569 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 41106 -> 0 bytes tests/test_webhooks/test_integration.py | 12 +- tests/test_webhooks/test_views.py | 81 +-- .../__pycache__/__init__.cpython-314.pyc | Bin 178 -> 0 bytes .../test_engine.cpython-314-pytest-9.1.1.pyc | Bin 65425 -> 0 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 18581 -> 0 bytes ...st_validators.cpython-314-pytest-9.1.1.pyc | Bin 10893 -> 0 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 45695 -> 0 bytes tests/test_workflows/test_views.py | 32 +- 372 files changed, 7523 insertions(+), 2008 deletions(-) delete mode 100644 .coverage create mode 100644 .gitignore delete mode 100644 04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf delete mode 100644 141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf delete mode 100644 51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf delete mode 100644 6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf create mode 100644 CHANGELOG.md create mode 100644 README.md delete mode 100644 b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf delete mode 100644 config/__pycache__/__init__.cpython-314.pyc delete mode 100644 config/__pycache__/celery.cpython-314.pyc delete mode 100644 config/__pycache__/urls.cpython-314.pyc delete mode 100644 config/settings/__pycache__/__init__.cpython-314.pyc delete mode 100644 config/settings/__pycache__/base.cpython-314.pyc delete mode 100644 config/settings/__pycache__/dev.cpython-314.pyc delete mode 100644 config/settings/__pycache__/test.cpython-314.pyc delete mode 100644 d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf delete mode 100644 docs/04dd318c64774fb08ece1060dbf8c7d0/img.png delete mode 100644 docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png delete mode 100644 docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt delete mode 100644 docs/380be0205e7d4da7b718e96cb62ae2b4/img.png delete mode 100644 docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt delete mode 100644 docs/513a00e428e848e8a77d007243a0f88e/test.txt delete mode 100644 docs/569c1d2348dd42348e16221e92268ded/img.png delete mode 100644 docs/5a05d7c2edca4d509722b05f57114874/test.txt delete mode 100644 docs/60ac6c34c5c4442f9b2db03580f3073e/img.png delete mode 100644 docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png delete mode 100644 docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png delete mode 100644 docs/98fd1b7317fc46668f945560910d0d77/img.png delete mode 100644 docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt delete mode 100644 docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt delete mode 100644 docs/cfcb245ce6114826bd0063bef7b58075/test.txt delete mode 100644 docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt delete mode 100644 docs/f454df5e834c4db2a9824a6387d7115d/test.txt delete mode 100644 infrasynth/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/api/__init__.py create mode 100644 infrasynth/api/exceptions.py create mode 100644 infrasynth/api/idempotency.py create mode 100644 infrasynth/api/middleware.py create mode 100644 infrasynth/api/pagination.py create mode 100644 infrasynth/api/renderers.py create mode 100644 infrasynth/api/throttling.py create mode 100644 infrasynth/api/urls.py create mode 100644 infrasynth/api/webhooks.py delete mode 100644 infrasynth/audit/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/middleware.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/receivers.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/audit/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/audit/tasks.py delete mode 100644 infrasynth/billing/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/billing/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/billing/entitlements.py delete mode 100644 infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc delete mode 100644 infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc delete mode 100644 infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc delete mode 100644 infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc delete mode 100644 infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/billing/tasks.py delete mode 100644 infrasynth/features/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/decorators.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/registry.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/features/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/processing.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/storage.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/files/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/files/scanner.py delete mode 100644 infrasynth/notifications/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/tasks.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/notifications/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc delete mode 100644 infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc delete mode 100644 infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/scheduler/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/permissions.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/security/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/security/altcha/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc delete mode 100644 infrasynth/security/auth/__pycache__/backends.cpython-314.pyc delete mode 100644 infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc delete mode 100644 infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc delete mode 100644 infrasynth/security/migrations/0002_add_role_users_m2m.py delete mode 100644 infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc delete mode 100644 infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/password_validation.py create mode 100644 infrasynth/security/throttling.py delete mode 100644 infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc delete mode 100644 infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc delete mode 100644 infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/crypto.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/enums.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/protocols.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/results.cpython-314.pyc delete mode 100644 infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc create mode 100644 infrasynth/shared/exceptions.py rename infrasynth_base.egg-info/dependency_links.txt => infrasynth/tenancy/__init__.py (100%) create mode 100644 infrasynth/tenancy/apps.py create mode 100644 infrasynth/tenancy/context.py create mode 100644 infrasynth/tenancy/filters.py create mode 100644 infrasynth/tenancy/managers.py create mode 100644 infrasynth/tenancy/middleware.py create mode 100644 infrasynth/tenancy/migrations/0001_initial.py create mode 100644 infrasynth/tenancy/migrations/__init__.py create mode 100644 infrasynth/tenancy/mixins.py create mode 100644 infrasynth/tenancy/models.py create mode 100644 infrasynth/tenancy/serializers.py create mode 100644 infrasynth/tenancy/services.py create mode 100644 infrasynth/tenancy/signals.py create mode 100644 infrasynth/tenancy/urls.py create mode 100644 infrasynth/tenancy/views.py delete mode 100644 infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/registry.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/signature.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/webhooks/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc create mode 100644 infrasynth/webhooks/migrations/0002_inboundevent_external_id_inboundevent_result_and_more.py delete mode 100644 infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/apps.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/engine.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/filters.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/models.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/serializers.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/signals.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/urls.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/validators.cpython-314.pyc delete mode 100644 infrasynth/workflows/__pycache__/views.cpython-314.pyc delete mode 100644 infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc delete mode 100644 infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc delete mode 100644 infrasynth_base.egg-info/PKG-INFO delete mode 100644 infrasynth_base.egg-info/SOURCES.txt delete mode 100644 infrasynth_base.egg-info/requires.txt delete mode 100644 infrasynth_base.egg-info/top_level.txt delete mode 100644 tests/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/helpers.cpython-314.pyc delete mode 100644 tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_api/test_conventions.py create mode 100644 tests/test_api/test_renderers.py delete mode 100644 tests/test_audit/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/test_entitlements.py delete mode 100644 tests/test_features/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_files/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_files/test_scanner.py delete mode 100644 tests/test_notifications/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/test_tasks.py delete mode 100644 tests/test_scheduler/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/test_workspace.py delete mode 100644 tests/test_shared/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_tenancy/test_isolation.py create mode 100644 tests/test_tenancy/test_managers.py create mode 100644 tests/test_tenancy/test_middleware.py create mode 100644 tests/test_tenancy/test_services.py delete mode 100644 tests/test_webhooks/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_workflows/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc delete mode 100644 tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc diff --git a/.coverage b/.coverage deleted file mode 100644 index 453678a295dacc74bae082017f3e16acdbe617d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86016 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 RP@))Q_pNfhu{G3`{U5=*gBAb) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4fb3ab..52a6333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: Run ruff run: ruff check . + - name: Check formatting + run: ruff format --check infrasynth/ config/ tests/ + typecheck: name: Type Check runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b08863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / coverage artifacts +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml + +# Local databases and env +*.sqlite3 +*.db +.env +.env.* + +# Tooling caches +.mypy_cache/ +.ruff_cache/ + +# Local media (file storage backend writes here in dev) +media/ diff --git a/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf b/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf deleted file mode 100644 index 054d0f8..0000000 --- a/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 ->> -stream -Gasam_/>bs&A@O6bc.>!+SK1O:+oV2VJ=XN9paqj$Y;mtZp;d>C-THh2Jccfh$+7'NM#CIH+&hXW(Hm2.tA-ZS6npN#jDF^'duVf_T-cgHQda3((a+C;_4"s)D**tN)`8oW(!4)BHJ%T8FCLsQAY5WaFs./E`SE5TFB7j\cV=]bI_V\]nQ(bdPLA?ZR"Ipg-gUS[1b9'@Y0Jj1P$hd9IO^m+2PRo"Puq5d>]bpB5=qP%mJn+q^>;@qkDBZW\!G<3F)%(-GG:j@V4_;`U`bu9"CZ#`Q*??DL]/imrHZFMN_koNh%js*gkImRr5heg78C*o"OmgSs<0mfr5'^5K-?%)j]R81=q6hg1eK_W""dmBH,WI?ebj)?9AZL*\&q[o2!.SeJ%#g9hqkg>Ip3YQVJ"cUN&Oa=?Hi9"$1^/@5Ik0h=%G%bj1!tcp,U-P;DXq0Um?*=S^r8!J5jij3O?DPGpO2OSk1E(2k$g`PGf@X`PDd7]Q)O]^ZJ>HERP%fO!*_DPc6!.$:U5?Wq=I];qL8h#PG^p=8#W!s(B4BOGVlQ~>endstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[<9e52a7c939f2a870451bc8004c9e7d1d><9e52a7c939f2a870451bc8004c9e7d1d>] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1657 -%%EOF diff --git a/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf b/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf deleted file mode 100644 index 3cf5150..0000000 --- a/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 650 ->> -stream -Gasam?#Q2d'Rf.Ggo$!/K)-BqE;),i+4I*p;A[o^]eJGH;uo$H[/Tu/gQ4Zed`)V`f>mWUH[H8(UOi,A;ug8\357aE%bW)"#R+a'i&^K/HIsGgK*2XN@NdL+&g?cnPm#mkR#$i\F84J"TE>2E;+Na-X3p2qXEn?+lJ=&J=.M5FX(Uec@=2`;-'Nr]B4(IgRABu*f?AN085P'*u2X-2(1ZN,BN$Xb4I>g7Ys=N2OrD+Zgbc3:9V:S>.7]DCuhpOc)[1r%Z@2F`>P^u^Ht@dakuHUR_Y$g&TJ(IA#nSYN,k^R[m'UhH92U.Onm^)4:\@9HBEM$b<%/ot?06920(\0e=>33P21TTal@BOfmbB&sK=ua$Fq$X[)BfWV)M!&8PY.[1H]V,KK3sP;&_7ftSc6rA)#*1\bs/tk')W[gY,o\9Z@[diYs-)Q&[m>%`IiS\8hNRd0#hB+2"MJ"O_Z:Wk!"#Sdl9(O\JfJ+hJkDlE])'LV5"",/2i!hVTW$W@'#iB(Qendstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1661 -%%EOF diff --git a/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf b/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf deleted file mode 100644 index ef3029b..0000000 --- a/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 ->> -stream -Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[<6708953b0665daa52c06093e6e045b34><6708953b0665daa52c06093e6e045b34>] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1657 -%%EOF diff --git a/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf b/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf deleted file mode 100644 index 292a22e..0000000 --- a/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 680 ->> -stream -Gat=(9lldX&A@sBm&/*$GhSd^PlqTL`6HdM,L,LfhqP%>"MkQ72m33FV"!LZ!=@1Y5T**`^VIm3p3VN7;@/0,J2nC<5ckS!U0'n?LJ#LAK][0lM/])VZW*H]!#(Q_aCHS*"0%-TdhR+o=l/S8lntU5OIh"fRUlLYgdWJeXIP(YP9Hl,\08)0_@R(/RQL#A/V3Y[@WJ6Sm;1#_lG;$DdO.?'(ZpKrhFMrbk:MTd?<1XBC$XgPDJeBkW%Va\^61tb@8oj#<3Q'W+,/Gk;l4cQ2G/jjD#KBb0FNrcC5Zr@gD8&j!ZQDdr6;6sFV3\WFc&!'a^KmlUPf%rFuBa=(!)KPB8oUd[qD5K2!9Z,X/uKRS^0T-$HW))>nl&+.3J-O)(:&1Pu;LZ(%^6nr]2JYWbYr)d24Yf>hOr4ej-;.o8^s82TMda%DOkoo?f_oRN2I"MD=?#fr*s*fc1B%M=nTMklq:5'7*-;bOR!&ItU6YR$[,]1lo!9ce8"7)AUZ@5VJEiM1p'A)i9ge~>endstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[<50bd4355e2b23b12206fd030ee2a05ef><50bd4355e2b23b12206fd030ee2a05ef>] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1691 -%%EOF diff --git a/AGENTS.md b/AGENTS.md index 0d13b6c..cd1fb4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,13 @@ ## Project Overview -InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It provides 9 Django apps that cover authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code. +InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It is the **one shared kit** every Infrasynth app depends on, providing 10 Django apps that cover tenancy, authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code. -**One version, one repo, one pip install.** Feature flags control what is active per tenant/user. +**Every app is multi-tenant.** One deployment per app serves all customers; a customer is a **tenant** (workspace), isolated at row level via `tenant_id` on a shared schema. Read `../TENANCY.md` — it is the source of truth for tenancy. + +**No license server.** Everything runs on our own infrastructure, so there are no signed license keys, no phone-home, no offline SDK, and no validation grace period. What a tenant may use is an **entitlement**, enforced in-process by `infrasynth.billing`. Read `../ENTITLEMENTS.md`. + +**One version, one repo, one pip install.** Feature flags are operational toggles per tenant/user; entitlements are commercial rights. --- @@ -40,6 +44,8 @@ infrasynth-base/ │ ├── infrasynth/ # Namespace package root │ ├── shared/ # NOT a Django app. Zero-Django utilities. +│ ├── api/ # DRF API layer (envelope, camelCase, cursor pagination, request-id) +│ ├── tenancy/ # Django app: 'infrasynth.tenancy' (Tenant, membership, scoping) │ ├── audit/ # Django app: 'infrasynth.audit' │ ├── security/ # Django app: 'infrasynth.security' │ ├── files/ # Django app: 'infrasynth.files' @@ -73,6 +79,19 @@ infrasynth-base/ - `infrasynth.shared.*` (protocols, enums, crypto, types) - Django stdlib (`django.db.models`, `django.conf.settings`, `django.dispatch.Signal`) +### 1b. Multi-Tenancy First — read `../TENANCY.md` before any model + +Every app is multi-tenant. The kit's `infrasynth.tenancy` app provides the tenant model, membership, request context, and scoped managers. Non-negotiables: + +- Every **tenant-owned** model has a non-null `tenant` FK, `objects = TenantManager()` and `all_objects = AllObjectsManager()`. +- No tenant context ⇒ the scoped manager returns an **empty queryset** (fail closed). A query that works without a tenant is a bug. +- Cross-tenant object access returns **`404`, never `403`**. +- Uniqueness that was global becomes unique **per tenant**; indexes lead with `tenant_id`. +- `unsafe_all()` is never called from a view. +- Celery tasks and signals carry `tenant_id` explicitly; cache keys are prefixed `tenant:{id}:`. + +See `../TENANCY.md` §4 for the full contract and the per-model scoping table in `PLAN.md` §2.0. + ### 2. Integration Mechanisms (in priority order) | Mechanism | When to use | Example | @@ -92,7 +111,9 @@ infrasynth.shared ← Zero deps (protocols, enums, crypto) ↑ infrasynth.audit ← shared only ↑ -All other Django apps ← shared + audit only +infrasynth.tenancy ← shared + audit (defines isolation; used by every tenant-owned app) + ↑ +All other Django apps ← shared + audit (+ tenancy where tenant-owned) ↑ infrasynth.features ← Used by ALL apps for feature gating ↑ (but apps register flags, don't import features) @@ -143,6 +164,8 @@ app_name/ 4. **`settings.AUTH_USER_MODEL`** for user references. Never hardcode `auth.User`. 5. **JSONField for flexible metadata**, not TextField. 6. **Use `infrasynth.shared.enums`** for choice fields (never hardcode strings in choices). +7. **Tenant-owned models carry `tenant` + scoped managers:** non-null FK to `tenancy.Tenant`, `objects = TenantManager()`, `all_objects = AllObjectsManager()`. Uniqueness becomes `(tenant, field)` and indexes lead with `tenant_id` (`../TENANCY.md` §4). +8. **Prefer the tenancy mixins over hand-writing the field:** inherit `infrasynth.tenancy.mixins.TenantOwnedModel` (non-null `tenant`, `TenantManager` default, `all_objects`, and save-time tenant auto-assignment) or `GlobalOrTenantModel` (nullable `tenant`, `GlobalOrTenantManager` returning global + current-tenant rows, `resolve()` for precedence). Do not redeclare `tenant`/managers on a model that already inherits a mixin. ### Serializer Conventions @@ -167,6 +190,7 @@ def initial(self, request, *args, **kwargs): 5. **Pagination:** All list views use the standard `CustomPagination` class. Query param `?page_size=` (default 25, max 100). 6. **Filtering:** Use `DjangoFilterBackend` with a `FilterSet` class per view. +7. **Tenant scoping is automatic:** never filter by tenant by hand — `Model.objects` is already scoped to the current tenant. Never call `unsafe_all()` from a view. A cross-tenant id resolves to `404` (the scoped manager makes the row invisible), never `403`. ### Signal Conventions @@ -219,6 +243,9 @@ class MyRegistry: - `authenticated_client` — APIClient with JWT cookies set - `admin_client` — authenticated superuser client - `user_factory`, `role_factory`, etc. + - `tenant_factory`, `membership_factory` — for multi-tenant tests + +6. **Tenant isolation is mandatory:** create two tenants with data and assert tenant A cannot read, write, update, or delete tenant B's rows, and that cross-tenant access returns `404`. Any Celery task touching tenant data gets a test proving it carries `tenant_id` and does not leak across tenants. ### Settings Conventions @@ -232,6 +259,8 @@ from infrasynth.shared.settings_utils import get_setting cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) ``` +5. **Tenancy is configured via `INFRASYNTH_TENANCY`** (`TENANT_MODEL`, `TENANT_CLAIM`, `REQUIRE_TENANT_BY_DEFAULT`, allowlist, defaults). Billing/grace via `INFRASYNTH_BILLING` (`GRACE_PERIOD_DAYS`, `DEFAULT_CURRENCY`). Both have safe defaults (`../TENANCY.md`, `../ENTITLEMENTS.md`). + ### Crypto Conventions 1. **Use `infrasynth.shared.crypto`** for Fernet encryption/decryption. @@ -307,6 +336,40 @@ class TicketDataValidator: DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) ``` +### App B needs: a tenant-owned model + +```python +# helpdesk/models.py +from infrasynth.tenancy.managers import TenantManager, AllObjectsManager + +class Ticket(models.Model): + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+") + subject = models.CharField(max_length=255) + + objects = TenantManager() # always scoped to the current tenant + all_objects = AllObjectsManager() # unscoped — admin/management only + + class Meta: + constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")] +``` + +No view filters by tenant by hand: `Ticket.objects.all()` already returns only the current tenant's tickets, and a foreign tenant's id yields `404`. Never call `unsafe_all()` in a view. + +### App B needs: entitlement gating + +```python +# helpdesk/views.py +from infrasynth.billing.services import EntitlementService +from infrasynth.shared.exceptions import EntitlementError + +def create_ticket(request, tenant): + if not EntitlementService().is_entitled(tenant, "helpdesk", feature="tickets"): + raise EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets") + ... +``` + +Enforcement is server-side and in-process. There is no license key and no offline check (`../ENTITLEMENTS.md`). + --- ## Common Patterns and Anti-Patterns @@ -323,6 +386,10 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) - Encrypt secrets at rest with Fernet - Use `Result[T, E]` monad for service methods that can fail - Add `select_related()`/`prefetch_related()` in every view's `get_queryset()` +- Put `tenant` + `TenantManager` on every tenant-owned model +- Resolve the tenant from the session token or a tenant-scoped credential +- Prefix every cache/Redis/rate-limit key with `tenant:{id}:` +- Carry `tenant_id` explicitly into Celery tasks and signals ### ❌ DON'T @@ -335,6 +402,10 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) - Don't use signals for synchronous request-response flows (use direct method calls) - Don't create circular imports — if app A needs app B, and app B needs app A, refactor into shared or use signals - Don't store file contents in the database — always use the files app's storage abstraction +- Don't resolve a tenant from a client-supplied id on an unauthenticated request +- Don't call `unsafe_all()` from a view +- Don't leave a formerly-global unique field global when it should be unique per tenant +- Don't introduce license keys, a license server, phone-home, or offline verification — use entitlements --- @@ -342,7 +413,16 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) | File | Purpose | |------|---------| -| `infrasynth/shared/protocols.py` | All ABCs and Protocols | +| `infrasynth/shared/protocols.py` | All ABCs and Protocols (incl. `TenantProtocol`) | +| `infrasynth/api/renderers.py` | EnvelopeJSONRenderer (envelope + camelCase) | +| `infrasynth/api/pagination.py` | CursorPagination | +| `infrasynth/api/exceptions.py` | envelope_exception_handler + namespaced error codes | +| `infrasynth/api/middleware.py` | RequestIdMiddleware | +| `infrasynth/tenancy/models.py` | Tenant, TenantMembership | +| `infrasynth/tenancy/managers.py` | TenantManager, AllObjectsManager | +| `infrasynth/tenancy/middleware.py` | TenantMiddleware (resolves the tenant from the token claim) | +| `infrasynth/tenancy/context.py` | `current_tenant` ContextVar | +| `infrasynth/billing/models.py` | App, Plan, Entitlement, Subscription, Invoice, PaymentTransaction | | `infrasynth/shared/crypto.py` | Fernet encrypt/decrypt/rotation | | `infrasynth/shared/enums.py` | All shared enums | | `infrasynth/shared/results.py` | Result monad | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6afb666 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to `infrasynth-base` are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Versions are derived from Conventional Commits by `python-semantic-release`; do +not hand-pick a version (see `../AGENTS.backend-packages.md` §8). + +## [Unreleased] + +### Added +- **Verified inbound webhooks.** `InboundReceiveView` now enforces the shared + HMAC signature (or a provider-specific `BaseInboundHandler.verify`), payload + size limits, timestamp tolerance, and idempotent re-delivery via + `InboundEvent.external_id`; verified events are dispatched to the endpoint's + handler through `process_inbound_event` and marked `is_verified`/`is_processed`. +- **Working 2FA login flow.** Login now challenges users with a configured + second factor (pre-auth session + cookie) and only mints JWT cookies after + `2fa/verify/` (or `2fa/recovery/`) succeeds; `TwoFactorMiddleware` guards the + session-authenticated surface. +- **Permission enforcement.** `HybridPermission` / `require_permission` are now + wired into security and audit viewsets with documented codenames and a + tenant-owner bypass; `HybridPermission` takes tenant ownership into account. +- **API-key rotation** (`/api/v1/auth/api-keys//rotate/`) and + **user permission/role endpoints** (`/api/v1/auth/users//permissions/`, + `/users//roles/`). +- **Billing webhook processing.** Verified events are applied idempotently to + subscriptions, entitlements, invoices, and `PaymentTransaction` rows; replay + protection via `assert_fresh_webhook`. +- **Scheduled billing lifecycle** (`sync_subscriptions`, + `advance_entitlement_lifecycle`, `expire_entitlements`, + `generate_renewal_invoices`) and **notification retries** + log retention, all + wired into `CELERY_BEAT_SCHEDULE`. +- **Audit update diffs** are captured automatically via a `pre_save` snapshot; + **audit retention purge** task added. +- **Feature rollout** (`rollout_percentage`, `environments`, + `ROLLOUT_HASH_ALGORITHM`) and settings-driven flag registration. +- **Login brute-force guard** (per-credential rate limit + IP blacklist), + **configurable password policy** (`PasswordPolicyValidator`), and a correctly + enforced **ALTCHA** proof-of-work. +- **File hardening:** global upload-size limit, processing-pipeline toggle, and + a pluggable virus scanner (`noop`/`clamav`/custom) with `REQUIRE_VIRUS_SCAN`. +- **Workflow guards:** `MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, + `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`. +- **MercadoPago** webhook signature verification. +- `README.md`, `CHANGELOG.md`, and a CI format/coverage gate. + +### Changed +- `TenantRateThrottle` and `RateLimitHeadersMiddleware` are active by default, + producing `X-RateLimit-*` headers on API responses. +- `EntitlementService` treats `past_due` as within grace (entitled) and merges + entitlement-level feature overrides over `plan.features`; `require_limit` + raises `ENTITLEMENT_LIMIT_REACHED`. +- `FeatureService` resolves the current tenant automatically and honors rollout + and environment targeting. + +### Fixed +- API-key authentication no longer leaks tenant context. +- `EventRegistry.emit` no longer uses `__import__` and honors + `DELIVERY_BACKEND`. +- Test media artifacts no longer accumulate in the repository tree. diff --git a/PLAN.md b/PLAN.md index d107fd2..67efcfd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -11,6 +11,12 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (`infrasynth-base`), se configura desde `settings.py`, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags. +**Es el único kit compartido.** Cada app desplegada (Messenger, Invoicer, y las futuras) depende de este paquete y no reimplementa nada de lo que aquí vive. La arquitectura anterior de "cuatro paquetes pequeños" (`infrasynth-auth`, `infrasynth-license-sdk`, `infrasynth-update-client`, `infrasynth-api-conventions`) está retirada: `auth` → `infrasynth.security`, `api-conventions` → la capa API de este kit, y `license-sdk`/`update-client` se eliminan (ver abajo). + +**Cada app es multi-tenant.** Un solo despliegue por app sirve a todos los clientes; cada cliente es un **tenant** (workspace). El aislamiento es a nivel de fila con `tenant_id` sobre un único esquema compartido. Ver `../TENANCY.md` (fuente de verdad) — este paquete provee la app `infrasynth.tenancy` que lo implementa. + +**No hay servidor de licencias.** Todo corre en nuestra propia infraestructura, así que no hay claves firmadas, ni phone-home, ni SDK offline, ni grace period de validación. Lo que un tenant puede usar es un **entitlement** (derecho comercial) verificado en proceso por `infrasynth.billing`. Ver `../ENTITLEMENTS.md`. El despliegue es por CI/CD propio, sin supervisor ni banner de actualización (`../DEPLOYMENT.md`). + **Principio rector:** Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags. > **IMPORTANTE:** Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como `✅` (completada), `🔄` (en progreso), o `⬜` (pendiente) con la fecha del cambio. @@ -108,7 +114,7 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra - [x] Tests: run_now triggers Celery, toggle enables/disables ✅ _(2026-07-30)_ ### Fase 13 — Facturación (`infrasynth/billing/`) ✅ _(2026-07-30)_ -- [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction +- [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction _(extendido en Fase 17: App, Plan, Entitlement)_ - [x] `gateways/base.py` — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult - [x] Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive - [x] `services.py` — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ _(2026-07-30)_ @@ -133,6 +139,42 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra - [x] Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md - [ ] Docker push (pendiente de registry config) +### Fase 16 — Multi-tenancy ✅ _(2026-09-24)_ +- [x] `infrasynth.tenancy` — Tenant, TenantMembership, TenantInvitation, PlatformStaff, TenantManager/AllObjectsManager/GlobalOrTenantManager, TenantMiddleware, `current_tenant` ContextVar, `INFRASYNTH_TENANCY` +- [x] `tenant_id` + `TenantManager` en todos los modelos tenant-owned (audit, security, files, notifications, webhooks, workflows, scheduler, billing) vía `TenantOwnedModel`/`GlobalOrTenantModel` +- [x] Restricciones compuestas `(tenant, …)` e índices que empiezan con `tenant_id` +- [x] Propagación explícita de `tenant_id` a Celery tasks y signals; claves de caché/rate-limit con prefijo `tenant:{id}:` +- [x] `TenantProtocol` real (UUID no-nulo, ya no stub) +- [x] Suite de tests de aislamiento (tenant A no puede leer/escribir/borrar datos de tenant B; acceso cross-tenant → 404) + +### Fase 17 — Entitlements y facturación multi-tenant ✅ _(2026-09-24)_ +- [x] `billing` — modelos `App`, `Plan` (one_time/subscription), `Entitlement`; `tenant` en Subscription/Invoice/PaymentTransaction; dinero en unidades menores (BigInteger) +- [x] `EntitlementService` (`is_entitled`, `check_limit`) con caché por tenant e invalidación en mutaciones +- [x] Ciclo de vida `past_due` → `grace` → `suspended` a nivel tenant (reinstatement al pagar) +- [x] Códigos de error `ENTITLEMENT_*` en la capa de excepciones (`infrasynth.shared.exceptions`) +- [x] Gate = tenant activo AND entitled AND feature flag (los flags siguen siendo toggles operativos) + +### Fase 18 — Capa API (`infrasynth.api`) y estándar ✅ _(2026-09-24)_ +- [x] `renderers.py` — EnvelopeJSONRenderer (envelope + camelCase), `meta.requestId/timestamp/tenantId/pagination` +- [x] `pagination.py` — CursorPagination (`pageSize`, `nextCursor`/`prevCursor`) +- [x] `exceptions.py` — envelope_exception_handler + códigos namespaced (`shared.exceptions`) +- [x] `middleware.py` — RequestIdMiddleware + RateLimitHeadersMiddleware +- [x] `idempotency.py` (Idempotency-Key), `throttling.py` (tenant-scoped), `webhooks.py` (replay window), `schema.py` (drf-spectacular) +- [x] Prefijo de versión `/api/v1/`; login multi-workspace (select/switch) con claim `tenant` en el JWT; API keys tenant-scoped + +### Fase 19 — Endurecimiento a producción ✅ _(2026-09-24)_ +- [x] Webhooks entrantes verificados: HMAC / `BaseInboundHandler.verify`, límite de tamaño, tolerancia de timestamp, idempotencia por `external_id`, handler `process()` ejecutado y `is_verified`/`is_processed` persistidos +- [x] 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y `TwoFactorMiddleware` para sesión +- [x] Permisos cableados: `HybridPermission`/`require_permission` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users//permissions` y `/roles` +- [x] Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/`PaymentTransaction`), replay protegido, firma MercadoPago +- [x] Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación) +- [x] Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención +- [x] Feature rollout (%) y targeting por entorno; registración de flags desde settings +- [x] Login brute-force guard, política de contraseñas, ALTCHA con PoW real; límite global de subida, virus scanner pluggable, toggle de pipelines +- [x] Guardas de workflow (`MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`) +- [x] Throttling tenant-scoped por defecto; `CELERY_BEAT_SCHEDULE` con trabajos periódicos +- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura + ## 1. Estructura del Paquete ``` @@ -164,8 +206,31 @@ backend-package/ # ← repo root / pip package roo │ │ ├── crypto.py # FernetAES encrypt/decrypt, key rotation │ │ ├── enums.py # Enums base (ChannelType, EventSeverity, BillingInterval, etc.) │ │ ├── results.py # Result[T, E] monad +│ │ ├── exceptions.py # Excepciones base zero-Django (AppError, EntitlementError, AuthError, …) │ │ └── settings_utils.py # get_setting() helper con defaults │ │ +│ ├── api/ # Capa API (DRF, no es Django app): implementa API-STANDARD.md +│ │ ├── __init__.py +│ │ ├── renderers.py # EnvelopeJSONRenderer (envelope + camelCase) +│ │ ├── exceptions.py # envelope_exception_handler (mapea shared.exceptions a códigos namespaced) +│ │ ├── pagination.py # CursorPagination +│ │ └── middleware.py # RequestIdMiddleware +│ │ +│ ├── tenancy/ # Django app: 'infrasynth.tenancy' — ver ../TENANCY.md +│ │ ├── __init__.py +│ │ ├── apps.py # TenancyConfig, registra flags "tenancy", "tenancy_memberships" +│ │ ├── models.py # Tenant, TenantMembership +│ │ ├── context.py # current_tenant ContextVar + get/set/reset +│ │ ├── managers.py # TenantManager, AllObjectsManager +│ │ ├── middleware.py # TenantMiddleware (resuelve tenant desde el claim del token) +│ │ ├── services.py # TenantService (membership, switch, suspend, offboard) +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ │ ├── audit/ # Django app: 'infrasynth.audit' │ │ ├── __init__.py │ │ ├── apps.py # AuditConfig(AppConfig), registra flag "audit" @@ -302,7 +367,7 @@ backend-package/ # ← repo root / pip package roo │ └── billing/ # Django app: 'infrasynth.billing' │ ├── __init__.py │ ├── apps.py # BillingConfig, registra flag "billing" -│ ├── models.py # PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction +│ ├── models.py # PaymentGateway, App, Plan, Entitlement, Subscription, Invoice, PaymentTransaction │ ├── services.py # Billing service stub │ ├── invoice_generator.py # Generación de PDF (Celery task stub) │ ├── gateways/ @@ -335,6 +400,58 @@ backend-package/ # ← repo root / pip package roo ## 2. Especificación Detallada por App +### 2.0 Contrato de Multi-tenancy (aplica a TODAS las apps) + +Toda app es multi-tenant. La fuente de verdad es `../TENANCY.md`; esta sección solo resume el contrato que los modelos de abajo cumplen. + +**Regla de oro:** + +> Todo modelo **tenant-owned** tiene un FK no-nulo `tenant`, un manager por defecto `TenantManager` y un escape hatch `all_objects`. Toda query de datos de tenant pasa por el manager scopeado. No hay excepción, y "me acordaré de filtrar" no es un diseño. + +```python +# Patrón que TODO modelo tenant-owned sigue (se omite en los bloques de abajo por brevedad, +# salvo donde el scoping no es obvio): +class CualquierModeloDeTenant(models.Model): + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+") + # ... + objects = TenantManager() # por defecto — SIEMPRE scopeado al tenant actual + all_objects = AllObjectsManager() # sin scope — solo migraciones, admin y platform staff +``` + +- **Fail closed:** sin contexto de tenant, el manager scopeado devuelve queryset vacío. Un query que "funciona" sin tenant es un bug. +- **Acceso cross-tenant → `404`, nunca `403`** (un `403` confirma que el objeto existe: fuga de información). +- **Unicidad por tenant:** todo lo que era único global pasa a `unique_together = (tenant, campo)` (o `UniqueConstraint` con `tenant` primero). Índices empiezan con `tenant_id`. +- **FK cross-tenant prohibido:** una fila de tenant solo referencia filas globales o de su mismo tenant. +- **Tareas Celery, signals y claves de caché** llevan `tenant_id` explícito; las claves se prefijan `tenant:{id}:` (`../TENANCY.md` §7). +- **`unsafe_all()` nunca se llama desde una vista.** + +**Scoping de cada modelo del kit:** + +| App | Modelo | Scoping | +|---|---|---| +| `shared` | — | Zero-Django, no aplica | +| `api` | — | Capa DRF, no tiene modelos | +| `tenancy` | `Tenant`, `TenantMembership` | Definen el scoping (no se auto-scopean) | +| `audit` | `ModelChangeLog`, `APIInteractionLog`, `SecurityEvent` | Tenant-owned (`tenant_id` nulo solo para acciones de plataforma) | +| `security` | `Role` | Global (`tenant_id = NULL` = rol de sistema) + override por tenant | +| `security` | `Grant`, `Revoke`, `APIKey` | Tenant-owned | +| `security` | `TwoFactorConfig` | Global por usuario (el usuario es global) | +| `security` | `ALTCHAChallenge` | Global (efímero, anti-spam) | +| `files` | `StoredFile`, `FileCategory`, `PipelineExecution` | Tenant-owned | +| `files` | `ProcessingPipeline` | Global + override por tenant | +| `notifications` | `NotificationTemplate` | Global + override por tenant | +| `notifications` | `NotificationDispatch`, `ChannelConfig` | Tenant-owned | +| `webhooks` | `OutboundEndpoint`, `OutboundSubscription`, `OutboundDelivery`, `InboundEndpoint`, `InboundEvent` | Tenant-owned (`InboundEndpoint` resuelve el tenant por slug + secreto) | +| `workflows` | `Workflow`, `WorkflowNode`, `Transition`, `WorkflowInstance`, `NodeAssignment`, `WorkflowObserver` | Tenant-owned | +| `scheduler` | `ScheduledTask`, `TaskExecution` | Tenant-owned | +| `features` | `FeatureFlag` | Global (`tenant_id = NULL`) + override por tenant | +| `features` | `FeatureFlagOverride` | Tenant-owned | +| `billing` | `PaymentGateway` | Global (cuentas de la plataforma) | +| `billing` | `App`, `Plan` | Global (catálogo) | +| `billing` | `Entitlement`, `Subscription`, `Invoice`, `PaymentTransaction` | Tenant-owned | + +**Usuarios e identidad:** el `User` es global (email único dentro de la app); la pertenencia a tenants es vía `TenantMembership` (un usuario puede pertenecer a varios tenants, con rol distinto en cada uno). Nunca un FK `tenant` en el modelo de usuario. + ### 2.1 `infrasynth.shared` — Fundación Cero-Django **Propósito:** Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema. @@ -374,8 +491,8 @@ class EventProtocol(Protocol): timestamp: str class TenantProtocol(Protocol): - """Protocolo para modelos que soporten multi-tenant.""" - tenant_id: str | None + """Contrato de todo modelo tenant-owned. tenant_id es no-nulo en filas de tenant.""" + tenant_id: UUID ``` #### `crypto.py` — Utilidades Criptográficas @@ -904,7 +1021,6 @@ grant_revoked = Signal() # kwargs: user, codename, reason | `/security/revokes//` | DELETE | `security.manage_grants` | Elimina revoke | | `/security/users//permissions/` | GET | `security.view_permissions` | Permisos efectivos del usuario | | `/security/users//roles/` | GET, PUT | `security.manage_roles` | Roles del usuario | - #### Configuración Externalizable ```python @@ -2452,16 +2568,85 @@ INFRASYNTH_FEATURES = { --- -### 2.10 `infrasynth.billing` — Pagos y Suscripciones +### 2.10 `infrasynth.billing` — Pagos, Planes y Entitlements **Feature flag:** `billing` (default: **False** — requiere activación explícita) -**Dependencias:** `infrasynth.shared`, `infrasynth.audit` +**Dependencias:** `infrasynth.shared`, `infrasynth.audit`, `infrasynth.tenancy` + +> Este app es la fuente de enforcement comercial. **No hay servidor de licencias ni claves firmadas.** Lo que un tenant puede usar es un `Entitlement`, verificado en proceso por `EntitlementService`. Ver `../ENTITLEMENTS.md`. #### Modelos +```python +class App(models.Model): + """Una app desplegada en el catálogo (global).""" + slug = models.SlugField(max_length=100, unique=True) # "messenger", "invoicer" + name = models.CharField(max_length=200) + monetization = models.CharField( # default a nivel de app + max_length=20, + choices=[("one_time","one_time"),("subscription","subscription")], + ) + is_active = models.BooleanField(default=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_app" + + +class Plan(models.Model): + """Tier comprable de una app (global).""" + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans") + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=200) + price_amount = models.BigIntegerField() # UNIDADES MENORES (centavos) — nunca float + price_currency = models.CharField(max_length=3, default="USD") # ISO 4217 + interval = models.CharField( # one_time | monthly | yearly + max_length=20, + choices=[("one_time","one_time"),("monthly","monthly"),("yearly","yearly")], + default="monthly", + ) + trial_days = models.PositiveIntegerField(default=0) + features = models.JSONField(default=dict) # {"broadcast": true, "analytics": false} + limits = models.JSONField(default=dict) # {"max_agents": 10} + is_active = models.BooleanField(default=True) + gateway = models.ForeignKey("PaymentGateway", on_delete=models.SET_NULL, null=True) + external_id = models.CharField(max_length=200, blank=True) + + class Meta: + db_table = "billing_plan" + unique_together = [("app", "slug")] + + +class Entitlement(models.Model): + """Derecho de un tenant a usar una app bajo un plan. Tenant-owned. Fuente única de enforcement.""" + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="entitlements") + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements") + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True) + status = models.CharField( + max_length=20, + choices=[("trialing","trialing"),("active","active"),("past_due","past_due"), + ("grace","grace"),("suspended","suspended"),("expired","expired"), + ("cancelled","cancelled"),("revoked","revoked")], + default="active", + ) + started_at = models.DateTimeField(auto_now_add=True) + current_period_end = models.DateTimeField(null=True, blank=True) # suscripciones + expires_at = models.DateTimeField(null=True, blank=True) # NULL = one_time / perpetuo + cancel_at_period_end = models.BooleanField(default=False) + source = models.CharField(max_length=20, default="manual") # manual | stripe | mercadopago | wompi + metadata = models.JSONField(default=dict) + + objects = TenantManager() + all_objects = AllObjectsManager() + + class Meta: + db_table = "billing_entitlement" + constraints = [models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement")] +``` + ```python class PaymentGateway(models.Model): - """Configuración de una pasarela de pago.""" + """Configuración de una pasarela de pago (global — cuenta de la plataforma).""" slug = models.SlugField(max_length=50, primary_key=True) display_name = models.CharField(max_length=200) gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway") @@ -2472,30 +2657,14 @@ class PaymentGateway(models.Model): class Meta: db_table = "billing_gateway" +``` - -class BillingPlan(models.Model): - """Plan de suscripción/pago.""" - slug = models.SlugField(max_length=100, unique=True) - name = models.CharField(max_length=200) - description = models.TextField(blank=True) - price_amount = models.DecimalField(max_digits=12, decimal_places=2) # En centavos/subunidad - price_currency = models.CharField(max_length=3, default="USD") - interval = models.CharField(max_length=20, choices=[("monthly","Monthly"),("yearly","Yearly")], default="monthly") - trial_days = models.PositiveIntegerField(default=0) - features = models.JSONField(default=list, help_text='["10,000 emails/mes", "Soporte prioritario"]') - is_active = models.BooleanField(default=True) - gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) - external_id = models.CharField(max_length=200, blank=True, help_text="ID del plan en la pasarela (ej. Stripe price ID)") - - class Meta: - db_table = "billing_plan" - - +```python class Subscription(models.Model): - """Suscripción activa de un usuario.""" - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="subscriptions") - plan = models.ForeignKey(BillingPlan, on_delete=models.SET_NULL, null=True) + """Suscripción activa de un tenant (tenant-owned).""" + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="subscriptions") + entitlement = models.ForeignKey(Entitlement, on_delete=models.SET_NULL, null=True, related_name="subscriptions") + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True) gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) external_id = models.CharField(max_length=200, blank=True) status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus]) @@ -2506,20 +2675,23 @@ class Subscription(models.Model): trial_end = models.DateTimeField(null=True) metadata = models.JSONField(default=dict) + objects = TenantManager() + all_objects = AllObjectsManager() + class Meta: db_table = "billing_subscription" class Invoice(models.Model): - """Factura generada.""" + """Factura generada (tenant-owned).""" + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="invoices") subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices") - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices") gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) external_id = models.CharField(max_length=200, blank=True) invoice_number = models.CharField(max_length=50, unique=True) - amount = models.DecimalField(max_digits=12, decimal_places=2) + amount = models.BigIntegerField() # UNIDADES MENORES currency = models.CharField(max_length=3, default="USD") - tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + tax_amount = models.BigIntegerField(default=0) tax_name = models.CharField(max_length=50, blank=True, default="") status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft") due_date = models.DateTimeField(null=True) @@ -2528,22 +2700,29 @@ class Invoice(models.Model): pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+") metadata = models.JSONField(default=dict) + objects = TenantManager() + all_objects = AllObjectsManager() + class Meta: db_table = "billing_invoice" class PaymentTransaction(models.Model): - """Transacción de pago individual.""" + """Transacción de pago individual (tenant-owned).""" + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="payment_transactions") invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions") gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) external_id = models.CharField(max_length=200, blank=True) - amount = models.DecimalField(max_digits=12, decimal_places=2) + amount = models.BigIntegerField() # UNIDADES MENORES currency = models.CharField(max_length=3, default="USD") status = models.CharField(max_length=30) payment_method = models.CharField(max_length=100, blank=True) metadata = models.JSONField(default=dict) created_at = models.DateTimeField(auto_now_add=True) + objects = TenantManager() + all_objects = AllObjectsManager() + class Meta: db_table = "billing_transaction" ``` @@ -2626,7 +2805,10 @@ invoice_paid = Signal() # kwargs: user, invoice_id, amount | `/billing/gateways/` | GET | IsAuthenticated | Pasarelas activas | | `/billing/plans/` | GET | None | Planes disponibles | | `/billing/plans//` | GET | None | Detalle plan | -| `/billing/subscriptions/` | GET | IsAuthenticated | Suscripciones del usuario | +| `/billing/entitlements/` | GET | IsAuthenticated | Entitlements del tenant actual (todas las apps) | +| `/billing/entitlements//` | GET | IsAuthenticated | Entitlement del tenant para una app | +| `/billing/checkout/` | POST | IsAuthenticated | Crear checkout `{app, plan}` (tenant del token) | +| `/billing/subscriptions/` | GET | IsAuthenticated | Suscripciones del tenant actual | | `/billing/subscriptions//` | GET | IsAuthenticated | Detalle suscripción | | `/billing/subscriptions//cancel/` | POST | IsAuthenticated | Cancelar suscripción | | `/billing/subscribe//` | POST | IsAuthenticated | Crear checkout (retorna redirect URL) | @@ -2654,6 +2836,143 @@ INFRASYNTH_BILLING = { --- +### 2.11 `infrasynth.tenancy` — Tenants, Membresía y Contexto + +**Feature flag:** `tenancy` (default: True — es core) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +**Propósito:** proveer el modelo de tenant, la membresía usuario↔tenant, el contexto de request y los managers scopeados que hacen cumplir el aislamiento. Es la implementación de `../TENANCY.md`. + +#### Modelos + +```python +class Tenant(models.Model): + """Una empresa cliente. PK UUID para exponerla con seguridad.""" + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + slug = models.SlugField(max_length=100, unique=True) # handle público, ej. "acme" + name = models.CharField(max_length=200) + status = models.CharField( + max_length=20, + choices=[("trialing","trialing"),("active","active"), + ("suspended","suspended"),("archived","archived")], + default="active", + ) + locale = models.CharField(max_length=10, default="es") + timezone = models.CharField(max_length=64, default="UTC") + metadata = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + suspended_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "tenancy_tenant" + + +class TenantMembership(models.Model): + """Pertenencia de un usuario a un tenant. Única forma correcta de ligar usuario y tenant.""" + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships") + role = models.CharField(max_length=50, default="member") # slug de rol dentro del tenant + is_owner = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "tenancy_membership" + unique_together = [("tenant", "user")] + indexes = [models.Index(fields=["user", "is_active"])] +``` + +#### Contexto y Managers + +```python +# infrasynth/tenancy/context.py +from contextvars import ContextVar +current_tenant: ContextVar[Tenant | None] = ContextVar("current_tenant", default=None) + +# infrasynth/tenancy/managers.py +class TenantManager(models.Manager): + """Manager por defecto de todo modelo tenant-owned. Scopea al tenant actual.""" + def get_queryset(self): + tenant = current_tenant.get() + if tenant is None: + return super().get_queryset().none() # fail closed + return super().get_queryset().filter(tenant_id=tenant.id) + + def unsafe_all(self): + """Escape hatch explícito para código de sistema. Nunca desde una vista.""" + return super().get_queryset() + +class AllObjectsManager(models.Manager): + """Manager sin scope (`all_objects`) para migraciones, admin y platform staff.""" +``` + +#### Middleware + +`TenantMiddleware` corre **después** de la autenticación. Lee el claim `tenant` del token, verifica que la membresía siga activa (si no, `403 AUTH_MEMBERSHIP_REVOKED` — nunca espera a que expire el token), setea `current_tenant` y limpia el contexto al terminar. Rechaza requests a endpoints de tenant cuando no hay tenant resuelto, salvo el allowlist (login, select/switch-workspace, health, webhooks, catálogo). + +#### `TenantService` + +```python +class TenantService: + def get_active_memberships(self, user) -> list[TenantMembership]: ... + def select_tenant(self, user, tenant_id) -> Tenant: ... # valida membresía activa + def switch_tenant(self, user, tenant_id) -> tuple[str, str]: ... # (access, refresh) nuevos + def create_tenant(self, name, owner, slug=None) -> Tenant: ... # crea tenant + membership owner + def invite(self, tenant, email, role) -> Invitation: ... + def suspend(self, tenant, reason) -> None: ... + def reinstate(self, tenant) -> None: ... + def offboard(self, tenant) -> None: ... # export → archive → delete diferido +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/auth/select-workspace/` | POST | None (pre-auth) | Elegir workspace tras login multi-workspace. Emite tokens con claim `tenant` | +| `/auth/switch-workspace/` | POST | IsAuthenticated | Cambiar de workspace (rota refresh token). Auditado | +| `/tenancy/tenants/` | GET, POST | IsAuthenticated | Listar/crear tenants del usuario | +| `/tenancy/tenants//` | GET, PUT | `tenancy.manage_tenant` | Detalle/edición del tenant | +| `/tenancy/tenants//members/` | GET, POST | `tenancy.manage_members` | Listar/invitar miembros | +| `/tenancy/tenants//members//` | DELETE | `tenancy.manage_members` | Revocar membresía (invalida sesión) | + +#### Configuración Externalizable + +```python +INFRASYNTH_TENANCY = { + "TENANT_MODEL": "infrasynth.tenancy.Tenant", + "MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership", + "TENANT_CLAIM": "tenant", # nombre del claim en el JWT + "REQUIRE_TENANT_BY_DEFAULT": True, # endpoints sin tenant → 403 salvo allowlist + "TENANT_ALLOWLIST_PATHS": ["/api/v1/auth/", "/api/v1/billing/webhook/", "/healthz", "/readyz"], + "ENABLE_WORKSPACE_SWITCHING": True, + "DEFAULT_LOCALE": "es", + "DEFAULT_TIMEZONE": "UTC", +} +``` + +#### Patrón de Integración para App B + +```python +# App B: helpdesk/models.py — un modelo tenant-owned +from infrasynth.tenancy.managers import TenantManager, AllObjectsManager + +class Ticket(models.Model): + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+") + subject = models.CharField(max_length=255) + objects = TenantManager() + all_objects = AllObjectsManager() + + class Meta: + constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")] + +# App B: cualquier vista — el manager ya scopea; no se filtra a mano +Ticket.objects.all() # solo tickets del tenant actual +Ticket.all_objects.all() # TODOS los tenants — solo para admin/management +``` + +--- + ## 3. Patrones de Acoplamiento ### 3.1 Regla de Oro @@ -2721,6 +3040,7 @@ infrasynth.shared ↑ ├── infrasynth.audit │ ↑ + │ ├── infrasynth.tenancy ← define el aislamiento; todas las apps lo usan │ ├── infrasynth.security │ ├── infrasynth.files │ ├── infrasynth.notifications @@ -2728,11 +3048,12 @@ infrasynth.shared │ ├── infrasynth.workflows │ ├── infrasynth.scheduler │ ├── infrasynth.features - │ └── infrasynth.billing + │ └── infrasynth.billing ← usa tenancy (los entitlements son tenant-owned) │ - │ (Todas las apps de infraestructura dependen solo de shared + audit) + │ (Todas las apps de infraestructura dependen solo de shared + audit; + │ las apps tenant-owned usan infrasynth.tenancy para managers y contexto) │ - └── infrasynth.features ← es el orquestador transversal + └── infrasynth.features ← es el orquestador transversal de flags operativos ↑ (Todas las apps registran sus flags aquí, pero NO importan features) ``` @@ -2909,6 +3230,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", # InfraSynth Base (TODAS las apps, features controla visibilidad) + "infrasynth.tenancy", "infrasynth.audit", "infrasynth.security", "infrasynth.files", @@ -2932,6 +3254,7 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", + "infrasynth.tenancy.middleware.TenantMiddleware", "infrasynth.security.two_factor.middleware.TwoFactorMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -2946,7 +3269,9 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", ], - "DEFAULT_PAGINATION_CLASS": "infrasynth.shared.pagination.StandardPagination", + "DEFAULT_RENDERER_CLASSES": ["infrasynth.api.renderers.EnvelopeJSONRenderer"], + "EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler", + "DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination", "PAGE_SIZE": 25, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } @@ -2963,6 +3288,15 @@ INFRASYNTH_SECURITY = { "TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro", } +INFRASYNTH_TENANCY = { + "TENANT_MODEL": "infrasynth.tenancy.Tenant", + "MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership", + "TENANT_CLAIM": "tenant", + "REQUIRE_TENANT_BY_DEFAULT": True, + "DEFAULT_LOCALE": "es", + "DEFAULT_TIMEZONE": "UTC", +} + INFRASYNTH_FILES = { "DEFAULT_STORAGE_BACKEND": "S3", "STORAGE_BACKENDS": { @@ -3063,6 +3397,7 @@ from infrasynth.workflows.models import WorkflowAwareModel from infrasynth.files.models import StoredFile class Ticket(WorkflowAwareModel): + tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+") subject = models.CharField(max_length=255) description = models.TextField() priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")]) @@ -3072,6 +3407,9 @@ class Ticket(WorkflowAwareModel): attachments = models.ManyToManyField(StoredFile, blank=True, related_name="+") resolution = models.TextField(blank=True) + objects = TenantManager() # queries scopeadas al tenant actual + all_objects = AllObjectsManager() # solo admin/management + # ============================================================ # helpdesk/views.py # ============================================================ @@ -3082,6 +3420,7 @@ class TicketViewSet(ModelViewSet): permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")] def get_queryset(self): + # Ticket.objects ya está scopeado al tenant actual por TenantManager (fail closed). qs = Ticket.objects.select_related("assigned_to", "created_by") authz = AuthorizationService() if not authz.has_permission(self.request.user, "helpdesk.view_all_tickets"): @@ -3089,18 +3428,24 @@ class TicketViewSet(ModelViewSet): return qs def perform_create(self, serializer): - ticket = serializer.save(created_by=self.request.user) + tenant = current_tenant.get() + # Gate = tenant activo AND entitled AND feature flag operativo + if not EntitlementService().is_entitled(tenant, "helpdesk", feature="tickets"): + raise EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets") - # Disparar evento → webhooks outbound reaccionan + ticket = serializer.save(created_by=self.request.user, tenant=tenant) + + # Disparar evento → webhooks outbound reaccionan (tenant_id viaja explícito) from infrasynth.webhooks.registry import EventRegistry EventRegistry.emit("helpdesk.ticket.created", { + "tenant_id": str(tenant.id), "ticket_id": ticket.id, "subject": ticket.subject, "priority": ticket.priority, }) - # Auto-assign si el feature flag está activo - if FeatureService().is_enabled("helpdesk.auto_assign"): + # Auto-assign si el feature flag operativo está activo para este tenant + if FeatureService().is_enabled("helpdesk.auto_assign", tenant_id=tenant.id): assign_ticket_to_best_agent(ticket) ``` @@ -3119,7 +3464,8 @@ class TicketViewSet(ModelViewSet): | `infrasynth/webhooks/` | Django app: inbound/outbound con HMAC, EventRegistry | | `infrasynth/workflows/` | Django app: máquina de estados con votación, WorkflowAwareModel mixin | | `infrasynth/scheduler/` | Django app: dashboard y API de jobs Celery | +| `infrasynth/tenancy/` | Django app: Tenant, TenantMembership, managers scopeados, middleware, contexto de request | | `infrasynth/features/` | Django app: feature flags con tenant/user overrides, endpoint central `/api/features/active/` | -| `infrasynth/billing/` | Django app: suscripciones, facturas, Stripe/MercadoPago/Wompi | -| `tests/` | Test suite completa con pytest + factory_boy | +| `infrasynth/billing/` | Django app: App, Plan, Entitlements, suscripciones, facturas, Stripe/MercadoPago/Wompi | +| `tests/` | Test suite completa con pytest + factory_boy, incluye aislamiento de tenants | | `AGENTS.md` | Guía completa para agentes de IA | diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7fc61f --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# InfraSynth Base + +Reusable, multi-tenant Django infrastructure kit — the one shared package every InfraSynth app depends on. + +```bash +pip install -e ".[dev]" # install the kit + dev tooling +docker compose up -d db redis # postgres + redis +python manage.py migrate # create the schema +python manage.py runserver # http://localhost:8000/api/v1/schema/docs/ +``` + +Then `pytest` (single-command test suite), `ruff check .`, and `mypy infrasynth/`. + +--- + +## What this is + +One pip package (`infrasynth-base`) providing ten Django apps so no app ever reimplements auth, tenancy, entitlements, audit, files, notifications, webhooks, workflows, scheduling, or the API envelope: + +| Module | Responsibility | +|---|---| +| `infrasynth.shared` | Zero-Django primitives: protocols, enums, `Result`, Fernet crypto, settings helper | +| `infrasynth.api` | DRF envelope, camelCase, cursor pagination, request-id, exceptions, throttling, idempotency, webhook hardening | +| `infrasynth.tenancy` | `Tenant`, membership, invitations, platform staff, `current_tenant`, scoped managers, middleware | +| `infrasynth.security` | JWT cookie + API-key auth, roles/grants/revokes, 2FA (TOTP), ALTCHA, login brute-force guard, password policy | +| `infrasynth.audit` | Passive create/update/delete tracking, API interaction log, security events, retention purge | +| `infrasynth.features` | Operational feature flags with tenant/user/group overrides, rollout %, environment targeting | +| `infrasynth.billing` | App catalog, plans, entitlements, subscriptions, invoices, gateways, entitlement lifecycle jobs | +| `infrasynth.files` | Storage abstraction (S3/GCS/local/Cloudinary), signed URLs, processing pipelines, pluggable virus scanning | +| `infrasynth.notifications` | Multi-channel delivery with failover, retries, rate limits, and log retention | +| `infrasynth.webhooks` | Outbound delivery with HMAC + retry, verified inbound processing, event registry | +| `infrasynth.workflows` | State-machine engine, voting/approval strategies, validators | +| `infrasynth.scheduler` | Celery job dashboard + on-demand execution | + +**Every app is multi-tenant.** One deployment, one schema, row-level isolation via a non-null `tenant_id`, a fail-closed `TenantManager`, and cross-tenant access that returns `404`. See `../TENANCY.md` (binding). + +**There is no license server.** Access is an in-process *entitlement* (`is_entitled` / `check_limit`), enforced server-side. See `../ENTITLEMENTS.md` (binding). + +--- + +## Configuration + +Every knob is a namespaced dict with a safe default, read through `infrasynth.shared.settings_utils.get_setting`: + +```python +INFRASYNTH_SECURITY = {"COOKIE_SECURE": True, "IP_BLACKLIST_THRESHOLD": 100} +INFRASYNTH_TENANCY = {"REQUIRE_TENANT_BY_DEFAULT": True, "TENANT_CLAIM": "tenant"} +INFRASYNTH_BILLING = {"GRACE_PERIOD_DAYS": 5, "DEFAULT_CURRENCY": "USD"} +INFRASYNTH_NOTIFICATIONS = {"CHANNELS": {"email": {"primary": "myapp.channels.SlackChannel"}}} +``` + +The full, commented reference lives in `config/settings/base.py`. Real settings files **never** live in this package — apps supply their own and pin the kit. + +--- + +## Extending without forking + +Integrate through settings, signals, registries, ABCs, and feature flags — never a local patch: + +```python +# myapp/channels.py +from infrasynth.notifications.channels.base import BaseChannel +from infrasynth.shared.results import Result + +class SlackChannel(BaseChannel): + channel_type = "slack" + def send(self, recipient, subject, body, is_html=True, attachments=None): + ... + return Result.ok(True) + def health_check(self) -> bool: + return True + @classmethod + def from_config(cls, config): return cls(**config) +``` + +Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`. + +--- + +## Scheduled work + +`CELERY_BEAT_SCHEDULE` ships with the kit: notification retries/log purge, billing sync + lifecycle + renewal invoices, and audit retention. Run `celery -A config beat` and `celery -A config worker`. + +--- + +## Quality bar + +- `ruff check` + `ruff format --check` + `mypy infrasynth/` are CI gates. +- `pytest` runs the full suite; coverage is enforced in CI (see `pyproject.toml`). +- A change to kit behavior belongs **here**, then consuming apps bump their pin — see `../AGENTS.backend-packages.md` §9. diff --git a/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf b/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf deleted file mode 100644 index 2ba3d15..0000000 --- a/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 ->> -stream -Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[<87ef345d02f27d9ba4d6b0fa590299a4><87ef345d02f27d9ba4d6b0fa590299a4>] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1657 -%%EOF diff --git a/config/__pycache__/__init__.cpython-314.pyc b/config/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 62be409795ff1db36de10c292c0f78b1f3a1406f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 240 zcmdPqb7DckEw1F$oYbPqcp%$PljRm0gj>W6R8qtOByO?C$0z3G#K*5>_zdJS z+;Y^<$j?pH&&@ANEKAJR4=BpdN=+^)*3ZmKD@rV`%qz*zO-d|I)lW)H&Q8rs(NE6L zOUq2xkB`sH%PfhH*DI*J#bJ}1pHiBWYF7mE3&=Ia{6OLZGb1D8T?W+$+~OU2P3%P+ GKrsNb#XaNz diff --git a/config/__pycache__/celery.cpython-314.pyc b/config/__pycache__/celery.cpython-314.pyc deleted file mode 100644 index 5de0edced9d661ff871d239b973efcbd59f312ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 585 zcmYLF!EVz)5FOjtO(I%QN=-Rch*N}WbBKh5s#ZWv36#`wiv)*aZR}0zB;Jj7H<8j) zIl-sEAMhEes3=-4R8P!KU4WUBSn~3;PR6K zNAL~;tpy^>dP{q*3w;Il!MBR`GW*pM7bq}-YfU|8N~hn2z-($c7v{d{&ljC#4y<4< zs5~rHrhlWnEl~3ctf>9&L8JOB{uwT_ zXUGlrfTq|@DMJ%9aD7HI1$PtDWr$CSI9TlDHZ)E90o4n*^Aq$BwfD7CSUQHKneEITcqj1e%)0*Z%~#7lGQNXx3idJB Wv)ij*mSNpdO^bI;5}wn(zQz1e;PrOj{3Fo*T){npa0Gi!ad! zn?cqcI$|k&ZLqLl5nmP+EauDNf+c)eQm~XSOAD6qWw!*o?aQ_VyW`953YNXe%5fUG z*e-%n?iYXTlH`U^dx-T?z>Q#C(X6)W!V#jqX;|p;G=@f4Mi%YK(;cjL2r?%+?UhYc zu?VJ44o3(B0;uIZek_!mrWp3NQleO#FXt9 zSKS0VvW=rE(aq1`nqlCc$~|MK%0q*o_h=wDh|yOSYRdoPpR|yv$}OY}RJ|>;%nt6# zd|r7paii6t!QVlRq}eq7rI=sX;VLLe(s%IS2YB>53_`Kl&ZnoBm3igzY<}jz;x`zb z?asUQ{)w~w+<_Hd-=B}|!ZRm#?7$PB+K%ll#`fVWr|{Z=b#5pu4(*3k=iaFUYpk|o n`NgqatU0?Z;iaE&vtzcu$k_)KC;!5M)vIhG6Zn`Q{HO32p(-@Q diff --git a/config/celery.py b/config/celery.py index 93e1f15..b789155 100644 --- a/config/celery.py +++ b/config/celery.py @@ -1,9 +1,12 @@ import os from celery import Celery +from django.conf import settings os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") app = Celery("config") app.config_from_object("django.conf:settings", namespace="CELERY") -app.autodiscover_tasks() + +if getattr(settings, "INFRASYNTH_SCHEDULER", {}).get("AUTO_DISCOVER_TASKS", True): + app.autodiscover_tasks() diff --git a/config/settings/__pycache__/__init__.cpython-314.pyc b/config/settings/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 283ada40f62e886580375502ba076e46ec828251..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 369 zcmY+Au};G<5QgJYQDg+qU@23Jh%L|%Coqu=R&v<^N;!osw858oBt+SA=NuDiduOIXoLQtmh%*wKp z4HT5#Yl|+h&;pazIExjjib} GWBChh_H14N diff --git a/config/settings/__pycache__/base.cpython-314.pyc b/config/settings/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 8fe534828c6d9609e078691bb988a8ac1c8d28e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8717 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 F{y$B9Z|VR5 diff --git a/config/settings/__pycache__/dev.cpython-314.pyc b/config/settings/__pycache__/dev.cpython-314.pyc deleted file mode 100644 index c13c9df1f3cd01c0ecafad0c972fca05e8048a85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 580 zcmZXQKX2MV7{7A0IWX!>h~%f0N+Egspwz0 zZ!wsGcO2wO;54!iV1#fmdcu(ijFBkhZ)0#P^LX9&K~)_6c@ys8*RU55pl7+~8Pba6mv>_Rupb1LR_{j#Js4K;rS)oCWkjfm~l zJAU!h9KEc(&3gte)}}Y*Rx?|xzSnx=O17q%#X8Eqra~Szs;1t&R2y1)i^f`3fGA&3 zb5}(wD&?|XR}0P1$Djf;fT|f@rHO)KDmlea4Vo-z=TI>kntAbOg_7C$2=2oOsVaBz z5XnRATX$ApuKKu#Y!b--z9@x;a?7&&$a7>Hdu_KP2Z#{Y>jW~(>|`{d@mE6@&j7vg zQ*3IOY*1mx&nU-n-#~H|-UDtI9I+<4#1T@uT#x83}q ze)+ocUa~bOe=`11ZW;$CwJWtZtQ?%EdcKYi-qN^)8a2IYl$6FXO-R*hsi+v5BAp_I zu4sA{DMnE>VH#&FQglCq#$>3&9MlwzrfN{r>Qy8g1*jDiq|q%&DJ!U9 z=uj&euv~`?%}`)oL3H~rvv3(TRIR~0)R|IPf>ME<>7sg!pw>|J!rzLDdV{9!IN_;W z)W3n>{&3J^vEvM|$o|^=VqSOl{Ae5X%Q_~G$7ENBq=b=C< zJP(8t{3JF{f=!v5rsXL}-9E@n!PYFZGX>krob_osHxKZBcreaS!t+(|Oyi~?eO>GKK4h-$BORWCi>Iq0sE9 diff --git a/config/settings/base.py b/config/settings/base.py index 6d00f2a..4928dc8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,4 +1,6 @@ +from datetime import timedelta from pathlib import Path +from typing import cast BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -18,6 +20,8 @@ INSTALLED_APPS = [ "corsheaders", "django_celery_results", "django_celery_beat", + "rest_framework_simplejwt.token_blacklist", + "infrasynth.tenancy", "infrasynth.audit", "infrasynth.security", "infrasynth.files", @@ -30,6 +34,7 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + "infrasynth.api.middleware.RequestIdMiddleware", "django.middleware.security.SecurityMiddleware", "corsheaders.middleware.CorsMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -37,10 +42,12 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", + "infrasynth.tenancy.middleware.TenantMiddleware", "infrasynth.security.two_factor.middleware.TwoFactorMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "infrasynth.audit.middleware.AuditAPIMiddleware", + "infrasynth.api.middleware.RateLimitHeadersMiddleware", ] ROOT_URLCONF = "config.urls" @@ -68,9 +75,11 @@ TIME_ZONE = "America/Bogota" USE_TZ = True STATIC_URL = "static/" MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" MIGRATION_MODULES = { + "tenancy": "infrasynth.tenancy.migrations", "infrasynth_audit": "infrasynth.audit.migrations", "infrasynth_security": "infrasynth.security.migrations", "infrasynth_files": "infrasynth.files.migrations", @@ -101,9 +110,23 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", ], - "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "DEFAULT_RENDERER_CLASSES": [ + "infrasynth.api.renderers.EnvelopeJSONRenderer", + ], + "EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler", + "DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination", + "DEFAULT_THROTTLE_CLASSES": ["infrasynth.api.throttling.TenantRateThrottle"], "PAGE_SIZE": 25, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_THROTTLE_RATES": {"tenant": "1000/hour"}, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "InfraSynth Base API", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, } CELERY_BROKER_URL = "redis://localhost:6379/0" @@ -167,6 +190,7 @@ INFRASYNTH_SECURITY = { "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5, "ALTCHA_DIFFICULTY": 10000, "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, + "ALTCHA_PROTECT_LOGIN": False, "API_KEY_PREFIX_LENGTH": 8, "API_KEY_HASH_ALGORITHM": "pbkdf2_sha256", "API_KEY_DEFAULT_EXPIRY_DAYS": 365, @@ -174,12 +198,26 @@ INFRASYNTH_SECURITY = { "PASSWORD_REQUIRE_UPPERCASE": True, "PASSWORD_REQUIRE_DIGIT": True, "PASSWORD_REQUIRE_SPECIAL_CHAR": True, + "ENABLE_WORKSPACE_SWITCHING": True, } +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "infrasynth.security.password_validation.PasswordPolicyValidator"}, +] + AUTHENTICATION_BACKENDS = [ INFRASYNTH_SECURITY["AUTH_BACKEND_CLASS"], ] +# JWT lifetimes/rotation are derived from the INFRASYNTH_SECURITY block so there +# is a single source of truth. +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=cast(int, INFRASYNTH_SECURITY["ACCESS_TOKEN_LIFETIME_MINUTES"])), + "REFRESH_TOKEN_LIFETIME": timedelta(days=cast(int, INFRASYNTH_SECURITY["REFRESH_TOKEN_LIFETIME_DAYS"])), + "ROTATE_REFRESH_TOKENS": cast(bool, INFRASYNTH_SECURITY["ROTATE_REFRESH_TOKENS"]), + "BLACKLIST_AFTER_ROTATION": cast(bool, INFRASYNTH_SECURITY["BLACKLIST_AFTER_ROTATION"]), +} + INFRASYNTH_FILES = { "DEFAULT_STORAGE_BACKEND": "local", "STORAGE_BACKENDS": { @@ -207,11 +245,12 @@ INFRASYNTH_FILES = { "ENABLE_PROCESSING_PIPELINES": True, "PROCESSING_BACKEND": "celery", "ENABLE_X_SENDFILE": False, + "VIRUS_SCANNER": "noop", + "CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl", + "REQUIRE_VIRUS_SCAN": False, } - INFRASYNTH_NOTIFICATIONS = { "DEFAULT_FROM_EMAIL": "noreply@example.com", - "DEFAULT_FROM_SMS": "+1234567890", "CHANNELS": { "email": { "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", @@ -224,13 +263,13 @@ INFRASYNTH_NOTIFICATIONS = { "DISPATCH_BACKEND": "celery", "MAX_RETRIES": 3, "RETRY_DELAY_SECONDS": [60, 300, 900], - "TEMPLATE_ENGINE": "django", "RATE_LIMIT_PER_CHANNEL": { "email": "50/m", "sms": "10/m", }, "STORE_DISPATCH_LOGS": True, "DISPATCH_LOG_RETENTION_DAYS": 90, + "RETRY_SCAN_BATCH_SIZE": 100, } INFRASYNTH_WEBHOOKS = { @@ -241,6 +280,7 @@ INFRASYNTH_WEBHOOKS = { "SIGNATURE_ALGORITHM": "sha256", "SIGNATURE_HEADER": "X-Webhook-Signature", "DELIVERY_BACKEND": "celery", + "INBOUND_PROCESSING_BACKEND": "sync", "INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300, "MAX_PAYLOAD_SIZE_BYTES": 1048576, } @@ -271,13 +311,39 @@ INFRASYNTH_FEATURES = { "CACHE_KEY_PREFIX": "features", "ROLLOUT_HASH_ALGORITHM": "md5", "AUTO_REGISTER_FROM_SETTINGS": True, + "FLAGS": {}, "EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True, "EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True, } +# Deployment environment used by feature-flag ``environments`` targeting. +ENVIRONMENT = "development" + +INFRASYNTH_TENANCY = { + "ENABLED": True, + "TENANT_MODEL": "infrasynth.tenancy.Tenant", + "MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership", + "TENANT_CLAIM": "tenant", + "REQUIRE_TENANT_BY_DEFAULT": True, + "TENANT_ALLOWLIST_PATHS": [ + "/api/v1/auth/login/", + "/api/v1/auth/refresh/", + "/api/v1/auth/select-workspace/", + "/api/v1/auth/altcha/", + "/api/v1/auth/2fa/", + "/api/v1/billing/webhook/", + "/api/v1/schema/", + "/api/v1/tenancy/invitations/accept/", + "/healthz", + "/readyz", + ], + "ENABLE_WORKSPACE_SWITCHING": True, + "DEFAULT_LOCALE": "es", + "DEFAULT_TIMEZONE": "UTC", +} + INFRASYNTH_BILLING = { "INVOICE_NUMBER_PREFIX": "INV-", - "INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html", "GRACE_PERIOD_DAYS": 5, "MAX_RETRY_FAILED_PAYMENTS": 3, "DEFAULT_CURRENCY": "USD", @@ -286,4 +352,37 @@ INFRASYNTH_BILLING = { "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3, "WEBHOOK_TOLERANCE_SECONDS": 300, "SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24, + "ENTITLEMENT_CACHE_TTL_SECONDS": 60, +} + +# Periodic work. Every task binds the tenant(s) it touches explicitly. +CELERY_BEAT_SCHEDULE = { + "notifications-retry-pending": { + "task": "infrasynth.notifications.retry_pending_dispatches", + "schedule": 60.0, + }, + "notifications-purge-old": { + "task": "infrasynth.notifications.purge_old_dispatches", + "schedule": 86400.0, + }, + "billing-sync-subscriptions": { + "task": "infrasynth.billing.sync_subscriptions", + "schedule": cast(int, INFRASYNTH_BILLING["SYNC_SUBSCRIPTIONS_EVERY_HOURS"]) * 3600, + }, + "billing-advance-lifecycle": { + "task": "infrasynth.billing.advance_entitlement_lifecycle", + "schedule": 3600.0, + }, + "billing-expire-entitlements": { + "task": "infrasynth.billing.expire_entitlements", + "schedule": 3600.0, + }, + "billing-generate-renewal-invoices": { + "task": "infrasynth.billing.generate_renewal_invoices", + "schedule": 86400.0, + }, + "audit-purge-expired": { + "task": "infrasynth.audit.purge_expired_logs", + "schedule": 86400.0, + }, } diff --git a/config/settings/test.py b/config/settings/test.py index ec2ea0d..f86b214 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -1,7 +1,13 @@ +import tempfile +from pathlib import Path + from .base import * # noqa: F403 SECRET_KEY = "test-secret-key" +# Keep every test artifact out of the repo tree. +MEDIA_ROOT = Path(tempfile.mkdtemp(prefix="infrasynth-test-media-")) + DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", @@ -24,3 +30,22 @@ INFRASYNTH_SECURITY["CRYPTO_KEY"] = "sBptcnWgrG5Tp8MJCnSoGQzZLb_4QPwNjuM4QNTGWe4 INFRASYNTH_SECURITY["COOKIE_SECURE"] = False INFRASYNTH_AUDIT["STORE_IN_DB"] = True + +# Tests default to the pre-envelope wire format so the bulk of the suite asserts +# on raw payloads; tests/test_api enables the envelope/cursor layer explicitly. +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "infrasynth.security.auth.cookies.CookieJWTAuthentication", + "infrasynth.security.auth.api_keys.APIKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 25, + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +} + +# The tenant middleware is exercised by dedicated tests via override_settings. +INFRASYNTH_TENANCY["ENABLED"] = False +INFRASYNTH_TENANCY["REQUIRE_TENANT_BY_DEFAULT"] = False diff --git a/config/urls.py b/config/urls.py index 12912a7..2894071 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,13 +3,15 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), - path("api/auth/", include("infrasynth.security.urls")), - path("api/audit/", include("infrasynth.audit.urls")), - path("api/files/", include("infrasynth.files.urls")), - path("api/notifications/", include("infrasynth.notifications.urls")), - path("api/webhooks/", include("infrasynth.webhooks.urls")), - path("api/workflows/", include("infrasynth.workflows.urls")), - path("api/scheduler/", include("infrasynth.scheduler.urls")), - path("api/features/", include("infrasynth.features.urls")), - path("api/billing/", include("infrasynth.billing.urls")), + path("api/v1/", include("infrasynth.api.urls")), + path("api/v1/tenancy/", include("infrasynth.tenancy.urls")), + path("api/v1/auth/", include("infrasynth.security.urls")), + path("api/v1/audit/", include("infrasynth.audit.urls")), + path("api/v1/files/", include("infrasynth.files.urls")), + path("api/v1/notifications/", include("infrasynth.notifications.urls")), + path("api/v1/webhooks/", include("infrasynth.webhooks.urls")), + path("api/v1/workflows/", include("infrasynth.workflows.urls")), + path("api/v1/scheduler/", include("infrasynth.scheduler.urls")), + path("api/v1/features/", include("infrasynth.features.urls")), + path("api/v1/billing/", include("infrasynth.billing.urls")), ] diff --git a/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf b/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf deleted file mode 100644 index 1f1fe70..0000000 --- a/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf +++ /dev/null @@ -1,74 +0,0 @@ -%PDF-1.4 -%“Œ‹ž ReportLab Generated PDF document (opensource) -1 0 obj -<< -/F1 2 0 R /F2 3 0 R ->> -endobj -2 0 obj -<< -/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font ->> -endobj -3 0 obj -<< -/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font ->> -endobj -4 0 obj -<< -/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page ->> -endobj -5 0 obj -<< -/PageMode /UseNone /Pages 7 0 R /Type /Catalog ->> -endobj -6 0 obj -<< -/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False ->> -endobj -7 0 obj -<< -/Count 1 /Kids [ 4 0 R ] /Type /Pages ->> -endobj -8 0 obj -<< -/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 ->> -stream -Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream -endobj -xref -0 9 -0000000000 65535 f -0000000061 00000 n -0000000102 00000 n -0000000209 00000 n -0000000321 00000 n -0000000514 00000 n -0000000582 00000 n -0000000862 00000 n -0000000921 00000 n -trailer -<< -/ID -[] -% ReportLab generated PDF document -- digest (opensource) - -/Info 6 0 R -/Root 5 0 R -/Size 9 ->> -startxref -1657 -%%EOF diff --git a/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png b/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png b/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt b/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png b/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt b/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/513a00e428e848e8a77d007243a0f88e/test.txt b/docs/513a00e428e848e8a77d007243a0f88e/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/513a00e428e848e8a77d007243a0f88e/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/569c1d2348dd42348e16221e92268ded/img.png b/docs/569c1d2348dd42348e16221e92268ded/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/5a05d7c2edca4d509722b05f57114874/test.txt b/docs/5a05d7c2edca4d509722b05f57114874/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/5a05d7c2edca4d509722b05f57114874/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png b/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png b/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png b/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/98fd1b7317fc46668f945560910d0d77/img.png b/docs/98fd1b7317fc46668f945560910d0d77/img.png deleted file mode 100644 index 40be78bdfbeb4107e1feead553a6c9334222655d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ diff --git a/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt b/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt b/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/cfcb245ce6114826bd0063bef7b58075/test.txt b/docs/cfcb245ce6114826bd0063bef7b58075/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/cfcb245ce6114826bd0063bef7b58075/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt b/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/f454df5e834c4db2a9824a6387d7115d/test.txt b/docs/f454df5e834c4db2a9824a6387d7115d/test.txt deleted file mode 100644 index 3213863..0000000 --- a/docs/f454df5e834c4db2a9824a6387d7115d/test.txt +++ /dev/null @@ -1 +0,0 @@ -hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/infrasynth/__pycache__/__init__.cpython-314.pyc b/infrasynth/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index beab4b093ffef7e47b1cef6bc42653836ef291aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 245 zcmdPqG=woAT`A$MWx9lrA4U<*_kDJTz;C2x7g$3 zQ}UDJ<5x0#23c~;RX-y?H&s73zbLURFNC_gJTxujShq90_3ZW7QA{iMX??9{vz zBys)t_{_Y_lK6PNg34PQHo5sJr8%i~MQlJ*LGCXGx%~q(BO~KyCMHIfA{HPQ0F;?T A;Q#;t diff --git a/infrasynth/api/__init__.py b/infrasynth/api/__init__.py new file mode 100644 index 0000000..e746b28 --- /dev/null +++ b/infrasynth/api/__init__.py @@ -0,0 +1,11 @@ +"""``infrasynth.api`` — the DRF layer implementing ``API-STANDARD.md``. + +This package is not a Django app; it is a set of renderers, pagination, +exception handling, and middleware that every InfraSynth service consumes so +the wire format is identical across apps. +""" + +from .pagination import CursorPagination +from .renderers import EnvelopeJSONRenderer + +__all__ = ["CursorPagination", "EnvelopeJSONRenderer"] diff --git a/infrasynth/api/exceptions.py b/infrasynth/api/exceptions.py new file mode 100644 index 0000000..428f2e7 --- /dev/null +++ b/infrasynth/api/exceptions.py @@ -0,0 +1,117 @@ +"""Envelope-aware DRF exception handler and error helpers (``API-STANDARD.md`` §4, §5).""" + +from __future__ import annotations + +from typing import Any + +from django.http import JsonResponse +from rest_framework import status as http_status +from rest_framework.exceptions import ( + APIException, + AuthenticationFailed, + NotAuthenticated, + NotFound, + PermissionDenied, + Throttled, + ValidationError, +) +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_exception_handler + +from infrasynth.shared.exceptions import AppError + +__all__ = ["envelope_exception_handler", "error_response", "envelope_body"] + + +def _flatten_details(detail: Any) -> list[dict[str, Any]]: + """Turns a DRF ``detail`` (dict/list/str) into the standard details array.""" + if isinstance(detail, dict): + flattened: list[dict[str, Any]] = [] + for field, issue in detail.items(): + if isinstance(issue, (list, tuple)): + for entry in issue: + flattened.append({"field": str(field), "issue": str(entry)}) + else: + flattened.append({"field": str(field), "issue": str(issue)}) + return flattened + if isinstance(detail, (list, tuple)): + return [{"issue": str(entry)} for entry in detail] + if detail is None: + return [] + return [{"issue": str(detail)}] + + +def _map_exception(exc: APIException) -> tuple[str, str]: + if isinstance(exc, ValidationError): + return "VALIDATION_ERROR", "The request payload is invalid." + if isinstance(exc, (NotAuthenticated, AuthenticationFailed)): + return "AUTH_UNAUTHENTICATED", "Authentication credentials were not provided or are invalid." + if isinstance(exc, PermissionDenied): + return "AUTH_FORBIDDEN", "You do not have permission to perform this action." + if isinstance(exc, NotFound): + return "NOT_FOUND", "The requested resource was not found." + if isinstance(exc, Throttled): + return "RATE_LIMIT_EXCEEDED", "Too many requests." + return "SERVER_ERROR", "An unexpected error occurred." + + +def envelope_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None: + """DRF ``EXCEPTION_HANDLER`` producing ``{code, message, details}`` bodies. + + The envelope itself is added by :class:`EnvelopeJSONRenderer` based on the + HTTP status. Unexpected (non-``APIException``, non-``AppError``) exceptions + return ``None`` so Django still surfaces them loudly instead of masking a + bug behind a generic 500. + """ + if isinstance(exc, AppError): + return Response(exc.to_dict(), status=exc.status) + + response = drf_exception_handler(exc, context) + if response is None: + return None + + code, message = _map_exception(exc) if isinstance(exc, APIException) else ("SERVER_ERROR", str(exc)) + detail = getattr(exc, "detail", None) + response.data = { + "code": code, + "message": message, + "details": _flatten_details(detail), + } + return response + + +def envelope_body( + *, + success: bool, + data: Any = None, + error: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Builds an envelope dict for middleware that bypasses DRF views.""" + return {"success": success, "data": data, "error": error, "meta": meta or {}} + + +def error_response( + code: str, + message: str, + *, + status_code: int = http_status.HTTP_403_FORBIDDEN, + details: list[dict[str, Any]] | None = None, + request: Any = None, +) -> JsonResponse: + """Minimal envelope error for middleware (no DRF renderer in the chain).""" + from django.utils import timezone + + body = envelope_body( + success=False, + error={"code": code, "message": message, "details": details or []}, + meta={ + "requestId": getattr(request, "request_id", "") if request else "", + "timestamp": timezone.now().isoformat().replace("+00:00", "Z"), + }, + ) + response = JsonResponse(body, status=status_code) + request_id = getattr(request, "request_id", "") if request else "" + if request_id: + response["X-Request-Id"] = request_id + return response diff --git a/infrasynth/api/idempotency.py b/infrasynth/api/idempotency.py new file mode 100644 index 0000000..bff9128 --- /dev/null +++ b/infrasynth/api/idempotency.py @@ -0,0 +1,56 @@ +"""Idempotency-Key handling for state-mutating POSTs (``API-STANDARD.md`` §7).""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable +from functools import wraps +from typing import Any + +from django.core.cache import cache + +__all__ = ["idempotent"] + +_TTL_SECONDS = 24 * 60 * 60 + + +def _cache_key(request: Any, idempotency_key: str) -> str: + from infrasynth.tenancy.context import get_current_tenant + + tenant = get_current_tenant() + tenant_part = str(tenant.pk) if tenant is not None else "anon" + fingerprint = hashlib.sha256(f"{request.method}:{request.path}".encode()).hexdigest()[:16] + return f"tenant:{tenant_part}:idempotency:{idempotency_key}:{fingerprint}" + + +def idempotent(view_method: Callable[..., Any]) -> Callable[..., Any]: + """Replays the first successful response for a repeated ``Idempotency-Key``. + + Views that create real-world side effects (checkout, invitation acceptance) + apply this to the action method. Without the header the request is a no-op. + """ + + @wraps(view_method) + def wrapper(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any: + from rest_framework.response import Response + + key = request.headers.get("Idempotency-Key") + if not key: + return view_method(self, request, *args, **kwargs) + + cache_key = _cache_key(request, key) + cached = cache.get(cache_key) + if cached is not None: + response = Response(cached["data"], status=cached["status"]) + response["Idempotent-Replay"] = "true" + return response + + response = view_method(self, request, *args, **kwargs) + if 200 <= response.status_code < 300: + try: + cache.set(cache_key, {"data": response.data, "status": response.status_code}, _TTL_SECONDS) + except Exception: # noqa: BLE001 - never fail a request over cache serialization + pass + return response + + return wrapper diff --git a/infrasynth/api/middleware.py b/infrasynth/api/middleware.py new file mode 100644 index 0000000..42fb9d4 --- /dev/null +++ b/infrasynth/api/middleware.py @@ -0,0 +1,40 @@ +"""Request correlation middleware (``API-STANDARD.md`` §7).""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable + +from django.http import HttpRequest, HttpResponse + +__all__ = ["RequestIdMiddleware", "RateLimitHeadersMiddleware"] + +_HEADER = "X-Request-Id" + + +class RequestIdMiddleware: + """Ensures every request has an id, echoed in the response header and meta.""" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + request_id = request.headers.get(_HEADER) or f"req_{uuid.uuid4().hex[:20]}" + request.request_id = request_id # type: ignore[attr-defined] + response = self.get_response(request) + response[_HEADER] = request_id + return response + + +class RateLimitHeadersMiddleware: + """Adds ``X-RateLimit-*`` headers whenever a throttle recorded state.""" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + from .throttling import apply_rate_limit_headers + + response = self.get_response(request) + apply_rate_limit_headers(response, request) + return response diff --git a/infrasynth/api/pagination.py b/infrasynth/api/pagination.py new file mode 100644 index 0000000..335797d --- /dev/null +++ b/infrasynth/api/pagination.py @@ -0,0 +1,34 @@ +"""Opaque cursor pagination (``API-STANDARD.md`` §6).""" + +from __future__ import annotations + +from typing import Any + +from rest_framework.pagination import CursorPagination as DRFCursorPagination +from rest_framework.response import Response + +__all__ = ["CursorPagination"] + + +class CursorPagination(DRFCursorPagination): + """Cursor pagination with a camelCase ``pageSize`` param and envelope meta. + + Defaults to ordering by ``-pk`` so it works on every model out of the box; + views with a preferred ordering override the ``ordering`` attribute. + """ + + page_size = 25 + max_page_size = 100 + page_size_query_param = "pageSize" + cursor_query_param = "cursor" + ordering = "-pk" + + def get_paginated_response(self, data: Any) -> Response: + return Response( + { + "next": self.get_next_link(), + "previous": self.get_previous_link(), + "page_size": self.page_size, + "results": data, + } + ) diff --git a/infrasynth/api/renderers.py b/infrasynth/api/renderers.py new file mode 100644 index 0000000..7db8982 --- /dev/null +++ b/infrasynth/api/renderers.py @@ -0,0 +1,58 @@ +"""Envelope + camelCase renderer (``API-STANDARD.md`` §3, §4).""" + +from __future__ import annotations + +from typing import Any + +from django.utils import timezone +from djangorestframework_camel_case.render import CamelCaseJSONRenderer + +__all__ = ["EnvelopeJSONRenderer"] + +_PAGINATED_KEYS = {"results", "next", "previous"} + + +class EnvelopeJSONRenderer(CamelCaseJSONRenderer): + """Wraps every response in the standard envelope and camelCases its keys. + + * success (status < 400): ``{success: true, data, error: null, meta}`` + * error (status >= 400): ``{success: false, data: null, error, meta}`` + + ``meta`` always carries ``requestId`` and ``timestamp``; it carries + ``tenantId`` when a tenant is resolved and ``pagination`` for list + responses produced by :class:`infrasynth.api.pagination.CursorPagination`. + """ + + def render( + self, + data: Any, + accepted_media_type: str | None = None, + renderer_context: dict[str, Any] | None = None, + ) -> bytes: + renderer_context = renderer_context or {} + response = renderer_context.get("response") + request = renderer_context.get("request") + status_code = getattr(response, "status_code", 200) or 200 + + meta: dict[str, Any] = { + "requestId": getattr(request, "request_id", "") if request else "", + "timestamp": timezone.now().isoformat().replace("+00:00", "Z"), + } + tenant = getattr(request, "tenant", None) if request else None + if tenant is not None: + meta["tenantId"] = str(getattr(tenant, "pk", tenant)) + + if status_code >= 400: + body: dict[str, Any] = {"success": False, "data": None, "error": data, "meta": meta} + else: + payload = data + if isinstance(payload, dict) and _PAGINATED_KEYS.issubset(payload.keys()): + meta["pagination"] = { + "nextCursor": payload.get("next"), + "prevCursor": payload.get("previous"), + "pageSize": payload.get("page_size"), + } + payload = payload.get("results") + body = {"success": True, "data": payload, "error": None, "meta": meta} + + return super().render(body, accepted_media_type, renderer_context) diff --git a/infrasynth/api/throttling.py b/infrasynth/api/throttling.py new file mode 100644 index 0000000..e90bba8 --- /dev/null +++ b/infrasynth/api/throttling.py @@ -0,0 +1,58 @@ +"""Tenant-scoped throttling + standard rate-limit headers (``API-STANDARD.md`` §9).""" + +from __future__ import annotations + +import time +from typing import Any + +from rest_framework.throttling import SimpleRateThrottle + +__all__ = ["TenantRateThrottle", "apply_rate_limit_headers"] + + +class TenantRateThrottle(SimpleRateThrottle): + """Throttles per tenant *and* per identity, never per IP alone (``TENANCY.md`` §7). + + Requires a ``DEFAULT_THROTTLE_RATES["tenant"]`` rate; when it is absent the + throttle is a no-op so the kit still runs with zero configuration. + """ + + scope = "tenant" + + def get_rate(self) -> str | None: # type: ignore[override] + from django.conf import settings + + rates = getattr(settings, "DEFAULT_THROTTLE_RATES", {}) + return rates.get(self.scope) + + def get_cache_key(self, request: Any, view: Any) -> str | None: + if not self.rate: + return None + from infrasynth.tenancy.context import get_current_tenant + + tenant = get_current_tenant() + tenant_part = str(tenant.pk) if tenant is not None else "anon" + ident = self.get_ident(request) + return f"tenant:{tenant_part}:ratelimit:{self.scope}:{ident}" + + def allow_request(self, request: Any, view: Any) -> bool: + allowed = super().allow_request(request, view) + history = getattr(self, "history", []) + num_requests = getattr(self, "num_requests", 0) + if num_requests: + request._rate_limit = { # type: ignore[attr-defined] + "limit": num_requests, + "remaining": max(0, num_requests - len(history)), + "reset": int(history[-1]) if history else int(time.time()), + } + return allowed + + +def apply_rate_limit_headers(response: Any, request: Any) -> None: + """Copies throttle state recorded on the request onto the response headers.""" + info = getattr(request, "_rate_limit", None) + if not info: + return + response["X-RateLimit-Limit"] = str(info["limit"]) + response["X-RateLimit-Remaining"] = str(info["remaining"]) + response["X-RateLimit-Reset"] = str(info["reset"]) diff --git a/infrasynth/api/urls.py b/infrasynth/api/urls.py new file mode 100644 index 0000000..fe93ac0 --- /dev/null +++ b/infrasynth/api/urls.py @@ -0,0 +1,13 @@ +"""Live OpenAPI schema endpoints (``API-STANDARD.md`` §12).""" + +from __future__ import annotations + +from django.urls import path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +__all__ = ["urlpatterns"] + +urlpatterns = [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("schema/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="schema-docs"), +] diff --git a/infrasynth/api/webhooks.py b/infrasynth/api/webhooks.py new file mode 100644 index 0000000..f3fe947 --- /dev/null +++ b/infrasynth/api/webhooks.py @@ -0,0 +1,34 @@ +"""Shared webhook hardening helpers (``API-STANDARD.md`` §10).""" + +from __future__ import annotations + +import time +from typing import Any + +from infrasynth.shared.exceptions import ValidationAppError + +__all__ = ["assert_fresh_webhook"] + + +def assert_fresh_webhook(timestamp: Any, *, tolerance_seconds: int = 300) -> None: + """Rejects a webhook whose timestamp is older than the tolerance window. + + Replay protection is independent of signature validity: a correctly signed + event replayed after the window is still rejected. + """ + try: + event_ts = float(timestamp) + except (TypeError, ValueError) as exc: + raise ValidationAppError( + "Webhook timestamp is missing or invalid.", + code="VALIDATION_WEBHOOK_TIMESTAMP", + status=400, + ) from exc + + if abs(time.time() - event_ts) > tolerance_seconds: + raise ValidationAppError( + "Webhook timestamp is outside the accepted tolerance window.", + code="VALIDATION_WEBHOOK_STALE", + status=400, + details=[{"issue": "stale", "toleranceSeconds": tolerance_seconds}], + ) diff --git a/infrasynth/audit/__pycache__/__init__.cpython-314.pyc b/infrasynth/audit/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e4aa24d9533293d2106953e63e7326d5669d4215..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9%Wnvz+fA0MBY omst`YuUAlci^C>2KczG$)vkyYXg0|9Vi4mKGb1Bo5i^hl09V*6%m4rY diff --git a/infrasynth/audit/__pycache__/apps.cpython-314.pyc b/infrasynth/audit/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index fcd9ef57bb0ffb8023585ac8fbcc7796be27bf43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1091 zcmY*Y&1(}u6rW9Y(@oN9tBtK-%@#q79|?${porC~twN88!7-Rz~2h;{R^?5+$TeI`X5jb>%iQCtrb*lu!LW=?rn<_V7{vV-~fARhnd1u7?) z`-pkVp@sFrTCrcKlh@8>*{1)0Xpy?qqa`{;{ks$P>V%eY>WV^kEh}fA2@V4#sOU-! zYG{d4A{25@C~drKqR~u-FnF1EyE5sHR&x^+mvPWZRgfVK!fiw@^vz&pb3Kp=2*pE? zX@VY}M1dCJKuH(|VoxNXUzw5~Y7o7KYutqQgsDbChf4NsjhTu>hYw;fe#k*|Q#IUZ zc;?b6`a1dGx~V3kD@&Fk$}(a6+$&V0Xk0DgP8Q8#+Ahe|ATZBc+i6dGR{cV7$0Bo?jf-mJX|=y!?xl%9SI`CLhU0qhfrEEAEW7(O>44wng1jN#U)X>4h{n72i&YF4i?rh$VD83$0Z zJpiGxc8;+y=2}NGgiypWW4I18r<_=3xAT#jN2%;eWx_>8*HQd=he*d4?|pJFjH^rg ztK;d*`%iznRj+bLP^g9DiVgfM)yc17Syc9$()84MF|FGyd T&H-8eS(dx diff --git a/infrasynth/audit/__pycache__/filters.cpython-314.pyc b/infrasynth/audit/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index d67855400b81414f900d0cd850a5b3479ced567a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2051 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 diff --git a/infrasynth/audit/__pycache__/models.cpython-314.pyc b/infrasynth/audit/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 1c8f79cbe355489c8cc9e0391cf3433bb446d912..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4125 zcma)7&-6@Yj7|Nk%9vMiah6gQM3GEj}UvGY^Ml49DDEUcuWNi8pyKn~pxFr}pTw1Skv;pg>N!8L*Nb+xK?KElH7<$MEya@a@c- zdGCF1d@&Xc3DEw#uHAba5QOh=&~DyV=jETFQxonAl2{aOh)g_(eOJ+S!_D01gdt%< zkld#P$+O{Jfi-&DCAU4pyq2aHG<_#%`YcU9Xa-Ku^h?1>As6}{x5$Y+U=qeueanQt zZ^x(*%`A7?<7*9G>LMO#LtvsLFxOo#b8mQ7tgGl$7vN}j{~?Kz>$1z9XTI0e^AFSu zrOLfD9`oavmg`=EXH_$-{oce(A5 zIOt@L>%hJu{P8>XB|)$M-TYh%NI^$0Ww|W`ZVO*(-&b0Su#6N1C&hVe2|aRsThX`3 zqOrC1)Wt(P$|G4L`WD;ffl5hM^$K~Eb5V>V?k{f}YMGdy=jYTi%RW@ucJ|ksD$y#k zuI!L2*W(S5AE*8Z@~G%ZKCbUj67nnNzl+`*qAWFJ$hBsU@D?inIY@O zLs_X*Xb^V${OtOAaW1=Q&@3gNRf);6WrI?pDNHr=mPs@Wnq3j)B#8#UbVO8=xh0W# zHoPkl_@Sr44Z9?F-X(e3-W{mt&5Qrn%Db3f^09#Ahx-ihboYJcmk3k%uu`q4Otykw zE?ajkYsMB2+cTO{A{yLC=^iOFS*>8ASXXNDAX9hX*viiCQ(-w5L<+?wpJJ%9JRK&?%U~VRg5H!#7$(JIv?>91hVD_=jR{ z(oqzckM^|$AqnULMl#LdJa}{U_OkUOW&kFO$wLbUCF+)bi|kQMUmm}< zzIH=iU!9larQ37!i#)J8za}rQFD`N~tYc6fodd(xz@_#+r|_##E40D!_QA3+Yn{$M z?0Fm_BrSolZT{$ zWA*6RVS0!sq$968=Bq8d2ng|^W^BA38-Jerq!IhH_A%UcGcs0>jD5Y`9DnDZDZ9QBO|nOV6)<(nx+Mt3EqgL)UBs3S2)UV-}1>!DtP)b1z=i24L<-_tCNItk8#poPBfX)V}#9f*cs zQ!fH~eXwt>=WAny#XxJkXG<7D(MYVIvwIq7V_^3*s~BL^L1SsqdDHH9$_e38YsiK` zgIN1!3*2AMu%!9Q?_)2Zg!sl!~ zHQR<~dZd}2tfwdUGoX>aQd>OCoM~pJ>Y1tiDOe?QwYFqIZ>FA{Ik?nF{;YQMBx)j| zXX>e$f29g7IuBy#GfyM=J}l@^)pJm4B;Q4-ir&Hx0th=E@T6T4?LhSdi0zJuo_+;X zXS-mD?`O=go(4b)^m_w?b{}ZMif-o>i@?M%?`b{wje=I6_b}`m?|5S;K&uZHTj=sR zs7Zm|Aawa1_@*r&1pr7v2S^zy#D=6WWPOB3S4p`_Rkk<(0HV%@k89{G}D=*wR` rzVp?cBLTWcZ^p#J*S^0-{uDV9pnG&aC{BO5@Obg7#UBOeT4w$a&CpNZ diff --git a/infrasynth/audit/__pycache__/receivers.cpython-314.pyc b/infrasynth/audit/__pycache__/receivers.cpython-314.pyc deleted file mode 100644 index 102b4816afa0eca396d166acc83b0e243d3d522e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5851 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 L9{fc>$?xzlq!z~= diff --git a/infrasynth/audit/__pycache__/signals.cpython-314.pyc b/infrasynth/audit/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index 64dc20b7f699e1e1e5a7e6933b4f8f38c16c99b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 305 zcmdPq*0UP*j@GLT!8nzEANGswhSUiumNxvBcO`9+CkiP`!A zMfq8&$tA`5nR#hNiN%$9B^kO&iN&elIYq;;;eQ uU7C|>R|Eepd0|5QBs!x diff --git a/infrasynth/audit/__pycache__/urls.cpython-314.pyc b/infrasynth/audit/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 6f7cf18fb0155163038c3db83f67e812a729aa5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 835 zcma)4&x_MQ6rTKOlD3VyF4mrR++3=5W2Pnil)OqHmnMzkWZ&s($DOu?MN#cSyS|Q2i#{b>e_nQA{8zOZ4Q8M!g;~?6|~_!i}i4 z<-@&ANND~}37Qi9MfG<|XT>?Kl{QP7jVW zr|(ySsD($%Gtuulhv%LZ$W8GmKNI{#Q*gJe)2!EFpA=PN6kx=6k7Sl;0YOY2?;_S| zFGAfanofvOia$&Z*iabpzXGW|X`EGN8mb}K@-f>1mANU+vds3yh!DR4hfE6SZKECa zpwWwvGjO^_14SL^5^VS(Yl(*;X;=85#*X85Vdx#>jp-}~cdqt_RJ+=Xyf}c@5Wi7= zgSgMqL=pty6FBz~T=)vM-+}GV>B7t6Sor{yXSpZ2{Y-M|;@f-U+^Qb32}ukjGz1cgp^1T5;X*4=A=n}x#;FJ-b+#LOf|vM%&Q3yu zddRJ}p7{fM;g(~M9Jy4=%7=|cR2+J!dZP*|RnUrM|g&@?MZu6KH&pd$$HcN zREOwD1wK^x0N}V72o-TJ5*3hQMmqd$c-`4WB0Q$ZzO++#@^6 z`Zs#TkikuzJu;Xu;)s|T+0i+TA8WdpVmvzlzp?+ z0xNx9)GGH`Q+s5A3PeFjJD60k+zvIXYj2#3ykYpNq-EhA8nuA z(i3a5)9lDM!$*kMZS4O5f(ES15us#>@T^3HcYst$&X=(n+d7GmOr*Vyt*nv+-r_Gq zoo_2k~b9i0IAwA#NSAzkSPhAZ!av zNIwLo%Kr+D3%$$?p_L!zU4LxjxKsEC_aB3>O}=zj1aCbJu_0t!UN;mVWJzNO^-OK) zZrOcji`o_I`#VjZdN}T%_X5QI9M4t;s;duJUKlcJuo4I=XC-8v3|4u8admY|dbLvN zsUm$aV9mg7osp?Tv07${a+PbF+Ik|xtG1DZURmRsu~iYp@m0-WiB&DX&MJi#eL~wP z=pteBed6Jk4Bux${d$A(EugQ(%w2nCf4jAJA+vEj3VbL*@j#)Hm(&Wx!jecs; zX_HPr`?f~Eu;^Wr-mTH4-QdzbOdwCmT>OX(s%k|mGgXx#RV`Nw8zq#(DtMul+#af` zYdSYXNw2U2S2qMRz{eid8C1~oBF{=CRfVubT9hyi<8ze{ z0`l%Upl8NBz@Oj2sqG!|)V5Q5vBa*gZ|`_~_t?2z-@x9$x$XHkKE=PRydsGFqxuSm z%!b0OFGN%-;+1T6OaG{QT5 z9mU=-!WaT34#&FB;ju#a#|Y>?KZ_7YI0w+mc=VPdV1BkT{&4Oi<1e){e%xXFip}^H zm+`p5AsIjN!7{$DtwjH?*xrR1&c146w1uK-Qd0dX8Jq*w3!~oUHpsbLDU_F=@)Sz0wz~0(+}r9GJT?* z>2ZhYOE%M&T&Cj+bMcsra(LCKP}|GwQI)TAc+3#)1CYntdnTHN&Mkcbx57DDj75** zF5&5mPglda4)7_$@wjAHvw#}TWe~wWJS-WV(G6k05N7e&EPJyIsw3NEExPDUR)G?n zuM^rXg7&>&9~oXuPT;Ua%HS2}0q_~W0!^%)#`gfPyo#c{B(hJy diff --git a/infrasynth/audit/middleware.py b/infrasynth/audit/middleware.py index 523745a..cae8866 100644 --- a/infrasynth/audit/middleware.py +++ b/infrasynth/audit/middleware.py @@ -9,7 +9,8 @@ from .models import APIInteractionLog class AuditAPIMiddleware(MiddlewareMixin): def process_request(self, request): - request.request_id = str(uuid.uuid4()) + if not getattr(request, "request_id", None): + request.request_id = str(uuid.uuid4()) request._audit_start_time = time.time() def process_response(self, request, response): @@ -64,17 +65,26 @@ class AuditAPIMiddleware(MiddlewareMixin): if actor is not None and not hasattr(actor, "_meta"): actor = None - APIInteractionLog.objects.create( - method=request.method, - path=path, - status_code=response.status_code, - request_body=request_body, - response_body=response_body, - ip_address=request.META.get("REMOTE_ADDR"), - actor=actor, - duration_ms=duration_ms, - request_id=getattr(request, "request_id", ""), - user_agent=request.META.get("HTTP_USER_AGENT", ""), - ) + from infrasynth.tenancy.context import get_current_tenant + + tenant = get_current_tenant() + try: + APIInteractionLog.objects.create( + tenant=tenant, + method=request.method, + path=path, + status_code=response.status_code, + request_body=request_body, + response_body=response_body, + ip_address=request.META.get("REMOTE_ADDR"), + actor=actor, + duration_ms=duration_ms, + request_id=getattr(request, "request_id", ""), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + except Exception: # noqa: BLE001 - audit must never break the response + import logging + + logging.getLogger(__name__).exception("Failed to persist APIInteractionLog") return response diff --git a/infrasynth/audit/migrations/0001_initial.py b/infrasynth/audit/migrations/0001_initial.py index fa26b2b..4f3db65 100644 --- a/infrasynth/audit/migrations/0001_initial.py +++ b/infrasynth/audit/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.conf import settings @@ -9,6 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ("tenancy", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -25,7 +26,7 @@ class Migration(migrations.Migration): ("ip_address", models.GenericIPAddressField(null=True)), ("duration_ms", models.PositiveIntegerField()), ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), - ("request_id", models.CharField(max_length=64, unique=True)), + ("request_id", models.CharField(db_index=True, max_length=64)), ("user_agent", models.TextField(blank=True, default="")), ( "actor", @@ -33,29 +34,20 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL ), ), - ], - options={ - "db_table": "audit_api_interaction_log", - }, - ), - migrations.CreateModel( - name="SecurityEvent", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("event_type", models.CharField(db_index=True, max_length=50)), - ("ip_address", models.GenericIPAddressField(null=True)), - ("metadata", models.JSONField(default=dict)), - ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), - ("request_id", models.CharField(max_length=64)), ( - "actor", + "tenant", models.ForeignKey( - null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", ), ), ], options={ - "db_table": "audit_security_event", + "db_table": "audit_api_interaction_log", + "indexes": [models.Index(fields=["tenant_id", "timestamp"], name="audit_api_i_tenant__e12b1e_idx")], }, ), migrations.CreateModel( @@ -79,13 +71,57 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL ), ), + ( + "tenant", + models.ForeignKey( + blank=True, + help_text="Null = platform action; set for tenant-scoped actions.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "audit_model_change_log", "indexes": [ - models.Index(fields=["model_label", "object_id"], name="audit_model_model_l_923061_idx"), - models.Index(fields=["timestamp"], name="audit_model_timesta_4429ca_idx"), + models.Index( + fields=["tenant_id", "model_label", "object_id"], name="audit_model_tenant__a189da_idx" + ), + models.Index(fields=["tenant_id", "timestamp"], name="audit_model_tenant__3c17f6_idx"), ], }, ), + migrations.CreateModel( + name="SecurityEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_type", models.CharField(db_index=True, max_length=50)), + ("ip_address", models.GenericIPAddressField(null=True)), + ("metadata", models.JSONField(default=dict)), + ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), + ("request_id", models.CharField(max_length=64)), + ( + "actor", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "tenant", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ], + options={ + "db_table": "audit_security_event", + "indexes": [models.Index(fields=["tenant_id", "event_type"], name="audit_secur_tenant__64d72b_idx")], + }, + ), ] diff --git a/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index 62bca9473a04d587f21bf6429d384a34641a3f03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4314 zcmd5yzfk-Wa^g!QdseK{0fO!ZdtaGL{7^wubdJYsR)<$MyvW zc7&;ZDoQc7L5i`j+p|4QL8sr7R&$Iy82U3R?)Z;BU5?YlsUnO8si?*+#JwXF6%Grs zq!P!D%p*}PoQj}8iTQb_IrMw2!<`rkTQ;27%Fx&bn!8%z<~T~NcVvO#4?mN%xss%@ z3r_lexKHVfER1!%GkdY^9?j5j1CCKFMpBf2P;2e8g)+ut>9jwIn(8g)7WR(!yM4(oGKMBKwjwGjWPbT2hwNoKISDfSJTW z=6BE&r-*B*+5d9HJDbg9B#acUpf|)txkxMmKTj-~xwwsVab+yC1qE>!@uah)2s@mz z$8MuN;!=?!aH8-6k*-~&V|NX2Yk=J!B|Juh<7HnB-%Q6$|8QcKXbk+DnM zLW$*g9u`$Nt`}0$$(PO6&hDthg@qju#qE?pkjX`opBYCCUGAnX0j{ff_4AIhB!GV6 zE(1T7Dr+##~-R#Lty^0kd35 z06ToJF)PZ&xD^-~*4h-1$g8A7E}0LK?p0JMD+1oTzpLwZBmIlTdnH7iU|EjmFb8BK za3A9Fg*_z>L_f< zLIJ6!RG8^U)fdS*p@1XL1iTgbvvFI)&$l99$s*70a$*^cMI`jh9Ik%@k1f|05=$&b z&+&`k+FE8VvMDQ(hOHw7Sy7N^JioxM6TrjNH(;_bZ)xh*Ek!zv5JD;mwE`Ms*qiBz z@f+8H$8!)iq6^de^jT~p3fRfX^vwdNW50tpp%oZA%+OYD>ehs5xdi4#%B{xKbgD9~ zPlt$;HHfn;akDJwFN+AqT`c>w%!#@)VRnIKHw8t-q97qjh6T92fFVy5{qaV8>NBoX z(qjVxri36jq{5z}cWZKCb}hHSuC3l@*rk=Z`-`OO1401uo%f6+T+)52=nrSo^|?@- zh4h@)1Bkf5(TX|rXJr+1?xNKlP89XzE~3Vk>*4CkvWe<{fF#J^LMA(FW{|n;YaTvx zu6qv5l7nPwD9eFs&7pf!X(4487hU(tfTW|ljyS2fq zmBFhAwxgK#C96qFbx_qjxbF4u->3zyRRY)EK!`p|ZStD&6cS*v@E zgQ-e5b&x*ftKmnQXR&4R4S)D~HT*#HEWGsv>b^@g-{p$$a(yWJ`td)$`0k5Z;?8m6 zPAxHioS3gAR*n-ZTJGywPCU+u@2!^pffKvk+k4{g_4S;LQr>_ToB{*(_q@M$(d9l# zx7aZ9Q5&A#(RVal4KHb)<;ES)G7juNt_>wBLy0$hb!b}i-2Q3K)$m=-^M_wR?m{<^ z3mN3nCb?)IkQ;3w7yT$X!|mgh$oN6*Xrk$O!&S*jC4fdYc9S{qNAu!iEy-4>u{_;?H+|0+g zllaYwlH~JAqu{H_W?4~|^}5j6*e}RX2#hl7`f$RixJIcoQ(Uhmrtm-NN1eCL5`I_t u3a0wcTUGzncw(h#`kjNKqyMBL|Ds0UJ637h@vXb&i5+`l|E2VqA?5${)bEM_ diff --git a/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 07a141d76b3aa6c4bf3c8ebccb1af63a48189129..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9%Wnvz+fpPQLp zlvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A4agzIAjT(VMn=XWW*`dyt)?(T diff --git a/infrasynth/audit/models.py b/infrasynth/audit/models.py index 1165509..7dbd573 100644 --- a/infrasynth/audit/models.py +++ b/infrasynth/audit/models.py @@ -1,8 +1,18 @@ from django.conf import settings from django.db import models +from infrasynth.tenancy.managers import AllObjectsManager + class ModelChangeLog(models.Model): + tenant = models.ForeignKey( + "tenancy.Tenant", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="+", + help_text="Null = platform action; set for tenant-scoped actions.", + ) model_label = models.CharField(max_length=200, db_index=True) object_id = models.CharField(max_length=200, db_index=True) action = models.CharField( @@ -17,12 +27,21 @@ class ModelChangeLog(models.Model): class Meta: db_table = "audit_model_change_log" indexes = [ - models.Index(fields=["model_label", "object_id"]), - models.Index(fields=["timestamp"]), + models.Index(fields=["tenant_id", "model_label", "object_id"]), + models.Index(fields=["tenant_id", "timestamp"]), ] + objects = AllObjectsManager() + class APIInteractionLog(models.Model): + tenant = models.ForeignKey( + "tenancy.Tenant", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="+", + ) method = models.CharField(max_length=10, db_index=True) path = models.CharField(max_length=500, db_index=True) status_code = models.PositiveSmallIntegerField(db_index=True) @@ -32,14 +51,24 @@ class APIInteractionLog(models.Model): actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) duration_ms = models.PositiveIntegerField() timestamp = models.DateTimeField(auto_now_add=True, db_index=True) - request_id = models.CharField(max_length=64, unique=True) + request_id = models.CharField(max_length=64, db_index=True) user_agent = models.TextField(blank=True, default="") class Meta: db_table = "audit_api_interaction_log" + indexes = [models.Index(fields=["tenant_id", "timestamp"])] + + objects = AllObjectsManager() class SecurityEvent(models.Model): + tenant = models.ForeignKey( + "tenancy.Tenant", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="+", + ) event_type = models.CharField(max_length=50, db_index=True) actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) ip_address = models.GenericIPAddressField(null=True) @@ -49,3 +78,6 @@ class SecurityEvent(models.Model): class Meta: db_table = "audit_security_event" + indexes = [models.Index(fields=["tenant_id", "event_type"])] + + objects = AllObjectsManager() diff --git a/infrasynth/audit/receivers.py b/infrasynth/audit/receivers.py index 9e44255..82c4001 100644 --- a/infrasynth/audit/receivers.py +++ b/infrasynth/audit/receivers.py @@ -1,13 +1,19 @@ import uuid from django.conf import settings -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from .models import ModelChangeLog, SecurityEvent from .signals import security_event_occurred +def _current_tenant(): + from infrasynth.tenancy.context import get_current_tenant + + return get_current_tenant() + + def _get_excluded_models(): config = getattr(settings, "INFRASYNTH_AUDIT", {}) return set(config.get("EXCLUDED_MODELS", [])) @@ -24,6 +30,34 @@ def _get_request_id(request=None): return str(uuid.uuid4())[:8] +def _store_enabled() -> bool: + return bool(getattr(settings, "INFRASYNTH_AUDIT", {}).get("STORE_IN_DB", True)) + + +@receiver(pre_save) +def capture_previous_state(sender, instance, raw, **kwargs): + """Snapshots the current DB row onto ``instance._previous_state`` before save. + + This is what lets :func:`track_model_change` compute an update diff without + requiring every domain model to opt in. Creates (no existing row) are left + alone. Excluded models are skipped so the audit tables never audit themselves. + """ + if raw or instance.pk is None: + return + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): + return + if sender._meta.label in _get_excluded_models(): + return + manager = getattr(sender, "all_objects", None) or sender._base_manager + try: + previous = manager.filter(pk=instance.pk).first() + except Exception: # noqa: BLE001 - audit must never break a write + return + if previous is not None: + instance._previous_state = previous + + @receiver(post_save) def track_model_change(sender, instance, created, raw, **kwargs): if raw: @@ -34,9 +68,12 @@ def track_model_change(sender, instance, created, raw, **kwargs): config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): return + if not _store_enabled(): + return if created: ModelChangeLog.objects.create( + tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="create", @@ -49,6 +86,7 @@ def track_model_change(sender, instance, created, raw, **kwargs): changes = _compute_changes(instance._previous_state, instance) if changes: ModelChangeLog.objects.create( + tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="update", @@ -66,8 +104,11 @@ def track_model_delete(sender, instance, **kwargs): config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): return + if not _store_enabled(): + return ModelChangeLog.objects.create( + tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="delete", @@ -121,7 +162,10 @@ def log_security_event(sender, **kwargs): config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_SECURITY_EVENTS", True): return + if not _store_enabled(): + return SecurityEvent.objects.create( + tenant=_current_tenant(), event_type=kwargs.get("event_type", "unknown"), actor=kwargs.get("actor"), ip_address=kwargs.get("ip_address"), diff --git a/infrasynth/audit/tasks.py b/infrasynth/audit/tasks.py new file mode 100644 index 0000000..0e0e5d3 --- /dev/null +++ b/infrasynth/audit/tasks.py @@ -0,0 +1,38 @@ +"""Retention jobs for audit data (``INFRASYNTH_AUDIT.RETENTION_DAYS``).""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +from celery import shared_task +from django.utils import timezone + +from infrasynth.shared.settings_utils import get_setting + +logger = logging.getLogger(__name__) + + +@shared_task(name="infrasynth.audit.purge_expired_logs") +def purge_expired_logs(tenant_id=None): + """Deletes audit rows older than the configured retention window. + + ``RETENTION_DAYS <= 0`` disables retention (rows are kept forever). + Returns a ``{model_label: deleted_count}`` mapping. + """ + retention_days = int(get_setting("INFRASYNTH_AUDIT", "RETENTION_DAYS", 365)) + if retention_days <= 0: + return {} + + from .models import APIInteractionLog, ModelChangeLog, SecurityEvent + + cutoff = timezone.now() - timedelta(days=retention_days) + deleted: dict[str, int] = {} + for model in (ModelChangeLog, APIInteractionLog, SecurityEvent): + qs = model.objects.filter(timestamp__lt=cutoff) + if tenant_id: + qs = qs.filter(tenant_id=tenant_id) + count, _ = qs.delete() + deleted[model._meta.label] = count + logger.info("Audit retention purge removed %s", deleted) + return deleted diff --git a/infrasynth/audit/views.py b/infrasynth/audit/views.py index b1b8089..2375e75 100644 --- a/infrasynth/audit/views.py +++ b/infrasynth/audit/views.py @@ -1,5 +1,7 @@ from rest_framework import mixins, viewsets -from rest_framework.permissions import IsAuthenticated + +from infrasynth.security.permissions import IsAuthenticatedAndPermitted +from infrasynth.tenancy.context import get_current_tenant from .filters import APIInteractionLogFilter, ModelChangeLogFilter, SecurityEventFilter from .models import APIInteractionLog, ModelChangeLog, SecurityEvent @@ -10,31 +12,54 @@ from .serializers import ( ) -class ModelChangeLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): - queryset = ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() +class _AuditScopedViewSet: + """Audit rows are tenant-filtered for tenants; platform staff see all.""" + + def _scoped(self, model): + from infrasynth.tenancy.services import TenantService + + qs = model.objects.select_related("actor").order_by("-timestamp") + user = getattr(self.request, "user", None) + if user is not None and getattr(user, "is_authenticated", False): + if getattr(user, "is_superuser", False) or TenantService().is_platform_staff(user): + return qs + tenant = get_current_tenant() + if tenant is None: + return qs.none() + return qs.filter(tenant_id=tenant.pk) + + +class ModelChangeLogViewSet( + _AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): serializer_class = ModelChangeLogSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["audit.view_model_changes"] filterset_class = ModelChangeLogFilter def get_queryset(self): - return ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() + return self._scoped(ModelChangeLog) -class APIInteractionLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): - queryset = APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() +class APIInteractionLogViewSet( + _AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): serializer_class = APIInteractionLogSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["audit.view_api_logs"] filterset_class = APIInteractionLogFilter def get_queryset(self): - return APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() + return self._scoped(APIInteractionLog) -class SecurityEventViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): - queryset = SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() +class SecurityEventViewSet( + _AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): serializer_class = SecurityEventSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["audit.view_security_events"] filterset_class = SecurityEventFilter def get_queryset(self): - return SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() + return self._scoped(SecurityEvent) diff --git a/infrasynth/billing/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 6ec94ae229ce7c168d0b8b707b4a44b20cf1c919..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176 zcmdPq_I|p@<2{{|u76<*T2OpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&7nUj;5m#!Zl qpP83g5+AQuP0|Baqp1`2{FZ-q$prr%^YHUsw%*#t(rR zp(BKiUG&C~Mg!H*8p63c!udnvX+HInIc(lTE$gQiTZSra#qrB1?D?LPI;7Odgz*z# zDC9jv(!faOj!cX*#d% z$_#0rsfx?~N2sF9dUOy-Wjj#Bi}-M_l~=cCQ0k?9iZ;(W)oU+iJ(RVT!Iy8<&J02>FGwKf2 zQie);&Cm=eOHfQa05U=s_U3oH{>^TFwVO$`mMq*#ZoW&FSCiV>Wd)Viu27*=9)m0T zjP_b(WfP*oAVif3fkhbvny(V_en5lFGEYd)=R(4Eu`mMjVg?qWuuouvh2m96sjPr@ zS-_VyUn4|FxFd&9DY@SrNG2e5feOC?QNQ-6mfG%JtSvhzX2n z0QvI~#!Ob9e=%2*N@MgYsWwNif14$%e2IYYm1%^&z)JQCG>BZk$TVSHa}!5hMA6vt;#dNs~qiO=6rlRW4NyJv3NqEo)OLCwsNvwX@E$xHE$j zE>%uZ4?S??+*|$}PFT}RrJj1T1WhhI^u5_71{oK;(mv+Lci-;3&zm>g&!m$CuCC%A z{;m`97j{OM5j6(?LgO9TC6=;DIN4P!byKOTT&-$ctLj{@8r-Ow+$@ni`I1;#fmr$u z+0Kj{o&7%1!Zwdfr2$HFhB6_QF;K>5D3el|0A+H9azQE=K$)7MOj*ipQcnMkpS@K! z#KL!;?|Z?)w(kTYwiz5Zy}Bzh+fKXT2K>3h-8W8KWTXC1)CrhV=Uy`qsTZvpt5fd? zd-b!B5CUJv2-aoy6mVd?NyhR996_j}LhE}ifl zJw*mzP5MrAZAS+`z;aEgDBJ|EvB+z2n%A`-UG9j4$822Bu`3MsEwnyBRN^9T+kw+? zZCfO5yV2Zh`Pfd`_RlTHk7lme_P$3Mhv>OM)3zz5moPx>`oi4zTz`*=ylpe?aIbDV zoKvsX;w}RzX3_xHFMg=JYBtEslRrE zy|M91EhJajmr-JCM{S|wqUZuRPQ%UeVBV9H#noSpKU0P7-EQ$zQ?tq6Ou7U!bQukP z%GL*yUct^HnyYBCXrut0NLBby0lSHjNkN05c~3s6x{>^cfC+`DnLu|@TI6P?#nrJS zosttKya}(tI+(G1%a7-TrD0}Ry37v^^231qFd^$=V(FawFgbMQw+<*qQpCLgGV$2) zDK0h)S~)&7Xjl>GH8eRi)50g?dIM&0lHCS_lva>Zw)3*Hd6HV{7P^U(h*?aZ#*7xx zJ>nY!vw~eT`0l|7jEP{tghHI23(V?FFxl}Ma0xP{Imr~x`;XAHm`g)6`Vp2c6SrB5 z{V$mq24@a3SXxSOe^p@J07!7dFbJIiN#| z<{ma~qLCbMXf6(qXL49Re_BpKa#1FLcP+VUKvoYOmX8P|KZ+QxWz>xjP?tchX<7`igN!+`0~{md_;oB15L!g4Nv)J z^c(0Za5Epwsir8(A0+=tHx(61sG<(DhLY?)>#uC|RyKwNI>W^njAZ)D>%HamA%V^? nha;=~;-g;i(b-66tDk$&%RPujOwjz&FFoy*o_+++;hO&gj@cIJ diff --git a/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc b/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc deleted file mode 100644 index 20b41ba7e2a70f0611411ae47e009e28d93abfee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6289 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 a2~^fc6svN4p*P!fc&X{|e+X2>6#oa|i~DN; diff --git a/infrasynth/billing/__pycache__/services.cpython-314.pyc b/infrasynth/billing/__pycache__/services.cpython-314.pyc deleted file mode 100644 index f76e46b831e2a3650ca947fbe49c4a71fe34a218..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9564 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 diff --git a/infrasynth/billing/__pycache__/signals.cpython-314.pyc b/infrasynth/billing/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f503896d0d6f583f47c7bb794c83c9998f2e8778..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 512 zcmY+9Pfx-y6u?`ih=xB|f`BH5s3&4%7mtQBCdL>%E9ts23R}0d9m0)x^~ljrpr3~8 zmUu9pxEc8Zc7!GNC2imD*Y>^kqFg!vDn3@@(XU#+Q^+E>P1aH+bMORu@CJ0DgiY`W zPr=o8Tjn$}G}_T}qziYgA(5FIuB7!blwaQ0O{SxlFp8Xq;^8cR@% cGp-&lx=UcjW_{6^m*Wg8hp*F@A}^^De`xHG>i_@% diff --git a/infrasynth/billing/__pycache__/urls.cpython-314.pyc b/infrasynth/billing/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 413e011b564705738a88a3a863c23207c01e085c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1267 zcma)5&ubGw6rSB~b|=}UNq=M2##9u7HY>F%h>D2f=AvOMtx&gRlTMRulHD>hNog)t z5mda?lRbLWn}30S!(s*FfOzoat))V*&NNw@f``uG&Ajh@@4at+UFeI)03TaYb*0e* zz%Sv119ldBFC+jqzyh$O955v+nKG4A!k2SBra~3ImaLv!#8jzjYE&~Jg=UmS%@~cD zI@MFaR?=X)Q;zo9z4?=2lf&6kY-mL+^%~b92G9CGPqS4E9)lOPV^-`KJl)o;-ed6M zcGT)S!jsd$O4w3bC_U9y-EF7&xrs3?w`W`MleV58Z~NVrNBXS+Lke@OL2D>HiL6YM z*6>-78TlQ!p_b&7CCWdnEIYSr~h`Ksg1x@c_% zQP!998opaZp+B5=)@#V4_Z^DXob?bs(SaXf$0JUWy8gckgERF4DPniE8|<4yg|hEg z!hBe2*CWkjp)F!EM7UB|a0v388e)-Eu8lO~A?d(nanjX-G>60J5tu=@y_K#WA;Nm; zp>Wb13JY|j3^bE#ZPf-wMdUV+!OzkslxZEmIT|K*h?AWF--9CbKgjemeQCwdfljMEC z&F%n*jlM~3s4bwrOgu|$>cQ!$;O4`P#8;3HF&K$%O@ck^%Ox8vz;W#YR6TX3ueN4~-FFL3<5 d`XL&mrncebR{Z=QO^K*G5iKD<*Kvw(;V+{uPjLVM diff --git a/infrasynth/billing/__pycache__/views.cpython-314.pyc b/infrasynth/billing/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 9802bc59acc65289cd40d9b15a2d3633f0a8b9e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10362 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 Keoe5%CiY)Ts31=O diff --git a/infrasynth/billing/entitlements.py b/infrasynth/billing/entitlements.py new file mode 100644 index 0000000..8449ba2 --- /dev/null +++ b/infrasynth/billing/entitlements.py @@ -0,0 +1,101 @@ +"""``EntitlementService`` — in-process commercial enforcement (``ENTITLEMENTS.md`` §4).""" + +from __future__ import annotations + +from typing import Any + +from django.core.cache import cache + +from infrasynth.shared.settings_utils import get_setting + +from .models import Entitlement + +__all__ = ["EntitlementService"] + +_ENTITLED_STATUSES = {"trialing", "active", "past_due", "grace"} +_TENANT_OK_STATUSES = {"active", "trialing"} + + +class EntitlementService: + """The only gate for commercial access. Feature flags never replace it.""" + + def __init__(self) -> None: + self.ttl = int(get_setting("INFRASYNTH_BILLING", "ENTITLEMENT_CACHE_TTL_SECONDS", 60)) + + def _cache_key(self, tenant: Any, app_slug: str) -> str: + return f"tenant:{tenant.pk}:entitlements:{app_slug}" + + def get(self, tenant: Any, app_slug: str) -> Entitlement | None: + if tenant is None: + return None + key = self._cache_key(tenant, app_slug) + cached = cache.get(key) + if cached is not None: + return cached if isinstance(cached, Entitlement) else None + entitlement = ( + Entitlement.all_objects.filter(tenant=tenant, app__slug=app_slug).select_related("plan", "app").first() + ) + cache.set(key, entitlement, self.ttl) + return entitlement + + def is_entitled(self, tenant: Any, app_slug: str, *, feature: str | None = None) -> bool: + if tenant is None: + return False + # Tenant status gates before entitlement state (ENTITLEMENTS.md §4). + if getattr(tenant, "status", None) not in _TENANT_OK_STATUSES: + return False + entitlement = self.get(tenant, app_slug) + if entitlement is None: + return False + if entitlement.status not in _ENTITLED_STATUSES: + return False + if feature is None: + return True + return bool(self._features(entitlement).get(feature, False)) + + @staticmethod + def _features(entitlement: Entitlement) -> dict: + """``plan.features`` merged under any entitlement-level override.""" + features: dict = {} + if entitlement.plan: + features.update(entitlement.plan.features or {}) + override = (entitlement.metadata or {}).get("features") + if isinstance(override, dict): + features.update(override) + return features + + def check_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> bool: + """True when ``current`` is within the plan's ``limit`` (None = unlimited).""" + entitlement = self.get(tenant, app_slug) + if entitlement is None or entitlement.plan is None: + return False + max_value = (entitlement.plan.limits or {}).get(limit) + if max_value is None: + return True + return current < int(max_value) + + def require_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> None: + """Raises :class:`EntitlementError` when the limit would be exceeded.""" + if self.check_limit(tenant, app_slug, limit, current): + return + from infrasynth.shared.exceptions import ENTITLEMENT_LIMIT_REACHED, EntitlementError + + entitlement = self.get(tenant, app_slug) + max_value = (entitlement.plan.limits or {}).get(limit) if entitlement and entitlement.plan else None + raise EntitlementError( + code=ENTITLEMENT_LIMIT_REACHED, + app=app_slug, + limit=limit, + current=current, + max=max_value, + ) + + def invalidate(self, tenant: Any, app_slug: str | None = None) -> None: + if tenant is None: + return + if app_slug is not None: + cache.delete(self._cache_key(tenant, app_slug)) + return + slugs = Entitlement.all_objects.filter(tenant=tenant).values_list("app__slug", flat=True) + for slug in set(slugs): + cache.delete(self._cache_key(tenant, slug)) diff --git a/infrasynth/billing/filters.py b/infrasynth/billing/filters.py index 3708539..afde820 100644 --- a/infrasynth/billing/filters.py +++ b/infrasynth/billing/filters.py @@ -1,10 +1,12 @@ import django_filters from .models import ( - BillingPlan, + App, + Entitlement, Invoice, PaymentGateway, PaymentTransaction, + Plan, Subscription, ) @@ -12,45 +14,46 @@ from .models import ( class PaymentGatewayFilter(django_filters.FilterSet): class Meta: model = PaymentGateway - fields = { - "is_active": ["exact"], - } + fields = {"is_active": ["exact"]} -class BillingPlanFilter(django_filters.FilterSet): +class AppFilter(django_filters.FilterSet): class Meta: - model = BillingPlan + model = App + fields = {"slug": ["exact"], "monetization": ["exact"], "is_active": ["exact"]} + + +class PlanFilter(django_filters.FilterSet): + class Meta: + model = Plan fields = { + "app": ["exact"], + "app__slug": ["exact"], "slug": ["exact"], "interval": ["exact"], "is_active": ["exact"], } +class EntitlementFilter(django_filters.FilterSet): + class Meta: + model = Entitlement + fields = {"app": ["exact"], "app__slug": ["exact"], "plan": ["exact"], "status": ["exact"]} + + class SubscriptionFilter(django_filters.FilterSet): class Meta: model = Subscription - fields = { - "user": ["exact"], - "plan": ["exact"], - "status": ["exact"], - } + fields = {"entitlement": ["exact"], "plan": ["exact"], "status": ["exact"]} class InvoiceFilter(django_filters.FilterSet): class Meta: model = Invoice - fields = { - "user": ["exact"], - "subscription": ["exact"], - "status": ["exact"], - } + fields = {"subscription": ["exact"], "status": ["exact"]} class PaymentTransactionFilter(django_filters.FilterSet): class Meta: model = PaymentTransaction - fields = { - "invoice": ["exact"], - "status": ["exact"], - } + fields = {"invoice": ["exact"], "status": ["exact"]} diff --git a/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 00f925f679c7ccabedfe3133e2c347234a25d652..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 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 C$!s CheckoutSessionResult: ... + def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: ... @abstractmethod def handle_webhook(self, payload, headers) -> WebhookResult: ... diff --git a/infrasynth/billing/gateways/mercadopago.py b/infrasynth/billing/gateways/mercadopago.py index e51e918..d3f59db 100644 --- a/infrasynth/billing/gateways/mercadopago.py +++ b/infrasynth/billing/gateways/mercadopago.py @@ -26,6 +26,7 @@ class MercadoPagoGateway(BasePaymentGateway): def __init__(self, config: dict | None = None): config = {str(key).lower(): value for key, value in (config or {}).items()} self.access_token = config.get("access_token") + self.webhook_secret = config.get("webhook_secret") self._sdk = None @property @@ -36,7 +37,7 @@ class MercadoPagoGateway(BasePaymentGateway): self._sdk = mercadopago.SDK(self.access_token) return self._sdk - def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: self._require_credentials() preference = { "items": [ @@ -44,7 +45,7 @@ class MercadoPagoGateway(BasePaymentGateway): "title": plan.name, "quantity": 1, "currency_id": plan.price_currency, - "unit_price": float(plan.price_amount), + "unit_price": int(plan.price_amount), } ], "back_urls": { @@ -54,7 +55,12 @@ class MercadoPagoGateway(BasePaymentGateway): }, "auto_return": "approved", "notification_url": kwargs.get("notification_url") or "", - "metadata": {"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, + "metadata": { + "plan_slug": plan.slug, + "app_slug": getattr(getattr(plan, "app", None), "slug", ""), + "user_id": str(getattr(user, "pk", "")), + "tenant_id": str(getattr(tenant, "pk", "")), + }, } result = self.sdk.preference().create(preference) if result.get("status") != 201: @@ -67,14 +73,35 @@ class MercadoPagoGateway(BasePaymentGateway): ) def handle_webhook(self, payload, headers) -> WebhookResult: - event_type = payload.get("type") or "payment" + event_type = payload.get("type") or payload.get("action") or "payment" data = payload.get("data") or payload + # MercadoPago signs a manifest of id/request-id/ts with HMAC-SHA256. + if self.webhook_secret: + if not self._verify_signature(payload, headers): + return WebhookResult(event_type=event_type, is_handled=False, data=data) return WebhookResult( event_type=event_type, is_handled=True, data=data, ) + def _verify_signature(self, payload, headers) -> bool: + import hashlib + import hmac + + secret = self.webhook_secret or "" + signature = headers.get("x-signature") or headers.get("X-Signature") or "" + request_id = headers.get("x-request-id") or headers.get("X-Request-Id") or "" + parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part) + ts = parts.get("ts", "") + v1 = parts.get("v1", "") + if not ts or not v1: + return False + data_id = str((payload.get("data") or {}).get("id") or "") + manifest = f"id:{data_id};request-id:{request_id};ts:{ts};" + expected = hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, v1) + def cancel_subscription(self, subscription) -> bool: self._require_credentials() if not subscription.external_id: diff --git a/infrasynth/billing/gateways/stripe.py b/infrasynth/billing/gateways/stripe.py index 3a26662..4618dbf 100644 --- a/infrasynth/billing/gateways/stripe.py +++ b/infrasynth/billing/gateways/stripe.py @@ -38,7 +38,7 @@ class StripeGateway(BasePaymentGateway): if self.api_key: stripe.api_key = self.api_key - def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: self._require_credentials() if not plan.external_id: raise ValueError("Plan has no external price ID configured for Stripe") @@ -48,7 +48,12 @@ class StripeGateway(BasePaymentGateway): success_url=kwargs.get("success_url") or "https://example.com/success", cancel_url=kwargs.get("cancel_url") or "https://example.com/cancel", customer_email=str(getattr(user, "email", "") or ""), - metadata={"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, + metadata={ + "plan_slug": plan.slug, + "app_slug": getattr(getattr(plan, "app", None), "slug", ""), + "user_id": str(getattr(user, "pk", "")), + "tenant_id": str(getattr(tenant, "pk", "")), + }, ) return CheckoutSessionResult( session_id=session.id, diff --git a/infrasynth/billing/gateways/wompi.py b/infrasynth/billing/gateways/wompi.py index f69f952..22da299 100644 --- a/infrasynth/billing/gateways/wompi.py +++ b/infrasynth/billing/gateways/wompi.py @@ -47,12 +47,12 @@ class WompiGateway(BasePaymentGateway): self.base_url = config.get("base_url") or self.BASE_URLS.get(environment, self.BASE_URLS["sandbox"]) self.timeout = config.get("timeout") or 30 - def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: if not self.public_key: raise ValueError("Wompi public key not configured") payload = { "name": plan.name, - "amount_in_cents": int(round(float(plan.price_amount) * 100)), + "amount_in_cents": int(plan.price_amount), "currency": plan.price_currency.lower(), "single_use": True, "redirect_url": kwargs.get("success_url") or "https://example.com/success", diff --git a/infrasynth/billing/invoice_generator.py b/infrasynth/billing/invoice_generator.py index 4880bf5..5d7a581 100644 --- a/infrasynth/billing/invoice_generator.py +++ b/infrasynth/billing/invoice_generator.py @@ -12,41 +12,45 @@ logger = logging.getLogger(__name__) max_retries=3, default_retry_delay=60, ) -def generate_invoice_pdf(self, invoice_id): +def generate_invoice_pdf(self, invoice_id, tenant_id=None): """Generates a PDF for an invoice and stores it via the files service.""" + from infrasynth.tenancy.context import tenant_context + from infrasynth.tenancy.models import Tenant + from .models import Invoice - try: - invoice = Invoice.objects.select_related("user", "subscription", "subscription__plan", "gateway").get( - pk=invoice_id + tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None + with tenant_context(tenant): + try: + invoice = Invoice.all_objects.select_related("subscription", "subscription__plan", "gateway").get( + pk=invoice_id + ) + except Invoice.DoesNotExist: + logger.warning("Invoice %s not found", invoice_id) + return None + + try: + pdf_bytes = _build_invoice_pdf(invoice) + except Exception as exc: # noqa: BLE001 + logger.exception("PDF generation failed for invoice %s", invoice_id) + raise self.retry(exc=exc) from exc + + from django.core.files.base import ContentFile + + from infrasynth.files.services import FileService + + filename = f"invoice_{invoice.invoice_number}.pdf" + content = ContentFile(pdf_bytes, name=filename) + content.content_type = "application/pdf" + stored = FileService().upload( + content, + filename=filename, + metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number}, ) - except Invoice.DoesNotExist: - logger.warning("Invoice %s not found", invoice_id) - return None - try: - pdf_bytes = _build_invoice_pdf(invoice) - except Exception as exc: # noqa: BLE001 - logger.exception("PDF generation failed for invoice %s", invoice_id) - raise self.retry(exc=exc) from exc - - from django.core.files.base import ContentFile - - from infrasynth.files.services import FileService - - filename = f"invoice_{invoice.invoice_number}.pdf" - content = ContentFile(pdf_bytes, name=filename) - content.content_type = "application/pdf" - stored = FileService().upload( - content, - filename=filename, - user=invoice.user, - metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number}, - ) - - invoice.pdf_file = stored - invoice.save(update_fields=["pdf_file"]) - return invoice.id + invoice.pdf_file = stored + invoice.save(update_fields=["pdf_file"]) + return invoice.id def _build_invoice_pdf(invoice) -> bytes: @@ -86,7 +90,7 @@ def _build_invoice_pdf(invoice) -> bytes: Spacer(1, 12), Paragraph("Cliente", body_style), Paragraph( - f"{invoice.user.get_full_name() or invoice.user.username}
{invoice.user.email}", + f"{getattr(invoice.tenant, 'name', '')}", body_style, ), Spacer(1, 12), diff --git a/infrasynth/billing/migrations/0001_initial.py b/infrasynth/billing/migrations/0001_initial.py index 2092b0b..d6caff8 100644 --- a/infrasynth/billing/migrations/0001_initial.py +++ b/infrasynth/billing/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion -from django.conf import settings from django.db import migrations, models import infrasynth.shared.enums @@ -12,10 +11,27 @@ class Migration(migrations.Migration): dependencies = [ ("infrasynth_files", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tenancy", "0001_initial"), ] operations = [ + migrations.CreateModel( + name="App", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100, unique=True)), + ("name", models.CharField(max_length=200)), + ( + "monetization", + models.CharField(choices=infrasynth.shared.enums.MonetizationModel.choices, max_length=20), + ), + ("is_active", models.BooleanField(default=True)), + ("metadata", models.JSONField(default=dict)), + ], + options={ + "db_table": "billing_app", + }, + ), migrations.CreateModel( name="PaymentGateway", fields=[ @@ -31,15 +47,163 @@ class Migration(migrations.Migration): "db_table": "billing_gateway", }, ), + migrations.CreateModel( + name="Plan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100)), + ("name", models.CharField(max_length=200)), + ("price_amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")), + ("price_currency", models.CharField(default="USD", max_length=3)), + ( + "interval", + models.CharField( + choices=infrasynth.shared.enums.PlanInterval.choices, + default=infrasynth.shared.enums.PlanInterval["MONTHLY"], + max_length=20, + ), + ), + ("trial_days", models.PositiveIntegerField(default=0)), + ("features", models.JSONField(default=dict)), + ("limits", models.JSONField(default=dict)), + ("is_active", models.BooleanField(default=True)), + ("external_id", models.CharField(blank=True, max_length=200)), + ( + "app", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="plans", to="infrasynth_billing.app" + ), + ), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ], + options={ + "db_table": "billing_plan", + }, + ), + migrations.CreateModel( + name="Entitlement", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "status", + models.CharField( + choices=infrasynth.shared.enums.EntitlementStatus.choices, + default=infrasynth.shared.enums.EntitlementStatus["ACTIVE"], + max_length=20, + ), + ), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("current_period_end", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("cancel_at_period_end", models.BooleanField(default=False)), + ("source", models.CharField(default="manual", max_length=20)), + ("metadata", models.JSONField(default=dict)), + ( + "app", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="entitlements", + to="infrasynth_billing.app", + ), + ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ( + "plan", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.plan", + ), + ), + ], + options={ + "db_table": "billing_entitlement", + }, + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(blank=True, max_length=255)), + ("status", models.CharField(choices=infrasynth.shared.enums.SubscriptionStatus.choices, max_length=20)), + ("current_period_start", models.DateTimeField(blank=True, null=True)), + ("current_period_end", models.DateTimeField(blank=True, null=True)), + ("cancel_at_period_end", models.BooleanField(default=False)), + ("cancelled_at", models.DateTimeField(blank=True, null=True)), + ("trial_end", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(default=dict)), + ( + "entitlement", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="subscriptions", + to="infrasynth_billing.entitlement", + ), + ), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ( + "plan", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.plan", + ), + ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ], + options={ + "db_table": "billing_subscription", + }, + ), migrations.CreateModel( name="Invoice", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("external_id", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(max_length=100, unique=True)), - ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("invoice_number", models.CharField(max_length=100)), + ("amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")), ("currency", models.CharField(default="USD", max_length=3)), - ("tax_amount", models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ("tax_amount", models.BigIntegerField(default=0)), ("tax_name", models.CharField(blank=True, max_length=100)), ("status", models.CharField(choices=infrasynth.shared.enums.InvoiceStatus.choices, max_length=20)), ("due_date", models.DateTimeField(blank=True, null=True)), @@ -57,9 +221,12 @@ class Migration(migrations.Migration): ), ), ( - "user", + "tenant", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", ), ), ( @@ -72,46 +239,27 @@ class Migration(migrations.Migration): to="infrasynth_billing.paymentgateway", ), ), + ( + "subscription", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoices", + to="infrasynth_billing.subscription", + ), + ), ], options={ "db_table": "billing_invoice", }, ), - migrations.CreateModel( - name="BillingPlan", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("slug", models.SlugField(max_length=100, unique=True)), - ("name", models.CharField(max_length=200)), - ("description", models.TextField(blank=True)), - ("price_amount", models.DecimalField(decimal_places=2, max_digits=12)), - ("price_currency", models.CharField(default="USD", max_length=3)), - ("interval", models.CharField(choices=infrasynth.shared.enums.BillingInterval.choices, max_length=20)), - ("trial_days", models.IntegerField(default=0)), - ("features", models.JSONField(default=list)), - ("is_active", models.BooleanField(default=True)), - ("external_id", models.CharField(blank=True, max_length=255)), - ( - "gateway", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="infrasynth_billing.paymentgateway", - ), - ), - ], - options={ - "db_table": "billing_plan", - }, - ), migrations.CreateModel( name="PaymentTransaction", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("external_id", models.CharField(blank=True, max_length=255)), - ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")), ("currency", models.CharField(default="USD", max_length=3)), ("status", models.CharField(blank=True, max_length=50)), ("payment_method", models.CharField(blank=True, max_length=100)), @@ -133,67 +281,41 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="+", + related_name="transactions", to="infrasynth_billing.invoice", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "billing_transaction", + "indexes": [models.Index(fields=["tenant_id", "status"], name="billing_tra_tenant__7efcbc_idx")], }, ), - migrations.CreateModel( - name="Subscription", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("external_id", models.CharField(blank=True, max_length=255)), - ("status", models.CharField(choices=infrasynth.shared.enums.SubscriptionStatus.choices, max_length=20)), - ("current_period_start", models.DateTimeField(blank=True, null=True)), - ("current_period_end", models.DateTimeField(blank=True, null=True)), - ("cancel_at_period_end", models.BooleanField(default=False)), - ("cancelled_at", models.DateTimeField(blank=True, null=True)), - ("trial_end", models.DateTimeField(blank=True, null=True)), - ("metadata", models.JSONField(default=dict)), - ( - "gateway", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="infrasynth_billing.paymentgateway", - ), - ), - ( - "plan", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="infrasynth_billing.billingplan", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL - ), - ), - ], - options={ - "db_table": "billing_subscription", - }, + migrations.AddConstraint( + model_name="plan", + constraint=models.UniqueConstraint(fields=("app", "slug"), name="uniq_plan_slug_per_app"), ), - migrations.AddField( + migrations.AddConstraint( + model_name="entitlement", + constraint=models.UniqueConstraint(fields=("tenant", "app"), name="uniq_tenant_app_entitlement"), + ), + migrations.AddIndex( + model_name="subscription", + index=models.Index(fields=["tenant_id", "status"], name="billing_sub_tenant__d36cf8_idx"), + ), + migrations.AddConstraint( model_name="invoice", - name="subscription", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="infrasynth_billing.subscription", + constraint=models.UniqueConstraint( + fields=("tenant", "invoice_number"), name="uniq_invoice_number_per_tenant" ), ), ] diff --git a/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index 7a5f2da4c55d55ea7e7cb4aec8d00d68ce5fc6a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7962 zcmd5>T}&HS7WV%i4vdL`VuQi_gqRfb=Ry-m8o&fdAaMqW)HE;}dj^=qjNO?rDOpwO z!)l-Vm_BXxX}fQEF@MR8?3kVWaW3wFUdP1-_9M$+qp2?4(t4kPpGO-#kEn zj#{?dDdK#8!m9VhZITy82G2{toNT0ht10zSt#{iDX(b(`bK02Oq>XfeY^bK}3F)3T z?17x8o*d~tBj*FTu3GxGTF54oK0nCy){+Ac&2pFO>I(d+~HQ9Vg5LY-A_$q-0hspY}8 zZQDFB3ux`FAOmra*41hSUro4@ef1=_f~n#2Ge~`0qc2{g zZ|r>Ec985h&1V2CPn?&6T(H9Lph@yd!;(WFS)0dad3Uw0+*!WIKrT_w$E|Sc)AR3A z?KS#t)aaW%-$xFU*T|2jjrs_29I0pj7uvp6%XaPRMaE6M8m;FKj@d8tsWu*CW<268 zPzA;tssa-itO6gdzUJJX1PmIfy$O)JeI93x-=COj>J-Q|Rx76KSw2@QKCLHtfvlcC z?|bThXZ3ZkoIF1=idS^=3ULGE8p+f~SDePH`0veOKX7=aAr8-8ki(5*@N2A3bypJ7 zx;=4b&AC~RogjI3b!xOu-vW*G){@Umx%NB9p1ciy{>2&j>U#0?|@|Qd2jG3YFOiP!;+0=$M4Ph6`0X)%lf-! z*8jilqT+Z3xc8GRT&~HX+vMtRj@?=nJMw;I?-zGTgX9D03YmgaHHo}yl@s&^YEIFI z`z$Xl(GvTdK12>NTiCr5v%@i){))=7{I0Zzn(_j-PYZ|CGxiX9MONVGEcb#%eufkC zS^ALT>3ue474W42y0ojSqB2=p6uEbBo<*)qj^E*S;Uw>7*d4l%m5`ScDLNx@2P_JT zg?v6INGwBT3WC7$8IBcE^K&-6m&-k)L^dO^5}f><=`77Z)2(=zG$qmLEQ?ywTs8}L z1C(JGe0N}fkZXryvy6z`xx9W`;9lVma$JT*HjY8Im1N9;{B%LeQJ^1uW`rj2U%+<* z$bY~J>72-FGeb@6R}w4mbe4jF&77`<;tTs}R*2bjhg=1o`?3I5>HS=R$L=;WYzAP< zQUFl~CZr+CaJ!r&B9AfALuB7vPl677^Pns8NdUZ|#4hyXe(fbb6C)3Psn1T7XsLiN zMBJG@JS(vROo62sm<}k#mlXg;A)7_+Jk2o_Eg>IZ#ZsKa?t>TmtVDw>jXZf~huYx) zPV2Q7VUFJsXz`Gj_Ow1RE=oCpW$^o$Q`0KolO*!ycna=cSP7^guvv_u?wn1^Ax8m- z60-|ZG`DX)MUj7VLY#s(GaPI)_@!}&FisF0TF}_tgeY+#f-pd5e5co zGxh^rGpvCjYSvrt0rzk`1oME-BAZk>i$bq z9S7s&#C3;-{6wZh6vMmOf?9_*x1-O#qpd3af%Hg52X#I zDCDCkxcVz(S$OtS)RzT1i?ae+EfjE85G71=J_j8T0d2g1FO8tqJg4({TsVNac>n;a z&7pwr=1|Mr=EgF$xxPqJYY&o(t5Du561W~UEofC@4bMQphkWE+$F&j{8RT8qqXpyf z?)roK`lEy4G7|F7=W4x7));*UQkC1~??I_{F;8iJH-~IY8hNxt#q{;8FK$rxH&<7Y zdtq*UVJ^9dycnyC`c9|~?Tp?%;*N&Eve|70mQw|=v9>auu8%AQu$GgR^neeY4P-Y#Cf z{ddpYsnZ^GeY1M%w|FnBo=C|PDF>o&0v#{g)y`O{GxmD@n2{fSp>#fxiLG*==T*BJ z9WO=4kJgpwoJ`D@1HG@VsC^TqzKNrZ(l;*?3*|uP%PBQ{trWiYI;n)O%fyXxpzGz1 z8W}G|#*YS+$Y(NfyBrw$zFm!7FU77O4=AxEnOFwPucp-g$x{F1(ZI>1(*HmvQl~AR zVDo9v5(vv(Bd2y-Tl2e)X0PuwVsbe2CeZfnf!Z-#>KHz9oD9g1DVZ0P4pAngav<`` zrS^`MddFT%Cw8TGRVLQVmc)~U(w~xvhlbzrQaFA@pUf*^Y`z=_zkH;2kCeJcUb81w zrTeZ-+$#sVUmmDE6Q!Ps$Y{Uru5EA~yOeKW`Rshua?7~zH4%|wVyh@f!5P@OIx@)xYn?RV&8&HZpx3->SLk!Sa=8Vfx%7~ z+-zyPnB`k0%QHg|bJA)K`lfXRqW;8c3VY;(VK-AasC3?uiKVic*Gi|(OcOnn-dUNr zX|Oy|iYAWdWO74^Zpy?XGi4S}=n7@3?0Bk9EfuGh)Ty=N)S5cARh-&Vr`X~YD+`j+ zU66?b6K#&h>qe|*u9%#xaQIEIyBeGZB5TICvmNjq19)HF*Hdb;3&6c$!glb3*w%38 zBH2@|4+F;&N>~p~joh!nX9L5@QqSaZQ0c)#e+dvk@r$u^+QibKZb0#tM$&N|#yM+0 z;J9oiY0u9DQY)W7sTbSD#I~B)DJFJgv7m$xWa7C|MsJp4H;jtns$N+p^q@Vk4nxo$=|KyZJmGRjFCbHH z{YY)?FSYiU+e78H&T^>t&#~`fYG}9^8dgJ-#n7Z0nk|NA%iX=@%L(xBa`QX44>WB$ z3$|9*AMXm!E30wLhdT9LNt{W?^?%>SD_axc8QvE(8Sn9#QQw()-;dBXar*A7qq${}5wn-A$iY;xgp_~2+EB4Ild&XPB*U-fOXBCIw X=la@ab$#noiGd<9@NWxlX^Q>}R+7Pz diff --git a/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 9332614103d60984f56aeac54dd975498e6f15f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 187 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u766``MzpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&7nUj;5m#&|i znO>Awl9``ZtREkrnU`4-AFo$Xd5gm)H$SB`C)KWq6=)I2Da9bhCuT-Q#v*1Q3jibI BF%|#- diff --git a/infrasynth/billing/models.py b/infrasynth/billing/models.py index 90d0b3d..3bd01c4 100644 --- a/infrasynth/billing/models.py +++ b/infrasynth/billing/models.py @@ -1,10 +1,87 @@ -from django.conf import settings from django.db import models -from infrasynth.shared.enums import BillingInterval, InvoiceStatus, SubscriptionStatus +from infrasynth.shared.enums import ( + EntitlementStatus, + InvoiceStatus, + MonetizationModel, + PlanInterval, + SubscriptionStatus, +) +from infrasynth.tenancy.mixins import TenantOwnedModel + + +class App(models.Model): + """A deployed InfraSynth app in the catalog (global).""" + + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + monetization = models.CharField(max_length=20, choices=MonetizationModel.choices) + is_active = models.BooleanField(default=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_app" + + def __str__(self): + return self.name + + +class Plan(models.Model): + """A purchasable tier of an app (global). Money is integer minor units.""" + + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans") + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=200) + price_amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.") + price_currency = models.CharField(max_length=3, default="USD") + interval = models.CharField(max_length=20, choices=PlanInterval.choices, default=PlanInterval.MONTHLY) + trial_days = models.PositiveIntegerField(default=0) + features = models.JSONField(default=dict) + limits = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + gateway = models.ForeignKey( + "PaymentGateway", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + external_id = models.CharField(max_length=200, blank=True) + + class Meta: + db_table = "billing_plan" + constraints = [models.UniqueConstraint(fields=["app", "slug"], name="uniq_plan_slug_per_app")] + + def __str__(self): + return f"{self.app.slug}:{self.slug}" + + +class Entitlement(TenantOwnedModel): + """A tenant's right to use an app under a plan. The unit of enforcement.""" + + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements") + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + status = models.CharField(max_length=20, choices=EntitlementStatus.choices, default=EntitlementStatus.ACTIVE) + started_at = models.DateTimeField(auto_now_add=True) + current_period_end = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + cancel_at_period_end = models.BooleanField(default=False) + source = models.CharField(max_length=20, default="manual") + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_entitlement" + constraints = [ + models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement"), + ] + + def __str__(self): + return f"{self.tenant_id}:{self.app.slug}:{self.status}" class PaymentGateway(models.Model): + """Platform payment provider account (global). Credentials are Fernet-encrypted.""" + slug = models.SlugField(max_length=100, primary_key=True) display_name = models.CharField(max_length=200) gateway_class = models.CharField(max_length=500) @@ -20,40 +97,18 @@ class PaymentGateway(models.Model): return self.display_name -class BillingPlan(models.Model): - slug = models.SlugField(max_length=100, unique=True) - name = models.CharField(max_length=200) - description = models.TextField(blank=True) - price_amount = models.DecimalField(max_digits=12, decimal_places=2) - price_currency = models.CharField(max_length=3, default="USD") - interval = models.CharField(max_length=20, choices=BillingInterval.choices) - trial_days = models.IntegerField(default=0) - features = models.JSONField(default=list) - is_active = models.BooleanField(default=True) - gateway = models.ForeignKey( - PaymentGateway, +class Subscription(TenantOwnedModel): + """An active subscription backing an entitlement (tenant-owned).""" + + entitlement = models.ForeignKey( + Entitlement, on_delete=models.SET_NULL, null=True, blank=True, - related_name="+", - ) - external_id = models.CharField(max_length=255, blank=True) - - class Meta: - db_table = "billing_plan" - - def __str__(self): - return self.name - - -class Subscription(models.Model): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="+", + related_name="subscriptions", ) plan = models.ForeignKey( - BillingPlan, + Plan, on_delete=models.SET_NULL, null=True, blank=True, @@ -77,23 +132,21 @@ class Subscription(models.Model): class Meta: db_table = "billing_subscription" + indexes = [models.Index(fields=["tenant_id", "status"])] def __str__(self): - return f"{self.user}#{self.plan}" + return f"{self.tenant_id}#{self.plan}" -class Invoice(models.Model): +class Invoice(TenantOwnedModel): + """A generated invoice (tenant-owned). Money is integer minor units.""" + subscription = models.ForeignKey( Subscription, on_delete=models.SET_NULL, null=True, blank=True, - related_name="+", - ) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="+", + related_name="invoices", ) gateway = models.ForeignKey( PaymentGateway, @@ -103,10 +156,10 @@ class Invoice(models.Model): related_name="+", ) external_id = models.CharField(max_length=255, blank=True) - invoice_number = models.CharField(max_length=100, unique=True) - amount = models.DecimalField(max_digits=12, decimal_places=2) + invoice_number = models.CharField(max_length=100) + amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.") currency = models.CharField(max_length=3, default="USD") - tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + tax_amount = models.BigIntegerField(default=0) tax_name = models.CharField(max_length=100, blank=True) status = models.CharField(max_length=20, choices=InvoiceStatus.choices) due_date = models.DateTimeField(null=True, blank=True) @@ -123,18 +176,23 @@ class Invoice(models.Model): class Meta: db_table = "billing_invoice" + constraints = [ + models.UniqueConstraint(fields=["tenant", "invoice_number"], name="uniq_invoice_number_per_tenant"), + ] def __str__(self): return self.invoice_number -class PaymentTransaction(models.Model): +class PaymentTransaction(TenantOwnedModel): + """A single payment attempt (tenant-owned).""" + invoice = models.ForeignKey( Invoice, on_delete=models.SET_NULL, null=True, blank=True, - related_name="+", + related_name="transactions", ) gateway = models.ForeignKey( PaymentGateway, @@ -144,7 +202,7 @@ class PaymentTransaction(models.Model): related_name="+", ) external_id = models.CharField(max_length=255, blank=True) - amount = models.DecimalField(max_digits=12, decimal_places=2) + amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.") currency = models.CharField(max_length=3, default="USD") status = models.CharField(max_length=50, blank=True) payment_method = models.CharField(max_length=100, blank=True) @@ -153,6 +211,7 @@ class PaymentTransaction(models.Model): class Meta: db_table = "billing_transaction" + indexes = [models.Index(fields=["tenant_id", "status"])] def __str__(self): return f"txn_{self.pk}" diff --git a/infrasynth/billing/serializers.py b/infrasynth/billing/serializers.py index e96f3da..3ffb66e 100644 --- a/infrasynth/billing/serializers.py +++ b/infrasynth/billing/serializers.py @@ -1,14 +1,88 @@ from rest_framework import serializers from .models import ( - BillingPlan, + App, + Entitlement, Invoice, PaymentGateway, PaymentTransaction, + Plan, Subscription, ) +class AppSerializer(serializers.ModelSerializer): + class Meta: + model = App + fields = ["id", "slug", "name", "monetization", "is_active", "metadata"] + read_only_fields = ["id"] + + +class PlanSerializer(serializers.ModelSerializer): + app_info = serializers.SerializerMethodField() + gateway_info = serializers.SerializerMethodField() + + class Meta: + model = Plan + fields = [ + "id", + "app", + "app_info", + "slug", + "name", + "price_amount", + "price_currency", + "interval", + "trial_days", + "features", + "limits", + "is_active", + "gateway", + "gateway_info", + "external_id", + ] + read_only_fields = ["id"] + + def get_app_info(self, obj): + return {"id": obj.app_id, "slug": obj.app.slug, "name": obj.app.name} + + def get_gateway_info(self, obj): + if obj.gateway_id: + return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} + return None + + +class EntitlementSerializer(serializers.ModelSerializer): + app_info = serializers.SerializerMethodField() + plan_info = serializers.SerializerMethodField() + + class Meta: + model = Entitlement + fields = [ + "id", + "app", + "app_info", + "plan", + "plan_info", + "status", + "started_at", + "current_period_end", + "expires_at", + "cancel_at_period_end", + "source", + "metadata", + ] + read_only_fields = ["id", "started_at"] + + def get_app_info(self, obj): + return {"slug": obj.app.slug, "name": obj.app.name} + + def get_plan_info(self, obj): + if obj.plan_id: + return {"id": obj.plan_id, "slug": obj.plan.slug, "name": obj.plan.name} + return None + + class PaymentGatewaySerializer(serializers.ModelSerializer): class Meta: model = PaymentGateway @@ -24,45 +98,17 @@ class PaymentGatewaySerializer(serializers.ModelSerializer): read_only_fields = ["slug"] -class BillingPlanSerializer(serializers.ModelSerializer): - gateway_info = serializers.SerializerMethodField() - - class Meta: - model = BillingPlan - fields = [ - "id", - "slug", - "name", - "description", - "price_amount", - "price_currency", - "interval", - "trial_days", - "features", - "is_active", - "gateway", - "gateway_info", - "external_id", - ] - read_only_fields = ["id"] - - def get_gateway_info(self, obj): - if obj.gateway_id: - return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} - return None - - class SubscriptionSerializer(serializers.ModelSerializer): plan_info = serializers.SerializerMethodField() gateway_info = serializers.SerializerMethodField() - user_info = serializers.SerializerMethodField() + entitlement_info = serializers.SerializerMethodField() class Meta: model = Subscription fields = [ "id", - "user", - "user_info", + "entitlement", + "entitlement_info", "plan", "plan_info", "gateway", @@ -88,14 +134,15 @@ class SubscriptionSerializer(serializers.ModelSerializer): return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} return None - def get_user_info(self, obj): - return {"id": obj.user_id, "display": str(obj.user)} + def get_entitlement_info(self, obj): + if obj.entitlement_id: + return {"id": obj.entitlement_id, "status": obj.entitlement.status} + return None class InvoiceSerializer(serializers.ModelSerializer): subscription_info = serializers.SerializerMethodField() gateway_info = serializers.SerializerMethodField() - user_info = serializers.SerializerMethodField() pdf_file_info = serializers.SerializerMethodField() class Meta: @@ -104,8 +151,6 @@ class InvoiceSerializer(serializers.ModelSerializer): "id", "subscription", "subscription_info", - "user", - "user_info", "gateway", "gateway_info", "external_id", @@ -134,9 +179,6 @@ class InvoiceSerializer(serializers.ModelSerializer): return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} return None - def get_user_info(self, obj): - return {"id": obj.user_id, "display": str(obj.user)} - def get_pdf_file_info(self, obj): if obj.pdf_file_id: return {"id": obj.pdf_file_id, "filename": obj.pdf_file.original_filename} @@ -179,6 +221,15 @@ class PaymentTransactionSerializer(serializers.ModelSerializer): return None +class CheckoutSerializer(serializers.Serializer): + """``POST /billing/checkout/`` — tenant comes from the token, never the body.""" + + app = serializers.SlugField() + plan = serializers.SlugField() + success_url = serializers.URLField(required=False) + cancel_url = serializers.URLField(required=False) + + class SubscribeSerializer(serializers.Serializer): plan_slug = serializers.SlugField() success_url = serializers.URLField(required=False) diff --git a/infrasynth/billing/services.py b/infrasynth/billing/services.py index f6d13e2..cafc358 100644 --- a/infrasynth/billing/services.py +++ b/infrasynth/billing/services.py @@ -1,15 +1,20 @@ +"""Billing service: checkouts, subscriptions, invoices (``ENTITLEMENTS.md``).""" + +from __future__ import annotations + import logging from datetime import timedelta -from decimal import Decimal from django.utils import timezone from django.utils.module_loading import import_string -from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus +from infrasynth.shared.enums import EntitlementStatus, InvoiceStatus, SubscriptionStatus from infrasynth.shared.settings_utils import get_setting -from .models import BillingPlan, Invoice, PaymentGateway, Subscription +from .entitlements import EntitlementService +from .models import App, Entitlement, Invoice, PaymentGateway, PaymentTransaction, Plan, Subscription from .signals import ( + entitlement_changed, invoice_generated, subscription_cancelled, subscription_created, @@ -17,46 +22,48 @@ from .signals import ( logger = logging.getLogger(__name__) +__all__ = ["BillingService", "EntitlementService"] + class BillingService: - """Public API for subscriptions, checkouts, and invoices.""" + """Public API for plans, checkouts, subscriptions, and invoices.""" def create_checkout_session( self, - plan_slug: str, - user, + plan: Plan, + tenant, + user=None, success_url: str | None = None, cancel_url: str | None = None, ): """Creates a checkout session in the plan's payment gateway.""" - plan = BillingPlan.objects.select_related("gateway").get(slug=plan_slug, is_active=True) gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first() if gateway is None: raise ValueError("No active payment gateway configured for this plan.") gateway_instance = self._get_gateway(gateway) - result = gateway_instance.create_checkout_session( + return gateway_instance.create_checkout_session( plan, user, + tenant=tenant, success_url=success_url, cancel_url=cancel_url, - ) - return result, gateway + ), gateway def create_subscription( self, - plan: BillingPlan, - user, - gateway: PaymentGateway, + plan: Plan, + tenant, + gateway: PaymentGateway | None, external_id: str = "", ) -> Subscription: - """Creates a subscription record after checkout confirmation.""" + """Creates a subscription record (and its entitlement) after checkout.""" now = timezone.now() interval_days = 365 if plan.interval == "yearly" else 30 trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None - subscription = Subscription.objects.create( - user=user, + subscription = Subscription.all_objects.create( + tenant=tenant, plan=plan, gateway=gateway, external_id=external_id, @@ -66,17 +73,41 @@ class BillingService: trial_end=trial_end, ) + entitlement, _ = Entitlement.all_objects.get_or_create( + tenant=tenant, + app=plan.app, + defaults={ + "plan": plan, + "status": EntitlementStatus.TRIALING if trial_end else EntitlementStatus.ACTIVE, + "current_period_end": subscription.current_period_end, + "source": gateway.slug if gateway else "manual", + }, + ) + entitlement.plan = plan + entitlement.status = EntitlementStatus.TRIALING if trial_end and trial_end > now else EntitlementStatus.ACTIVE + entitlement.current_period_end = subscription.current_period_end + entitlement.save() + subscription.entitlement = entitlement + subscription.save(update_fields=["entitlement"]) + + EntitlementService().invalidate(tenant, plan.app.slug) + entitlement_changed.send( + sender=Entitlement, + tenant_id=str(tenant.pk), + app=plan.app.slug, + status=entitlement.status, + ) subscription_created.send( sender=Subscription, - user=user, + tenant_id=str(tenant.pk), plan_slug=plan.slug, + app_slug=plan.app.slug, gateway=gateway.slug if gateway else "", external_id=external_id, ) return subscription def cancel_subscription(self, subscription: Subscription) -> bool: - """Cancels a subscription in the gateway and locally.""" gateway = subscription.gateway if gateway: try: @@ -90,16 +121,22 @@ class BillingService: subscription.cancel_at_period_end = False subscription.save() + if subscription.entitlement_id: + entitlement = subscription.entitlement + if entitlement is not None: + entitlement.status = EntitlementStatus.CANCELLED + entitlement.save(update_fields=["status"]) + EntitlementService().invalidate(subscription.tenant, entitlement.app.slug) + subscription_cancelled.send( sender=Subscription, - user=subscription.user, + tenant_id=str(subscription.tenant_id), plan_slug=subscription.plan.slug if subscription.plan else "", reason="user_requested", ) return True def sync_subscription(self, subscription: Subscription) -> dict: - """Syncs subscription state from the gateway into the local record.""" gateway = subscription.gateway if gateway is None: return {} @@ -122,25 +159,25 @@ class BillingService: def generate_invoice( self, subscription: Subscription, - amount=None, + amount: int | None = None, line_items: list | None = None, ) -> Invoice: - """Creates an invoice record and triggers PDF generation.""" + """Creates an invoice (minor units) and triggers PDF generation.""" plan = subscription.plan currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD") if amount is None: amount = plan.price_amount if plan else 0 - amount = Decimal(str(amount)) + amount = int(amount) tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0) tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "") - tax_amount = amount * (Decimal(tax_percentage) / Decimal(100)) + tax_amount = int(amount * (int(tax_percentage) / 100)) - invoice = Invoice.objects.create( + invoice = Invoice.all_objects.create( + tenant=subscription.tenant, subscription=subscription, - user=subscription.user, gateway=subscription.gateway, - invoice_number=self._next_invoice_number(), + invoice_number=self._next_invoice_number(subscription.tenant), amount=amount, currency=currency, tax_amount=tax_amount, @@ -151,7 +188,7 @@ class BillingService: or [ { "description": plan.name if plan else "Subscription", - "amount": float(amount), + "amount": amount, "quantity": 1, } ], @@ -159,23 +196,189 @@ class BillingService: invoice_generated.send( sender=Invoice, - user=subscription.user, + tenant_id=str(subscription.tenant_id), invoice_id=invoice.id, - amount=float(invoice.amount), + amount=invoice.amount, ) from .invoice_generator import generate_invoice_pdf - generate_invoice_pdf.delay(invoice.id) + generate_invoice_pdf.delay(invoice.id, str(subscription.tenant_id)) return invoice - def _next_invoice_number(self) -> str: + # --- entitlement lifecycle (ENTITLEMENTS.md §5) ------------------------- + + def mark_past_due(self, entitlement: Entitlement) -> None: + entitlement.status = EntitlementStatus.PAST_DUE + metadata = dict(entitlement.metadata or {}) + metadata.setdefault("past_due_since", timezone.now().isoformat()) + entitlement.metadata = metadata + entitlement.save(update_fields=["status", "metadata"]) + EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) + + def enter_grace(self, entitlement: Entitlement) -> None: + entitlement.status = EntitlementStatus.GRACE + entitlement.save(update_fields=["status"]) + EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) + + def suspend_entitlement(self, entitlement: Entitlement) -> None: + entitlement.status = EntitlementStatus.SUSPENDED + entitlement.save(update_fields=["status"]) + EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) + + def reinstate_entitlement(self, entitlement: Entitlement) -> None: + entitlement.status = EntitlementStatus.ACTIVE + entitlement.save(update_fields=["status"]) + EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) + + # --- helpers ------------------------------------------------------------ + + def _next_invoice_number(self, tenant) -> str: prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-") year = timezone.now().year full_prefix = f"{prefix}{year}-" - count = Invoice.objects.filter(invoice_number__startswith=full_prefix).count() + count = Invoice.all_objects.filter(tenant=tenant, invoice_number__startswith=full_prefix).count() return f"{full_prefix}{count + 1:06d}" def _get_gateway(self, gateway: PaymentGateway): gateway_cls = import_string(gateway.gateway_class) return gateway_cls(gateway.config) + + # --- inbound webhooks (ENTITLEMENTS.md §6) ------------------------------ + + def process_webhook_event(self, gateway, event_type: str, data: dict, *, event_id: str | None = None) -> dict: + """Idempotently applies a verified provider event to billing state. + + Returns a small result dict describing what happened. Provider payloads + differ, so this extracts the common fields heuristically; a provider can + pre-normalize in its ``handle_webhook``. + """ + from django.core.cache import cache + + from infrasynth.tenancy.models import Tenant + + from .signals import payment_failed, payment_succeeded + + data = data if isinstance(data, dict) else {} + tenant_id = self._extract_tenant_id(data) + tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None + + if event_id: + idem_key = f"tenant:{tenant_id or 'unknown'}:billing:webhook:{event_id}" + if cache.get(idem_key): + return {"status": "duplicate", "event_id": event_id} + cache.set(idem_key, True, 60 * 60 * 24) + + lower = event_type.lower() + is_paid = any(token in lower for token in ("paid", "succeeded", "approved", "activated")) + is_failed = any(token in lower for token in ("failed", "declined", "past_due", "rejected")) + + subscription = self._find_subscription(data, tenant) + entitlement = subscription.entitlement if subscription and subscription.entitlement_id else None + if entitlement is None and tenant is not None: + entitlement = Entitlement.all_objects.filter(tenant=tenant).order_by("-id").first() + + invoice = self._find_invoice(data, tenant) + amount, currency = self._extract_amount(data) + transaction = None + if tenant is not None: + transaction = PaymentTransaction.all_objects.create( + tenant=tenant, + gateway=gateway, + invoice=invoice, + external_id=str(data.get("id") or data.get("payment_id") or ""), + amount=amount, + currency=currency, + status=event_type, + metadata=data, + ) + + result: dict = {"status": "processed", "event_type": event_type} + + if is_paid and entitlement is not None: + entitlement.status = EntitlementStatus.ACTIVE + entitlement.metadata = {**(entitlement.metadata or {}), "failed_payments": 0} + entitlement.save(update_fields=["status", "metadata"]) + EntitlementService().invalidate(tenant, entitlement.app.slug) + if tenant is not None and tenant.status != Tenant.Status.ACTIVE: + tenant.status = Tenant.Status.ACTIVE + tenant.suspended_at = None + tenant.save(update_fields=["status", "suspended_at"]) + if invoice is not None and invoice.status != InvoiceStatus.PAID: + invoice.status = InvoiceStatus.PAID + invoice.paid_at = timezone.now() + invoice.save(update_fields=["status", "paid_at"]) + payment_succeeded.send( + sender=PaymentTransaction, + tenant_id=str(tenant.pk) if tenant else None, + invoice_id=invoice.id if invoice else None, + amount=amount, + ) + result["action"] = "reinstated" + elif is_failed and entitlement is not None: + metadata = dict(entitlement.metadata or {}) + metadata["failed_payments"] = int(metadata.get("failed_payments", 0)) + 1 + entitlement.status = EntitlementStatus.PAST_DUE + entitlement.metadata = metadata + entitlement.save(update_fields=["status", "metadata"]) + EntitlementService().invalidate(tenant, entitlement.app.slug) + payment_failed.send( + sender=PaymentTransaction, + tenant_id=str(tenant.pk) if tenant else None, + invoice_id=invoice.id if invoice else None, + error=event_type, + ) + result["action"] = "past_due" + + if transaction is not None: + result["transaction_id"] = transaction.id + return result + + @staticmethod + def _extract_tenant_id(data: dict): + metadata = data.get("metadata") or {} + if isinstance(metadata, dict) and metadata.get("tenant_id"): + return metadata["tenant_id"] + return data.get("tenant_id") or data.get("tenantId") + + @staticmethod + def _find_subscription(data: dict, tenant): + external_id = data.get("subscription") or data.get("preapproval_id") or data.get("subscription_id") + if external_id: + found = Subscription.all_objects.filter(external_id=str(external_id)).first() + if found is not None: + return found + if tenant is not None: + return Subscription.all_objects.filter(tenant=tenant).order_by("-id").first() + return None + + @staticmethod + def _find_invoice(data: dict, tenant): + external_id = data.get("invoice") or data.get("invoice_id") + if external_id: + found = Invoice.all_objects.filter(external_id=str(external_id)).first() + if found is not None: + return found + if tenant is not None: + return Invoice.all_objects.filter(tenant=tenant, status=InvoiceStatus.OPEN).order_by("-id").first() + return None + + @staticmethod + def _extract_amount(data: dict) -> tuple[int, str]: + raw = ( + data.get("amount") + or data.get("amount_paid") + or data.get("amount_in_cents") + or data.get("transaction_amount") + or 0 + ) + try: + amount = int(raw) + except (TypeError, ValueError): + amount = 0 + currency = str(data.get("currency") or data.get("currency_id") or "USD").upper() + return amount, currency + + +def get_app(app_slug: str) -> App: + return App.objects.get(slug=app_slug, is_active=True) diff --git a/infrasynth/billing/signals.py b/infrasynth/billing/signals.py index 27a22bc..cfa229a 100644 --- a/infrasynth/billing/signals.py +++ b/infrasynth/billing/signals.py @@ -1,9 +1,15 @@ +"""Billing signals. + +Every signal carries ``tenant_id`` explicitly (``TENANCY.md`` §7). +""" + from django.dispatch import Signal -subscription_created = Signal() -subscription_cancelled = Signal() -subscription_renewed = Signal() -payment_succeeded = Signal() -payment_failed = Signal() -invoice_generated = Signal() -invoice_paid = Signal() +subscription_created = Signal() # kwargs: tenant_id, plan_slug, app_slug, gateway, external_id +subscription_cancelled = Signal() # kwargs: tenant_id, plan_slug, reason +subscription_renewed = Signal() # kwargs: tenant_id, subscription_id +payment_succeeded = Signal() # kwargs: tenant_id, invoice_id, amount +payment_failed = Signal() # kwargs: tenant_id, invoice_id, error +invoice_generated = Signal() # kwargs: tenant_id, invoice_id, amount +invoice_paid = Signal() # kwargs: tenant_id, invoice_id, amount +entitlement_changed = Signal() # kwargs: tenant_id, app, status diff --git a/infrasynth/billing/tasks.py b/infrasynth/billing/tasks.py new file mode 100644 index 0000000..795e3df --- /dev/null +++ b/infrasynth/billing/tasks.py @@ -0,0 +1,156 @@ +"""Scheduled billing lifecycle jobs (``ENTITLEMENTS.md`` §5). + +All tasks iterate tenants explicitly and bind ``current_tenant`` per row; none +runs "globally" against tenant-owned tables (``TENANCY.md`` §7). +""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +from celery import shared_task +from django.utils import timezone + +from infrasynth.shared.enums import EntitlementStatus, SubscriptionStatus +from infrasynth.shared.settings_utils import get_setting +from infrasynth.tenancy.context import tenant_context + +logger = logging.getLogger(__name__) + + +def _with_tenant(tenant): + return tenant_context(tenant) + + +@shared_task(name="infrasynth.billing.sync_subscriptions") +def sync_subscriptions(): + """Pulls the latest state from each active gateway subscription.""" + from .models import Subscription + from .services import BillingService + + service = BillingService() + synced = 0 + subs = Subscription.all_objects.filter( + status__in=[ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.PAST_DUE, + SubscriptionStatus.TRIALING, + ], + gateway__isnull=False, + ).select_related("gateway", "entitlement") + for sub in subs: + try: + with _with_tenant(sub.tenant): + service.sync_subscription(sub) + synced += 1 + except Exception: # noqa: BLE001 + logger.exception("Failed to sync subscription %s", sub.pk) + return synced + + +@shared_task(name="infrasynth.billing.advance_entitlement_lifecycle") +def advance_entitlement_lifecycle(): + """Moves past-due entitlements to suspended once the grace window elapses.""" + from infrasynth.tenancy.models import Tenant + + from .models import Entitlement + from .services import BillingService + + grace_days = int(get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5)) + max_retries = int(get_setting("INFRASYNTH_BILLING", "MAX_RETRY_FAILED_PAYMENTS", 3)) + service = BillingService() + suspended = 0 + + candidates = Entitlement.all_objects.filter( + status__in=[EntitlementStatus.PAST_DUE, EntitlementStatus.GRACE] + ).select_related("tenant", "app") + now = timezone.now() + for entitlement in candidates: + metadata = dict(entitlement.metadata or {}) + since_raw = metadata.get("past_due_since") + if not since_raw: + metadata["past_due_since"] = now.isoformat() + entitlement.metadata = metadata + entitlement.save(update_fields=["metadata"]) + continue + from datetime import datetime + + try: + since = datetime.fromisoformat(since_raw) + except ValueError: + continue + attempts = int(metadata.get("failed_payments", 0)) + if now - since < timedelta(days=grace_days) and attempts < max_retries: + continue + with _with_tenant(entitlement.tenant): + service.suspend_entitlement(entitlement) + tenant = entitlement.tenant + if tenant.status != Tenant.Status.SUSPENDED: + tenant.status = Tenant.Status.SUSPENDED + tenant.suspended_at = now + tenant.save(update_fields=["status", "suspended_at"]) + suspended += 1 + return suspended + + +@shared_task(name="infrasynth.billing.expire_entitlements") +def expire_entitlements(): + """Expires cancelled-at-period-end entitlements whose period has ended.""" + from .models import Entitlement + from .services import EntitlementService + + now = timezone.now() + expired = 0 + candidates = Entitlement.all_objects.filter( + status__in=[EntitlementStatus.ACTIVE, EntitlementStatus.TRIALING, EntitlementStatus.CANCELLED] + ).select_related("tenant", "app") + for entitlement in candidates: + if not entitlement.cancel_at_period_end: + continue + if entitlement.current_period_end and entitlement.current_period_end > now: + continue + entitlement.status = EntitlementStatus.EXPIRED + entitlement.save(update_fields=["status"]) + with _with_tenant(entitlement.tenant): + EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) + expired += 1 + return expired + + +@shared_task(name="infrasynth.billing.generate_renewal_invoices") +def generate_renewal_invoices(): + """Creates an open invoice ahead of each subscription renewal.""" + from django.db.models import Q + + from infrasynth.shared.enums import InvoiceStatus + + from .models import Invoice, Subscription + from .services import BillingService + + days_ahead = int(get_setting("INFRASYNTH_BILLING", "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL", 3)) + now = timezone.now() + horizon = now + timedelta(days=days_ahead) + service = BillingService() + created = 0 + + subs = Subscription.all_objects.filter( + status=SubscriptionStatus.ACTIVE, + current_period_end__lte=horizon, + current_period_end__gt=now, + ).select_related("tenant", "plan") + for sub in subs: + already = ( + Invoice.all_objects.filter( + subscription=sub, + status__in=[InvoiceStatus.DRAFT, InvoiceStatus.OPEN], + ) + .filter(Q(due_date__isnull=True) | Q(due_date__gte=now)) + .exists() + ) + if already: + continue + with _with_tenant(sub.tenant): + service.generate_invoice(sub) + created += 1 + return created diff --git a/infrasynth/billing/urls.py b/infrasynth/billing/urls.py index 282cfc0..79d009d 100644 --- a/infrasynth/billing/urls.py +++ b/infrasynth/billing/urls.py @@ -2,26 +2,40 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import ( - BillingPlanViewSet, + AppViewSet, + EntitlementViewSet, InvoiceViewSet, PaymentGatewayViewSet, PaymentTransactionViewSet, + PlanViewSet, SubscriptionViewSet, WebhookViewSet, ) router = DefaultRouter() router.register(r"gateways", PaymentGatewayViewSet, basename="billing-gateways") -router.register(r"plans", BillingPlanViewSet, basename="billing-plans") +router.register(r"apps", AppViewSet, basename="billing-apps") +router.register(r"plans", PlanViewSet, basename="billing-plans") +router.register(r"entitlements", EntitlementViewSet, basename="billing-entitlements") router.register(r"subscriptions", SubscriptionViewSet, basename="billing-subscriptions") router.register(r"invoices", InvoiceViewSet, basename="billing-invoices") router.register(r"transactions", PaymentTransactionViewSet, basename="billing-transactions") urlpatterns = [ path("", include(router.urls)), + path( + "checkout/", + SubscriptionViewSet.as_view({"post": "checkout"}), + name="billing-checkout", + ), path( "webhook/receive/", WebhookViewSet.as_view({"post": "receive"}), name="billing-webhook-receive", ), + path( + "webhook//", + WebhookViewSet.as_view({"post": "receive"}), + name="billing-webhook-provider", + ), ] diff --git a/infrasynth/billing/views.py b/infrasynth/billing/views.py index be64203..8b2c102 100644 --- a/infrasynth/billing/views.py +++ b/infrasynth/billing/views.py @@ -1,188 +1,257 @@ +from django.http import Http404 from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from infrasynth.api.idempotency import idempotent +from infrasynth.features.services import FeatureService +from infrasynth.shared.exceptions import NotFoundError, ValidationAppError +from infrasynth.tenancy.context import get_current_tenant + from .filters import ( - BillingPlanFilter, + AppFilter, + EntitlementFilter, InvoiceFilter, PaymentGatewayFilter, PaymentTransactionFilter, + PlanFilter, SubscriptionFilter, ) from .models import ( - BillingPlan, + App, + Entitlement, Invoice, PaymentGateway, PaymentTransaction, + Plan, Subscription, ) from .serializers import ( - BillingPlanSerializer, + AppSerializer, + CheckoutSerializer, + EntitlementSerializer, InvoiceSerializer, PaymentGatewaySerializer, PaymentTransactionSerializer, + PlanSerializer, SubscribeSerializer, SubscriptionSerializer, ) -class PaymentGatewayViewSet(viewsets.ModelViewSet): +class _BillingFeatureMixin: + def initial(self, request, *args, **kwargs): + if not FeatureService().is_enabled("billing", user=getattr(request, "user", None)): + raise Http404() + super().initial(request, *args, **kwargs) + + +class PaymentGatewayViewSet(_BillingFeatureMixin, viewsets.ModelViewSet): queryset = PaymentGateway.objects.all() serializer_class = PaymentGatewaySerializer permission_classes = [IsAuthenticated] filterset_class = PaymentGatewayFilter - def initial(self, request, *args, **kwargs): - from infrasynth.features.services import FeatureService - - if not FeatureService().is_enabled("billing", user=request.user): - from django.http import Http404 - - raise Http404() - super().initial(request, *args, **kwargs) - def get_queryset(self): return PaymentGateway.objects.all() -class BillingPlanViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): - queryset = BillingPlan.objects.filter(is_active=True) - serializer_class = BillingPlanSerializer - filterset_class = BillingPlanFilter - - def get_permissions(self): - if self.action in ("list", "retrieve"): - return [AllowAny()] - return [IsAuthenticated()] - - def initial(self, request, *args, **kwargs): - from infrasynth.features.services import FeatureService - - if not FeatureService().is_enabled("billing", user=request.user): - from django.http import Http404 - - raise Http404() - super().initial(request, *args, **kwargs) +class AppViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = App.objects.filter(is_active=True) + serializer_class = AppSerializer + filterset_class = AppFilter + permission_classes = [AllowAny] def get_queryset(self): - return BillingPlan.objects.filter(is_active=True).select_related("gateway") + return App.objects.filter(is_active=True).order_by("slug") -class SubscriptionViewSet(viewsets.ModelViewSet): - queryset = Subscription.objects.all() +class PlanViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = Plan.objects.filter(is_active=True) + serializer_class = PlanSerializer + filterset_class = PlanFilter + permission_classes = [AllowAny] + lookup_field = "slug" + + def get_queryset(self): + return Plan.objects.filter(is_active=True).select_related("app", "gateway").order_by("app__slug", "slug") + + +class EntitlementViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + serializer_class = EntitlementSerializer + permission_classes = [IsAuthenticated] + filterset_class = EntitlementFilter + + def get_queryset(self): + # Entitlement.objects is tenant-scoped by TenantManager (fail closed). + return Entitlement.objects.select_related("app", "plan").order_by("app__slug") + + @action(detail=False, methods=["get"], url_path=r"by-app/(?P[^/.]+)") + def by_app(self, request, app_slug=None): + entitlement = Entitlement.objects.filter(app__slug=app_slug).select_related("app", "plan").first() + if entitlement is None: + raise NotFoundError("No entitlement for this app.") + return Response(EntitlementSerializer(entitlement).data) + + +class SubscriptionViewSet(_BillingFeatureMixin, viewsets.ModelViewSet): serializer_class = SubscriptionSerializer permission_classes = [IsAuthenticated] filterset_class = SubscriptionFilter - def initial(self, request, *args, **kwargs): - from infrasynth.features.services import FeatureService - - if not FeatureService().is_enabled("billing", user=request.user): - from django.http import Http404 - - raise Http404() - super().initial(request, *args, **kwargs) - def get_queryset(self): - return Subscription.objects.select_related("plan", "gateway", "user").all() + return Subscription.objects.select_related("plan", "plan__app", "gateway", "entitlement").order_by("-id") @action(detail=False, methods=["post"]) - def subscribe(self, request): - ser = SubscribeSerializer(data=request.data) + @idempotent + def checkout(self, request): + ser = CheckoutSerializer(data=request.data) ser.is_valid(raise_exception=True) + tenant = get_current_tenant() + plan = ( + Plan.objects.filter(app__slug=ser.validated_data["app"], slug=ser.validated_data["plan"], is_active=True) + .select_related("gateway", "app") + .first() + ) + if plan is None: + raise NotFoundError("Plan not found.") from .services import BillingService try: result, gateway = BillingService().create_checkout_session( - ser.validated_data["plan_slug"], + plan, + tenant, request.user, success_url=ser.validated_data.get("success_url"), cancel_url=ser.validated_data.get("cancel_url"), ) except ValueError as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + raise ValidationAppError(str(exc), code="VALIDATION_CHECKOUT_FAILED") from exc return Response( { "checkout_url": result.checkout_url, "session_id": result.session_id, "client_secret": result.client_secret, "gateway": gateway.slug, - "plan_slug": ser.validated_data["plan_slug"], + "plan_slug": plan.slug, + "app_slug": plan.app.slug, } ) + @action(detail=False, methods=["post"]) + def subscribe(self, request): + ser = SubscribeSerializer(data=request.data) + ser.is_valid(raise_exception=True) + tenant = get_current_tenant() + plan = ( + Plan.objects.filter(slug=ser.validated_data["plan_slug"], is_active=True).select_related("gateway").first() + ) + if plan is None: + raise NotFoundError("Plan not found.") + from .services import BillingService -class InvoiceViewSet(viewsets.ModelViewSet): - queryset = Invoice.objects.all() + try: + result, gateway = BillingService().create_checkout_session( + plan, + tenant, + request.user, + success_url=ser.validated_data.get("success_url"), + cancel_url=ser.validated_data.get("cancel_url"), + ) + except ValueError as exc: + raise ValidationAppError(str(exc), code="VALIDATION_CHECKOUT_FAILED") from exc + return Response( + { + "checkout_url": result.checkout_url, + "session_id": result.session_id, + "client_secret": result.client_secret, + "gateway": gateway.slug, + "plan_slug": plan.slug, + } + ) + + @action(detail=True, methods=["post"]) + def cancel(self, request, pk=None): + subscription = self.get_object() + from .services import BillingService + + BillingService().cancel_subscription(subscription) + return Response(SubscriptionSerializer(subscription).data) + + +class InvoiceViewSet(_BillingFeatureMixin, viewsets.ModelViewSet): serializer_class = InvoiceSerializer permission_classes = [IsAuthenticated] filterset_class = InvoiceFilter - def initial(self, request, *args, **kwargs): - from infrasynth.features.services import FeatureService - - if not FeatureService().is_enabled("billing", user=request.user): - from django.http import Http404 - - raise Http404() - super().initial(request, *args, **kwargs) - def get_queryset(self): - return Invoice.objects.select_related("subscription", "gateway", "user", "pdf_file").all() + return Invoice.objects.select_related("subscription", "gateway", "pdf_file").order_by("-id") -class PaymentTransactionViewSet(viewsets.ReadOnlyModelViewSet): - queryset = PaymentTransaction.objects.all() +class PaymentTransactionViewSet(_BillingFeatureMixin, viewsets.ReadOnlyModelViewSet): serializer_class = PaymentTransactionSerializer permission_classes = [IsAuthenticated] filterset_class = PaymentTransactionFilter - def initial(self, request, *args, **kwargs): - from infrasynth.features.services import FeatureService - - if not FeatureService().is_enabled("billing", user=request.user): - from django.http import Http404 - - raise Http404() - super().initial(request, *args, **kwargs) - def get_queryset(self): - return PaymentTransaction.objects.select_related("invoice", "gateway").all() + return PaymentTransaction.objects.select_related("invoice", "gateway").order_by("-id") class WebhookViewSet(viewsets.GenericViewSet): permission_classes = [AllowAny] @action(detail=False, methods=["post"]) - def receive(self, request): - gateway_slug = request.data.get("gateway_slug") or request.query_params.get("gateway_slug") + def receive(self, request, provider=None): + from django.utils.module_loading import import_string + + from infrasynth.api.webhooks import assert_fresh_webhook + from infrasynth.shared.settings_utils import get_setting + + gateway_slug = provider or request.data.get("gateway_slug") or request.query_params.get("gateway_slug") gateway = ( PaymentGateway.objects.filter(slug=gateway_slug, is_active=True).first() if gateway_slug else PaymentGateway.objects.filter(is_active=True).first() ) if gateway is None: - return Response( - {"detail": "No active payment gateway found."}, - status=status.HTTP_404_NOT_FOUND, - ) + return Response({"detail": "No active payment gateway found."}, status=status.HTTP_404_NOT_FOUND) - from django.utils.module_loading import import_string + # Replay protection (API-STANDARD §10): reject events older than 5 min. + tolerance = int(get_setting("INFRASYNTH_BILLING", "WEBHOOK_TOLERANCE_SECONDS", 300)) + timestamp = ( + request.data.get("created") or request.data.get("timestamp") or request.headers.get("X-Webhook-Timestamp") + ) + if timestamp is not None: + try: + assert_fresh_webhook(timestamp, tolerance_seconds=tolerance) + except Exception as exc: # noqa: BLE001 + raise ValidationAppError(str(exc), code="VALIDATION_WEBHOOK_STALE") from exc try: gateway_cls = import_string(gateway.gateway_class) gateway_instance = gateway_cls(gateway.config) result = gateway_instance.handle_webhook(payload=request.data, headers=dict(request.headers)) except Exception as exc: # noqa: BLE001 - return Response( - {"detail": f"Webhook processing failed: {exc}"}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise ValidationAppError(f"Webhook processing failed: {exc}", code="VALIDATION_WEBHOOK_FAILED") from exc if not result.is_handled: - return Response( - {"status": "ignored", "event_type": result.event_type}, - status=status.HTTP_200_OK, - ) - return Response({"status": "processed", "event_type": result.event_type, "data": result.data}) + return Response({"status": "ignored", "event_type": result.event_type}) + + payload = result.data if isinstance(result.data, dict) else {} + event_id = str(request.data.get("id") or request.headers.get("X-Event-Id") or "") or None + from .services import BillingService + + outcome = BillingService().process_webhook_event( + gateway, + result.event_type, + payload, + event_id=event_id, + ) + return Response( + { + "status": "processed", + "event_type": result.event_type, + "billing": outcome, + } + ) diff --git a/infrasynth/features/__pycache__/__init__.cpython-314.pyc b/infrasynth/features/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 723ebcefdafc249b4042ff3fdb9d4bb4b91f58e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9npjd=lv=DG rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{UI$J diff --git a/infrasynth/features/__pycache__/apps.cpython-314.pyc b/infrasynth/features/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 771a9f37fc3ffcde4a6cf37eb3cb5698488dd6e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1028 zcmYjQ&1(}u6rW9Ylg&q4YTKk9N;e8>kR~949;8@5>c^p!K?M)(Fxj0=x83Zu9c|AhaIo?HeBqNiS@5UJkuO?DSMuy5YH@85fGZ_m$F5uCrT!^58jLO=8)KUT&} zZvitw`v@B^&~rl?>u3eF5H2(kF76q3i>aL~VDma^TW9*&HdJM6ID8yOe$aPQgOoy+ z*_qyfi$dHzBn^yYVc(R+28tnzG;u*%xF}25Y$934mbBj>S-~ZUUsm(mjLH#bB8`5s z<{i?gA9uW7XApZV6rD#we`_S;?SO^e$x;ycoQiQI4?2G4sV)ggE_CzKbbU5_0L-3l z0t4WU6#NjtzcF_6)@aM@-~ujU6I-~1%l9(I)TTshN`p(P7h<(TyYA^BL@dG3?B zOyz8cv@b>*;E+pvkT_y2BpWz^6fE?e?~{*C#Euzvyg<-i$h=pol$x|nWkz(sRLx_; z<-t$}aiq!~^XVv*%66$_{g{t;+C^patPoy-eEE~{c0rjs&rB=|pu6Fym)(PSz`BE& z(_?zn-R1EibEWA1Z|_Er3fApW_lQN_C3!bT*`>pw=nTio;*5G@wVXlBCfS)ubt&Q| zkO?}qH@?};ueS5Wc9MD{S#*-sXUXzvva)twLzUJADpjgeFh!rxPP?jXLNs(jRFx3e zg;A*aIw9{yG|V&$g!lm$G7KUX#h|{DwT4hw8PLJb@H&K4R!Dm+W~h@W`$39)lV5|U&Mq{B^P*uG Tr>OO-G;dTtyuCnh&Ak2rmNFe~ diff --git a/infrasynth/features/__pycache__/decorators.cpython-314.pyc b/infrasynth/features/__pycache__/decorators.cpython-314.pyc deleted file mode 100644 index bfb62bdb4a62454b891135090ffd1167a6710f4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1330 zcma)6&2Jk;6o0e3UfaR3T$~W4M9qd&Mec|9kl-SbL(jubhCa&Q{ zWCN(eJ>%U^0UD^Afe%@md29<$PRV5$%XmsQk)`~83!R%vOWf>cD7#!M^7~e_;kLuB zaJ!-8dwkd3kl~J~Yvp!=rsV1%(Cus6TnTrZ*LOwGctP(rPjgjnL)6h+hfFZI@VL?itPL^D^jI~n z>ywq6qmT|(Euen{B4%a5~C})d%by+l8uegIR#{U`AG73AtWk z@^v&TS<=QW8tbT9f=rZd32dB}lJY3oQJU}+N=Fu#Efv|j`|)lxmCTZDmY}aS`$1ii zuS=J&I)j1C(NUTPyeDL|m~^6+^rlf>^20j!)k^tna{f~#R^&q6ME3Up{x~;#d~WXW z#xKRmua>`9elYRf?eA~@Q2p_vjV%B*hwPsLJkI715010b z&kQSjeQ3hi`4f^(a!{B$%*Po##3vaCl`~{W4Q)E7U=?1CCTWD?hf_cSL3n0oQd1`r zkefWRCSrH%rF4`f=ye$SN?Ld?nwHqC8a~~w*bn7yS$td3Zd6?ZhsVE^gQqT<%a uLF5HAJVhw(TV|;nHu}C;k@I*!(;vUx6O%Fa8@%}h%1_|pa~q#p%Pl1P4;KCMB`}Lr_#2DM?8J6!JsJ0_h=jR%`EI7S_9Db`~X^ zd}-A~4$Ud)sW~QxN@*{>_SmEATCz1#PCfM&3ECdpH@h}9iBZ?u=QlIozIpG>d*6)r zMnfDt|7_^nKl(ZDcQ$B0Fm|2;41X2w0Vnax+!9at5vF^VJxgBV9pU=94>`$uij#bK z@2b%HrsMB|*BubQgY*M3@IT0a)HBLug+JJbS)K|OB1AJv7U+SpsUqDfc5J`}T7!cp zJaaV95nkem=YgMi^S)Ilz)m;^4nI&OeiqQ|Apt=3x_YMs)Gr+<0Z_@#bytVrJ(Bmx zRtQi7T~R*Ce}swv>U1|0_yxhwdfYYA3%C!u;b2dXBuK&8c5>r@%ydKMlTxU|VhV8G zW1RtuVJUKCvF}8S{T-1EIENaAL+!oL_WG0*BZE>Ljxa&Pi%20hMvJ;qg5wL7l}ECU zj1t*ehoQfqE5^4@^&}`dZ^FR?Gg>TG0B?xs)q`) zY$#Ll3}l$FET{#UCtWl!0eTE*rzDiUi-Gd8heJvPYJ zWI?geR6!~3AVck#pK3=0AG5a2?LMYH7AuZ|;W)e&+vY2-naX@EG>4|Er_Vo$Vb&UW zmJ&jjl*>;plAYmlEs4e2Uo=4kNxmV%uzoH}HQ2G^lh+=;M?tRmKS z*{RdWHPh6QVz^5|&5{);hdo5otEQzfZLJK|w`>qpLM@9)Z0fpc6L}ZmA~K+8!2ZU@ z^1SOn!g1in+MSh_1G;7r8bQXMhE1akA6Qf#{sapFz0z*x|3 z4u+V<=*MtGR^i1a#C+5=Q798qaZ3X-@K0>TK>R0m<)*+Up>DwLnu(tsghV93yBh#(rlNQX*O z9jaKT{CL)fr(q`?X2M~x^PugA6q{`29HKjRJS{Hg4%nsA!Di>$Y$o}0o(?_>(7`V5 zxxAKWvw+-ow$;}_Z;a`vk5N1@{Kv2t^_LjyVs_QO+X+sKuza0^*J2399Ak_*#;cQ8 zs;58gLLP~e$s@FAVx*Ofo9MCAV6^A@)wSza=NBALFuPl6?CQq)lDx6DAj!Ay%rD%+ zvoMQ4VS=TGhJZQX!U1MgPj2BHgEPU{%lZ;TtA&Sa;o;}^UoZZ&^218??iclNzSYZY zp((^0g7~W-Hj{(Ta!+&3XrkGhZ1xWxh69m6W&Uk17fm%HXKIl%O`)#~C>p3N00_7s zakiE?`&(kHa)UMI9M3v)j(1y=`L&Ymp<#7fF-m5xXacW0u?Jf-YQZI2|82(NE+IJ~ z9cL;kTyn3YD|ilUvJ26g1o76(^ZXl5e8UYN2BQ4TH)GExo=zNcpdN~S{2c#$?7N9? LC;sA??)daCbzoV= diff --git a/infrasynth/features/__pycache__/registry.cpython-314.pyc b/infrasynth/features/__pycache__/registry.cpython-314.pyc deleted file mode 100644 index 3ee7df24f76c0a22001b0ccb180cdb8d3fe013d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2874 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 diff --git a/infrasynth/features/__pycache__/urls.cpython-314.pyc b/infrasynth/features/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index fbfe8e1884ccd9473ab3847d8b9a9a1f77161e40..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 697 zcmZuvPiqrF6rb7uX&Qso_Ta$~MI@AFJp|f=2wEu{4_a1Cg%V|&og|xVcFW9cN_$Fs zsEU_*@-ukzL-+v#^&kTx;z7Js`T_Kf$=V9O!<#q1_kO>5Z)O%R6lM_EuP0IO5F_+W zZ8Ec_!RZGGM(7#xaUFSBU{4cTN$s_|?is>>82ftN^eka{wy;a6X_QfIN*7MkS-+Ht zo~^#ow0+aJmh}k+tpC^9P0M%wsnf~`*to11m(LWls0NcLt7((9ibdYfT}PFf9}1{w z(hlQRlm=89+eCCLSQhTnHc2DlC#j$eDz2P0DZG?2^F%@#|TrR`V zUZFiOPnOPA)LY#q%}; zjh%$`tL#sAoS72$O$O8* zB#aD6-(6=(kG2GN!x&EEyRqmlsqWn^((2PVIETB_0l2HgtJ}NMxjjgNG@^IdJV>j% z=TE_%=osTK=*DMs>pQypHMj7(G_pP+>qT+Dc%+Z#Z@za&#gFJ-2F;GIu8db33fuT; gAv5=K?$yRotxfAlZJgDz9>RvTO=rfW zL{-J9jnqqeYK|O>dPx2hTSNk_5~WfrRlSjdq8@taH)|U&B<-bh`1X6v>-RloC!>)d zg7GqH-}%{x(63x*52eNI{|(Frx`i|$hq3|_vLX|Qxh&?StjuJWmvX+WpZQ&0&IPgx zQ-BvVU#=?~WWj8RgUM8{vYzOpLR zQPwPcmtsa!g5x(#W>qV|sX6*VjVL zojFmDPr&07(z+7J77o`|wIGWX5bM?yz&`B6J(n&3?$FJApE0 z%heJjaE;jds=<~`FGe!Vb&<5)4Q?Ky+(u|sEQtA5Z23m*Q4sQc?y;evORW`FB~2(u z8DUkv8E88dW$!t8b6E7SLYY@pvFC$}S~?u)yz_)aX@ zh$VkYJQ~^>s>hPsv5WV8zl2Xb_-s!SW5N6Kfgi>CAIjfm7t~uZ^`Z^hvn?0LfKfY1#zr3x^)|FWobS)}5Q zqU#l-jCI`!>Uz0as@a?m>rh{Y-Lj~opi$jBo4$;+ObgOdv*s?^?`}H z-1ny}_);(Y$RB=iVe`V~*~i%@zRNE}zwh+Eg#5F6oO=;FG}ouEKJi`K7bV{nZl4!k za@IxJ-QR?*4@bLo?0*U>&$E(b(*J!@SXdLGElAFwrysW8$yNrDDcE))6(MQ(k_;#3 zIeCkdE=~@EN_ch={>YJcIN>2Ays423obZL3cjT>t@Va*i&u@Ab$hT-OjAF?hb)un8 z?5OF6n%-8=bFilm!G0q5HP_(N7gGIBlt5R~;YBFpyH&D6pvDoAfj9$4E$I?A;VEHN zLf!On29Mc0xz@_Q#4!gD-M6Y3-}ZNlK@wgPJYyFi=GHaySg zwbAA2=EOcE@L*xrFwg&eoai-+GLPK-5!{kg*FCD}$G8GTW42dv@jQyLqs)}u@|-1e zun$jl`Z}CHxB&0!j*^pI|J#)K>7fIaByA`CU@oF ej(oBqpLCglhCJXhLk)RoR}ME#FT23ejmZ{-PsP9$1 zG<+!}!1JF&{l#Wj5dO^K{)VlM2@VxoIUU`3dr;zBv>AW-{tjg zP5~PO?ED~XAtuLvp>+(91J4Z_$DgmtSNgP0d{S$?|4iACd{v&5r{v|lm~+X~Jq@4C zY2pv&bO!v82AyuL%Y+C>fVn;hQyAaR|CYZ$Hh{M}fY&q8w0k>Y9a$Qk(G0KSASJNw@=E~=#;z!b12V(PkJ-w z85`Qh3at~ott%Dg($sC{+TF@{SXil36O{<;VwDZmh*nj|33`c7SkS^mQ*`YVv+yw{ zMbpB_P^y@DEWKWV`4*}wUm+bE6?()xot+^STP32^_?!cPx2P+|OBU2Dq?D+3jDhD@ zaapPBl#LQo*UdUbHB3s_ph^Xwj^&qE^7*U;FX^tN=(&+=#{d}?#>TNm zOrr|st<$+LQ#E3$b@0?QQrD%F1wO&}P8B4RNM9Blk654tp{khVR(6;nl3sKNqj5mBY6V|JQmWHLR-^ z^P8P$TI_OP2KOKzL{l*fL1-ZmpjTy{j=rGT-Lp<-G^0!u>%^c(sAn|WB{(+YW9u}If96V z5Q1B^u49B)7@=2nMduX>s}baw&@}@aCNTV(p;0Jo!f!Sfv;LygYCM7vh#EFjY?Mw2 zAOCD*<#x+}-MmKRVcZ>X05?vncbZ zT8XmIwn?y7F+PXB^{Fouqhd0bT7_bp&5f-GEM!8%b-zU72tkokt%MXx ziB_yrY#~IJ;V>WIC$K{9$gJXA)g;QX@-nwi%on&st=vUrEf*CF=ZZ?{CB&|epX*gY z?&8ZQmxtt|Zuqmc6XFLOomT(|ONI;jZ$fK$^!3BGPYB*>1xA~Jg+^fE+dy;i-r3^4 z?*kicuRG>@{kR<#f|r|tL?e)BMHBC%@vo1Yg`fwkFB;_6vYodK}`N2NdFQRKl*k>(f4(z8A+c-(mx4&)zR`_N`4d- diff --git a/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b24df6710222dd28d14a077297cacb0dcf4cd1ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9npjd=lv=Ex zo0(peSdy8aSF9f&pP83g5+AQuP bool: config = getattr(settings, "INFRASYNTH_FEATURES", {}) ttl_seconds = ttl_seconds or config.get("CACHE_TTL_SECONDS", 60) + if tenant_id is None: + from infrasynth.tenancy.context import get_current_tenant + + tenant = get_current_tenant() + tenant_id = tenant.pk if tenant is not None else None + if user and user.is_authenticated: override = self._get_user_override(slug, user) if override is not None: @@ -31,13 +38,14 @@ class FeatureService: if override is not None: return override + # Resolution: tenant override → global default → registry default. + flag = None if tenant_id: - flag = self._get_flag(slug, tenant_id, ttl_seconds) - else: - flag = self._get_flag_global(slug, ttl_seconds) - - if flag: - return flag.is_active + flag = self._get_tenant_flag(slug, tenant_id, ttl_seconds) + if flag is None: + flag = self._get_global_flag(slug, ttl_seconds) + if flag is not None: + return self._evaluate_flag(flag, slug, user=user, tenant_id=tenant_id) registry_default = FeatureRegistry.get_all().get(slug) if registry_default: @@ -45,18 +53,46 @@ class FeatureService: return default if default is not None else False - def get_active_flags(self, *, user=None, tenant_id: str | None = None) -> dict[str, bool]: + def _evaluate_flag(self, flag, slug, *, user=None, tenant_id=None) -> bool: + if not flag.is_active: + return False + if flag.environments: + environment = getattr(settings, "ENVIRONMENT", None) or getattr( + settings, "INFRASYNTH_ENVIRONMENT", "development" + ) + if environment not in flag.environments: + return False + rollout = int(flag.rollout_percentage or 0) + if rollout >= 100: + return True + if rollout <= 0: + return False + return self._in_rollout(slug, user=user, tenant_id=tenant_id) < rollout + + @staticmethod + def _in_rollout(slug: str, *, user=None, tenant_id=None) -> int: + """Stable bucket in ``[0, 100)`` for a (flag, user/tenant) pair.""" + algorithm = str(getattr(settings, "INFRASYNTH_FEATURES", {}).get("ROLLOUT_HASH_ALGORITHM", "md5")) + identity = ( + str(getattr(user, "pk", "")) + if user is not None and getattr(user, "is_authenticated", False) + else f"tenant:{tenant_id}" + ) + digest = hashlib.new(algorithm, f"{slug}:{identity}".encode()).hexdigest() + return int(digest[:8], 16) % 100 + + def get_active_flags(self, *, user=None, tenant_id=None) -> dict[str, bool]: all_slugs = set(FeatureRegistry.get_all().keys()) - db_flags = set( - FeatureFlag.objects.filter(Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)).values_list( + db_slugs = set( + FeatureFlag.all_objects.filter(Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)).values_list( "slug", flat=True ) ) - all_slugs.update(db_flags) + all_slugs.update(db_slugs) return {slug: self.is_enabled(slug, user=user, tenant_id=tenant_id) for slug in sorted(all_slugs)} def _get_user_override(self, slug: str, user) -> bool | None: - override = FeatureFlagOverride.objects.filter(flag__slug=slug, user=user).first() + override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, user=user).first() if override: return override.is_enabled return None @@ -65,25 +101,36 @@ class FeatureService: groups = list(user.groups.all()) if not groups: return None - override = FeatureFlagOverride.objects.filter(flag__slug=slug, group__in=groups).first() + override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, group__in=groups).first() if override: return override.is_enabled return None - def _get_flag(self, slug: str, tenant_id: str, ttl_seconds: int) -> FeatureFlag | None: - cache_key = f"features:{slug}:{tenant_id}" + def _get_tenant_flag(self, slug: str, tenant_id, ttl_seconds: int) -> FeatureFlag | None: + cache, prefix = self._cache() + cache_key = f"{prefix}:tenant:{tenant_id}:features:{slug}" cached = cache.get(cache_key) if cached is not None: return cached if isinstance(cached, FeatureFlag) else None - flag = FeatureFlag.objects.filter(slug=slug, tenant_id=tenant_id).first() + flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id=tenant_id).first() cache.set(cache_key, flag, ttl_seconds) return flag - def _get_flag_global(self, slug: str, ttl_seconds: int) -> FeatureFlag | None: - cache_key = f"features:{slug}:global" + def _get_global_flag(self, slug: str, ttl_seconds: int) -> FeatureFlag | None: + cache, prefix = self._cache() + cache_key = f"{prefix}:tenant:global:features:{slug}" cached = cache.get(cache_key) if cached is not None: return cached if isinstance(cached, FeatureFlag) else None - flag = FeatureFlag.objects.filter(slug=slug, tenant_id__isnull=True).first() + flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id__isnull=True).first() cache.set(cache_key, flag, ttl_seconds) return flag + + @staticmethod + def _cache(): + """Returns ``(cache_client, key_prefix)`` from ``INFRASYNTH_FEATURES``.""" + from django.core.cache import caches + + config = getattr(settings, "INFRASYNTH_FEATURES", {}) + alias = config.get("CACHE_BACKEND", "default") or "default" + return caches[alias], config.get("CACHE_KEY_PREFIX", "features") diff --git a/infrasynth/features/views.py b/infrasynth/features/views.py index 932f4d2..0c4bd57 100644 --- a/infrasynth/features/views.py +++ b/infrasynth/features/views.py @@ -18,17 +18,32 @@ class FeatureFlagViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["get"], url_path="active") def active_flags(self, request): + from django.conf import settings + + from infrasynth.tenancy.context import get_current_tenant + fs = FeatureService() - flags = fs.get_active_flags(user=request.user) - data = { - "flags": flags, - } + tenant = get_current_tenant() + flags = fs.get_active_flags(user=request.user, tenant_id=tenant.pk if tenant is not None else None) + # A list (not a map) so camelCase key conversion never mangles slug identifiers. + data = {"flags": [{"slug": slug, "enabled": enabled} for slug, enabled in sorted(flags.items())]} + config = getattr(settings, "INFRASYNTH_FEATURES", {}) + if config.get("EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT"): + from infrasynth.security.services import AuthorizationService + + data["effective_permissions"] = sorted(AuthorizationService().get_effective_permissions(request.user)) + if config.get("EXPOSE_ROLES_IN_ACTIVE_ENDPOINT"): + roles_qs = getattr(request.user, "roles", None) + data["roles"] = list(roles_qs.values_list("slug", flat=True)) if roles_qs is not None else [] return Response(data) @action(detail=False, methods=["get"], url_path="check/(?P[^/]+)") def check_flag(self, request, slug=None): + from infrasynth.tenancy.context import get_current_tenant + fs = FeatureService() - enabled = fs.is_enabled(slug, user=request.user) + tenant = get_current_tenant() + enabled = fs.is_enabled(slug, user=request.user, tenant_id=tenant.pk if tenant is not None else None) return Response({"slug": slug, "is_enabled": enabled}) @@ -41,3 +56,6 @@ class FeatureFlagOverrideViewSet( queryset = FeatureFlagOverride.objects.all() serializer_class = FeatureFlagOverrideSerializer permission_classes = [IsAuthenticated] + + def get_queryset(self): + return FeatureFlagOverride.objects.select_related("flag", "user").all() diff --git a/infrasynth/files/__pycache__/__init__.cpython-314.pyc b/infrasynth/files/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index a9916d98558ab19346b6ad76a6bf50c3aa2a254f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmdPq_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9nUh+qA0MBY omst`YuUAlci^C>2KczG$)vkyYXg0|9Vi4mKGb1Bo5i^hl09a%z$^ZZW diff --git a/infrasynth/files/__pycache__/apps.cpython-314.pyc b/infrasynth/files/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 57cc9106ce2f3c38d76866efb5c673856c3e1c70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1021 zcmYjQOK;Oa5MC#C;yg+zeWXaVbwL6mN+Ka7IH0QKQOc#NR?u6!+Sr@atz(fCZKjHV{=ExMei;b;(8`3YTo~U6 zVT6tlHr}9JLmF#n5v?O!XdqlXG#(c-KUu)$9n`dbYPD&o%61&Th{CSlb2EpGLzR@q zkKmw?_Yg?~BUv~yWpN2bP(_-!AT3;!C2Tg3EMrUB2S`?M3G$a!ZO0Fo$WouJcqcUM zMJ=z>>PH?6MC-ZV+aAbh$7g|ecHIxVoQh#6k6T?Grlu?;wYZaxbXzujfX5zhf^da7 z2FwS>{$wKBGbdcYMQmaVmvH$>e!qsW4V_ka0i0kGvpbB+0cV)?d?EQzSy?Mq3goP% z4@by|(;nM!25}HkPi#0e^qkm_S>T7viFxEQA^foSR+V$#rm4)3_L-`AOt{>Sr5}Z= z?6EE#1X9^9m8=)>;a;<-ZcPs&Ye%$r-VoQYm7S@KHw(%XEa;|Yp+Ver)a&+f)MxEJ zboqgvw)c5-!dxlZS9u?Hs9^05bx&F7P1)Ni+BS`2(TV{)XVe?2xfFc%iqHs{Do{w= z12#hE_SSd1@y&L=+D=lRPv(}B<=tdqIayr2sG-XGB`Q^_V{k>E(O$EvY(n%P2vJo+ zU=Id?mg|JPAJ8DbnI-VvTu436FoNqF`Jf1eb%8tB7hZ>y$_i+Q1$;rvi-ZVCrSB3d zCHFf6$ppmC5#k0^2+wz=%YuLqc;}fZBZTR_+T!!Txn7KvnIMQYV1GXX%;e_NFXmEG zTN%Af>Z_wyf6R(iy+FYD(j-DZUM*j8JwmR3w=7{pOR1wSqU>iG@=bmlR9#(Y2p2`e UFwW8X@6t7+`l)$|;7FtX11MPh(05uVY%H2VLu3|efk!8m|Li1`W2vJ18x492*|@*a}dld&||1F}ZEd17%+ zI&JM?4^Wk=Wly_DbMu~-DRAc zzwWQQPXz-$4!&Pjiue8wy?^1L{W)8mH-Cjrm0ROPexAF*Q+@>dwt3qPJGGB+3GOH- z+J`vNv1(uPv^VenPTS2sbsCgTpme>1(q&M(fztC1O1DAj1xnvLC_SPu#!dQvMqws- z=Ft^O)zXFzeb<&&P>OnfUuKSLU*t{PhL>OEC@*ruiibjBLyqE4Mw;Jm>CqC$$)x^^2-retc_7KpW?cEKA>=M1! z9fu9$@_}&)(_jVo(L*Gcr{+G_svz{HbRS=G96P*quTZ<|tK)Mdn;D%BB0vPn0517NnBArGR<* zB6<4Me9!wWO3ZnD7+VB`U8M6_${I zrA*blBvVS%^$LYUBr*i@y+Qr4`OK!arDV1=B0rSxXO@U|Psvj~qn7RxS>GTYO0=bEt-UwX(0^vO|d@Z`nGG#SPL zs)-!NCXI~)8s?qZl*yc`6bmeb+K@LCsdYFL+7tJcZ5Gjpx}lcjqM(yVOP$=Esi!WzbR?SL zWFtIU3y<#1)Wc`1^UcUmBQjBoOuULrR&VyOL+Y_wcx)$I51#|-P@)kUt%XLLo+KRg zRpv>i8HhB4(PkpGC%D3{>W$Yy4mO2GYN3%GM?G|=dc%-utQH+>dcqCQ=#QS!K3ik1 z>Mhs`;zy3xBFA^9>yb;W3&H>0yat0m4`;e8`3J*4FS@NW z!-FKlPzj-+t&PZj6t{pjDhf0v`T@NH#1EIwP1*=_ZTH#iI5Z zpa2jnB^?NKGJ#Bj7`67Hiu?*E5s>6ORUaxW)n?PeL`i$75TX{8!H9vcWjq;5ss9BU zKnx+$5DwRb!{6%99d+TO>TJhMT{s0}3q#`d=oDZ`Y`75{tHs84^m^XvNd$;BnWZf z1LwDOIKSU;9V4l>Tqhv9X;Acl>v&ldB5YpKbx3uW)Utvh+i)7oom`cY`kxNO2Ih-}zv0s>*$5m`FE!0txE~7O~bl->}QV5s8X#LTKKV9>uU-^$zvj^?(ps^wQ zE)`ov#y>&X9maN<+Kp%BA@#I@8T2VPtg33D!L1H3F(Zw)gDCLPfw5y1;{^iRfe&Je z6BBtWjSm1FW>W_~h-C3qSHBVgW=9VY!w{lDTc&}9fpNCia-kTUJl@L*90X%*m^gee?0Eg29PV!mNZpe5u0p84SxnfET&|xtMt{7sGwgYA#DftS5beAz${&oUR z>M({6!QbqV%sU{NcT%XLs2h@dk7xs?ZkFm{I<*W5cttm3(xi>K%aBKd`!n~F*}*wd zDV2;l(d@vSH?M7#u^K5rPTcOoyz8zE)j@%|=8WG-Z$Cc-=Gn#B`NiKcx45#nh#l|D zo!d(b*<5xOwqBcG$j*{Jj>lXraYeW_#z`G94}33r;tRLg#7}?WZAQkvbeWJK!8(hz zF$DrzOPM-^`Vg!U%yM!hfL(m{NxPXhxbvHu3Q$M{T=^|Fm!RqY1_uhNl^)DX_UhfJ zt5RlW&@0}%as-rP1^%r%=1iX);ZQ%_n{|wUdB9BTil`%k7Hg#M%sq2$dFI+|mN`&5 z1LnvXSY@8JoF;_IxZ@AF;KC{nj0mF<^TB{%K`KKbYgq}71g%2L6>3yO zV4PjTBe1y)|N1LvKIisq4(IseWFs>6V`OZPgTZS*2goXCUxb*rPJWeslHTK>yLVaO#~)w#>dKQVdmME4(r$kIY4V%&*RT%V Vy?C0Rdg}Vd|F!?W9CQuY{|^ks9Txxq diff --git a/infrasynth/files/__pycache__/processing.cpython-314.pyc b/infrasynth/files/__pycache__/processing.cpython-314.pyc deleted file mode 100644 index 0e12b9240a31d2a8ea9a5024441a571be468050f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9908 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 diff --git a/infrasynth/files/__pycache__/serializers.cpython-314.pyc b/infrasynth/files/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index ef64b45db90383ef7cca56fe6ba9f142bb772bb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2693 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 diff --git a/infrasynth/files/__pycache__/signals.cpython-314.pyc b/infrasynth/files/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index db5c027cb76c3da3da64a4d1656054733eb312d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 330 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 diff --git a/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e6e14caf8b088d23d5db29e4f463c83b8a529b0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 zcmdPq_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9nUh+qpPQLp zlvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A4agzIAjT(VMn=XWW*`dyt{gBy diff --git a/infrasynth/files/models.py b/infrasynth/files/models.py index 39a26f4..0eb1aa2 100644 --- a/infrasynth/files/models.py +++ b/infrasynth/files/models.py @@ -1,8 +1,11 @@ from django.conf import settings from django.db import models +from django.db.models import Q + +from infrasynth.tenancy.mixins import GlobalOrTenantModel, TenantOwnedModel -class StoredFile(models.Model): +class StoredFile(TenantOwnedModel): storage_backend = models.CharField(max_length=50) storage_key = models.CharField(max_length=500) original_filename = models.CharField(max_length=500) @@ -29,10 +32,11 @@ class StoredFile(models.Model): class Meta: db_table = "files_stored_file" + indexes = [models.Index(fields=["tenant_id", "created_at"])] -class FileCategory(models.Model): - slug = models.SlugField(max_length=100, primary_key=True) +class FileCategory(TenantOwnedModel): + slug = models.SlugField(max_length=100) name = models.CharField(max_length=200) description = models.TextField(blank=True) storage_path = models.CharField(max_length=500) @@ -43,19 +47,30 @@ class FileCategory(models.Model): class Meta: db_table = "files_category" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_file_category_slug_per_tenant"), + ] -class ProcessingPipeline(models.Model): +class ProcessingPipeline(GlobalOrTenantModel): name = models.CharField(max_length=200) - slug = models.SlugField(max_length=100, unique=True) + slug = models.SlugField(max_length=100) steps = models.JSONField() is_active = models.BooleanField(default=True) class Meta: db_table = "files_pipeline" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_pipeline_slug_per_tenant"), + models.UniqueConstraint( + fields=["slug"], + condition=Q(tenant__isnull=True), + name="uniq_global_pipeline_slug", + ), + ] -class PipelineExecution(models.Model): +class PipelineExecution(TenantOwnedModel): class Status(models.TextChoices): PENDING = "pending", "Pending" RUNNING = "running", "Running" @@ -84,3 +99,4 @@ class PipelineExecution(models.Model): class Meta: db_table = "files_pipeline_execution" + indexes = [models.Index(fields=["tenant_id", "status"])] diff --git a/infrasynth/files/processing.py b/infrasynth/files/processing.py index 024a553..b6052af 100644 --- a/infrasynth/files/processing.py +++ b/infrasynth/files/processing.py @@ -109,7 +109,12 @@ class PipelineExecutor: return output.getvalue(), "image/png" def _step_scan(self, data: bytes, mime_type: str, params: dict): - logger.info("Virus scan step executed on %d bytes (no scanner configured)", len(data)) + from .scanner import get_scanner + + result = get_scanner().scan(data) + if not result.clean: + raise ValueError(f"Virus scan rejected the file: {result.threat}") + logger.info("Virus scan passed for %d bytes (scanner=%s)", len(data), result.scanner) return data, mime_type def _store_output(self, execution, data: bytes, mime_type: str): @@ -133,13 +138,19 @@ class PipelineExecutor: @shared_task(name="infrasynth.files.run_pipeline_execution", bind=True, max_retries=3) -def run_pipeline_execution(self, execution_id): +def run_pipeline_execution(self, execution_id, tenant_id=None): """Celery task wrapper around the pipeline executor.""" + from infrasynth.tenancy.context import tenant_context + from infrasynth.tenancy.models import Tenant + from .models import PipelineExecution try: - execution = PipelineExecution.objects.select_related("file", "pipeline").get(pk=execution_id) + execution = PipelineExecution.all_objects.select_related("file", "pipeline").get(pk=execution_id) except PipelineExecution.DoesNotExist: logger.warning("Pipeline execution %s not found", execution_id) return None - return PipelineExecutor().execute(execution) + + tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else execution.tenant + with tenant_context(tenant): + return PipelineExecutor().execute(execution) diff --git a/infrasynth/files/scanner.py b/infrasynth/files/scanner.py new file mode 100644 index 0000000..96c4f5e --- /dev/null +++ b/infrasynth/files/scanner.py @@ -0,0 +1,83 @@ +"""Pluggable virus scanning for uploaded files. + +Configured via ``INFRASYNTH_FILES``: + +* ``VIRUS_SCANNER``: ``"noop"`` (default) or ``"clamav"``. +* ``CLAMAV_SOCKET``: ``clamd`` socket path or ``host:port`` (when ``clamav``). +* ``REQUIRE_VIRUS_SCAN``: when ``True``, a ``noop`` scanner is rejected at call + time so a production deployment cannot silently skip scanning. + +A custom scanner is any object with ``scan(data: bytes) -> ScanResult``; set +``VIRUS_SCANNER`` to its dotted path and it is imported on demand. +""" + +from __future__ import annotations + +import io +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from django.core.exceptions import ImproperlyConfigured + +from infrasynth.shared.settings_utils import get_setting + +__all__ = ["ScanResult", "VirusScanner", "NoOpScanner", "ClamAVScanner", "get_scanner"] + + +@dataclass(frozen=True) +class ScanResult: + clean: bool + threat: str = "" + scanner: str = "" + + +@runtime_checkable +class VirusScanner(Protocol): + def scan(self, data: bytes) -> ScanResult: ... + + +class NoOpScanner: + """Pass-through scanner. Explicit, logged, and refused when required.""" + + name = "noop" + + def scan(self, data: bytes) -> ScanResult: + if get_setting("INFRASYNTH_FILES", "REQUIRE_VIRUS_SCAN", False): + raise ImproperlyConfigured("REQUIRE_VIRUS_SCAN is enabled but no virus scanner is configured.") + return ScanResult(clean=True, scanner=self.name) + + +class ClamAVScanner: + """ClamAV scanner over a Unix socket or a TCP endpoint (needs ``clamd``).""" + + name = "clamav" + + def __init__(self, socket: str | None = None): + try: + import clamd # type: ignore[import-not-found] + except ImportError as exc: # pragma: no cover - optional dependency + raise ImproperlyConfigured("VIRUS_SCANNER='clamav' requires the 'clamd' package to be installed.") from exc + socket = socket or get_setting("INFRASYNTH_FILES", "CLAMAV_SOCKET", "/var/run/clamav/clamd.ctl") + if ":" in socket and not socket.startswith("/"): + host, port = socket.rsplit(":", 1) + self._client = clamd.ClamdNetworkSocket(host=host, port=int(port)) + else: + self._client = clamd.ClamdUnixSocket(path=socket) + + def scan(self, data: bytes) -> ScanResult: + result = self._client.instream(io.BytesIO(data)) + status, signature = result.get("stream", ("ERROR", "unknown")) + if status == "OK": + return ScanResult(clean=True, scanner=self.name) + return ScanResult(clean=False, threat=signature or "unknown threat", scanner=self.name) + + +def get_scanner() -> VirusScanner: + configured = get_setting("INFRASYNTH_FILES", "VIRUS_SCANNER", "noop") + if configured in ("noop", None): + return NoOpScanner() + if configured == "clamav": + return ClamAVScanner() + from django.utils.module_loading import import_string + + return import_string(configured)() diff --git a/infrasynth/files/services.py b/infrasynth/files/services.py index f1e4d42..f4a91da 100644 --- a/infrasynth/files/services.py +++ b/infrasynth/files/services.py @@ -160,6 +160,10 @@ class FileService: return category def _validate_file(self, file_obj, filename: str, category: FileCategory | None) -> None: + max_mb = int(get_setting("INFRASYNTH_FILES", "MAX_UPLOAD_SIZE_MB", 100) or 0) + size = getattr(file_obj, "size", None) + if max_mb and size and size > max_mb * 1024 * 1024: + raise ValueError(f"File exceeds the global max upload size of {max_mb} MB.") if not category: return ext = Path(filename).suffix.lstrip(".").lower() @@ -167,7 +171,6 @@ class FileService: if allowed and ext not in allowed: raise ValueError(f"Extension '.{ext}' not allowed. Allowed: {', '.join(allowed)}") if category.max_size_bytes: - size = getattr(file_obj, "size", None) if size and size > category.max_size_bytes: raise ValueError(f"File exceeds max size of {category.max_size_bytes} bytes.") @@ -180,6 +183,8 @@ class FileService: return f"{unique_dir}/{safe_name}" def _schedule_pipeline(self, stored_file: StoredFile, pipeline_slug: str) -> None: + if not get_setting("INFRASYNTH_FILES", "ENABLE_PROCESSING_PIPELINES", True): + return pipeline = ProcessingPipeline.objects.filter(slug=pipeline_slug, is_active=True).first() if not pipeline: return @@ -196,4 +201,4 @@ class FileService: else: from .processing import run_pipeline_execution - run_pipeline_execution.delay(execution.id) + run_pipeline_execution.delay(execution.id, str(execution.tenant_id) if execution.tenant_id else None) diff --git a/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 116ba5bf4718d480fec7343da996647a4b300e17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX wnV(mzA0MBYmst`YuUAlci^C>2KczG$)vkyYXa~p@#UREfW=2NFB4!{901)0UIRF3v diff --git a/infrasynth/notifications/__pycache__/apps.cpython-314.pyc b/infrasynth/notifications/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 6d4faa02a0ca5499eecbb12a22d0470b00b90cc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1086 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 diff --git a/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc b/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc deleted file mode 100644 index 7bfea30b0eeb368cac23d24ede28b3efd6c3aa15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3477 zcmcgu-ESMm5#Rgbhe%0h@nlN=k}OI}2*;uu3AHT={($m<)-EBRWTvq!9gfG7bTLKJ zdncuGfP!cXM0qG82X(@{seMSCx8@;$UIO@`e*hH=5OWC-7kw!5CRYWV0!7=Iy(3LK zcATd!z~Su8?#|B4Z)WFMTWbq}@!!k&H(m}9@;98+hv+kVuK-gfb42BSMjSFrCi(i3 zhyO>(EI-0|b4TbTM~TYEi7JfvYaLPL5+o^pi%ZhR!;OSA^sK{I9W>1nhf|5e&jlP| zKPi$Fal~0kZOM>f4o}U8Q~{`*=SKWDj!gnszb2Vp7#yuxy8}rVTXMH=2Zi7vQ&-1~Sf-Nruevr+!CB z4!#~Dm$*ev<)*k9a15iu3qe@u4^@1^HwnL`L~vW+TXdt_y5iqNE9gt6E9Ld9nNPcs zoM{`>DmhlM;D%9TmvqDYgmVSBW|DUU^F_L(J8r9{8F}5dbC%&~nvG}nmEGyoVsXh# zEfuN0qF+l*(&8JY;n*pwFi&-Rwcsos&+4|B%Id~7vyf~0o+=a_Yu+++)NH4y?;m@p zwCYARO)r34cT7!l2cG7`q2^_XMSBd$cV+T`bV%Lzk^D7&U{}IPfXK1+>EGYj4dN_B z_IFoCZe7_8>jy?BI;Zoxpoz(2EV;O0U4LdL?zRZ3sb@v_ng0GfrYq-Oi2)2{`=om0Rk+l(v*~f zpicU`n*XN6C{Y7bQk1+X41(aQpEra|HrwKFQG-qE#d_ZLfJJcokju{(D8iA3fw+XC z1r*Wda-%nn)I-r*j~6fqXp@?`(cYxswrF*U?FRD2g$0wkQqIgSFC+u5IKNylT*1iO z>~v30LxBxNgbbE(&~{W9&Y1=P%PCTKq^|h!JQ}L&9~#dW4Lxt48EWj>2Zz{;KmZc= zWMw^9ljD`no$ge%JGF7VCLgZ6wbOH=+H>M|wk98}yk3)gHV*Gb0%zLRCf-fIldf2m zncLj$;ja4x$nJh3cUMDQ>;{jN z7F^Ft%Q;|vOlg6_!TBXcYA!QgF`;;O4-a8#8dk|N3yvE!iUr5~mBVaICwUr$Rj!D& z0w%4vr45K*HJH&<#;+Kn9+aE};%BacnXCAjt3c)|am-o;vbJMCW~{<8wxZk`#jI64 zoAF-tfjrCF{{REID%Nu?H;&~rgZV--9Og?YkReaXsY&$NV%ITp7 zAFHfxp4^U2RHGALw#UAoDHtjI?F|9>;Y@iXFF5eB;N=C@vNVkVeKzc||FF^R4EBQ5 zf1d#PRQPl5vlhmBH*C~$SlPh!Utj{bZoDD6vkZej8p-wEM<$o~%fhmdBFp?MfRi^t z8%<;cl{8~H!=3siA>KQ60f6*cBFkUsCL~ObkY%AHl=uX>+IVT6oKN-4Nr0vvf0qS_ zWWWOVG5krEV=mcr0IW-j9t_c=E_co4R%{X2!NEGv;(%2RnhpyZnY^j3_}z`|o_H$o zp0Z;Y&b6@yl*!*CN^RizkLUh4w=-~hYvAV0q|||8}g;B+?@Kb zfBp4a?OS61KLo;Y41g5Nb5cmB;c0J_j3l2V`DdPePjp;-zA48DgPf19yE{ z&tp3Yvvy6Jx2Wypt%6x7YMK}92x}*oOlPr}qrG_TV@O^E;_hqe?67BmLpq`9goe;E zG~IEim0fmB8$QFzMq6r_c_?RqDfZT2Z+*^tYS7sf2{U;HnD5?(XxIAs%Rj#-$q#t! z0l5 z=uh!BkUrj(n18)Jtmz4jdtN+4&%qKj!tMjID{vh56=}N{mUqGjz6c+voVpwCF9#k3 Ne!}(d5g_c0{{d^=-c$ep diff --git a/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc b/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 4c0e159f4fc46c69593a7347b5619f02ff4b6433..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2225 zcmai#OK;mo5P+9YQH*69sUKqzwWTfq~@U9PApblS^SQC~9pnrbuOX zl~z6ZWaQ8vkYf+Mq`#m)qXq(`U7)8Pir#|SX^!a(DaoW`*93ON*_quL?tDA?bhP3U zc-}sU9{p?)@+T^T2lVtg_z8Y?$tLl%`(#;@+C16RJpI1DY)E6-l;*M}t$8v@E)ma| zCZ4&Wt&Q~8=6-G(nIcQ6rv;w&8J)H%U;=ttrAjEkC~ zDtB9c9JAoQFmLJvEOE@6l7GX$fBlD}N`-{71=N?4hmEde0v01} z2QY|wSZ^h5R&OWVf8y`d*LZ@TBI;qh#eLC>W$UwsFIc_d2Rkfo=H}~h4hm7v0kqiZ zDHBhR<6SAi5Enq~k>AEYdTRZnyXW1f_Nyr}d+FI1FPsZ}7L0rz1?IK-+~$+0Ore-Y zfvYoz8GJ*pY|$LPIRk-5BG9-;-slyp`kH{q9MPu$&G4??0ZR_eX@myuCEQofxHUl2 zN^vy-S8Ir??b(1+2~cv1xDGDJ5w6M^ps`-ll`WDk2L#H)Y85gY;(Vo;*~KBw2I9Pd z(KUcshYU1Z4TXOQF2}pZ=QxMr z8jNArC|mAp0wUW2&8sz6jdCOt8e*G$oA4cOz`U~4-Ya;Ta0+MIlE+rCB_9a_;v2-0gn}DCsx+2S2eBaR2}S diff --git a/infrasynth/notifications/__pycache__/services.cpython-314.pyc b/infrasynth/notifications/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 6d363e85f265d2cafb9ecb95387d727f66049923..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14869 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 LkgoB1d5QlQRGe-F diff --git a/infrasynth/notifications/__pycache__/signals.cpython-314.pyc b/infrasynth/notifications/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index ff174a45ea76992e8650e059de23fed5d32e5a44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 314 zcmdPqqe@a$jUV6S>N@j6EVo7pF5i?N9Ey29}lFYQs zSo}HbY_ulNh zncw=15jjF&{Pk&e;d_;kU+|_ew7RnM6)5XuhFHQB3GgaT3D?Cy>?hMC39}B40WvM0 z7i!i(nHC3#C0X)}G;_&P#^jpaQoG3c`ouu3`e=uI^d=w85{5`D^c&7e!~`DpGY+F^ zJ8=BPwNWoO#~gn(7i7-m@_{$!xlZ8abN;zB&3d;fvzx7{n9SvzvqZT(>*dlsyyUFd zj0Vi3J~z{J&RNa|Rl}+c^PN>!tVfC$d|?TGJ72;pLRth&%XOgu=>T1v60b`Ish{+c zn_^PO3HwJfy#F5WEUA&MWaRMEn}Q{e)zev!Q_{e0;tEPHNh!&rhv%er51^l_dc7}4mygU+kXQJuUuX=vL z3ET|W&1U-z%5Hlu<$6Gu3Rx$h;2;(jUko-oMbq`!(bIkuswO&@U(TfyYaNYM(LU#R zS(<(%a*3|EJmi8)kj~%9#SE@4<)LPLhR4ixzqX~%@Lai;pIxAC;B&*LSx}(HFpM;p z=4rrNuI8x^p{D7<3{QNv+T)X42oR^#D5u2`1cR8T8PoZc-*H&Cwz#``%2%@ek zSjMX{jmxmZ91k~M#`e+3_0JVndFQKokqyJ1*e3pWAlAtr22nl3Q8XtWuWgzim&`=bOl(-w8)jnDoVlxSA0e%So5s-kM-@?4E|&Gy zlHOO;`yOPr^pT2ATDmsmV}DkTl9p&Ge6|=q`<#eM=a0H^-?(RNbd5j$d`q9=t!705 z^ULQ3X*r4>7r~>XcNg{UjpL&azu3~p%SOljg?kGRZakd4yRd1*zrC@cUwDbrF22}_ zLG)jrxzb6V27?`82{@XEg91sNM_ zE)OVk5k8IaIv4?fc3~t}VI=(rFj7)-6O2@Vk@_kaeP->UqYfkKe}$3u24JM^gOOH+ zk#<>Yh7k+Fu`v9gvKAD`Th@xA9YkWS_aH*>IO{B79pDl(7_z+9smRo7yRt6Sx=|oW zSq}>2B|8Cvx9@VHQ6cOkKI}&k1rgKP0P2Gvxb9}0T&@AA+WJHI^c0FJ1+W{3>lk3C zanj(cI(L6nvNPa_+q}^}9w_Pq-@LV@zyD_X_=CscE&b|0^)VZPSBU9cL;50hRToIj zRv9)O4*{k>sbN{SZTtse+qIKGid9qai0pt$YI)Ny!N*`ZODEWSV8c(9PeD{9K@ff* z#;@e$&!M(bsOMRz=fQ=|(5ZEGTP0$o9J){rMapgM34Ku@4`-h11uOc-2eap diff --git a/infrasynth/notifications/__pycache__/urls.cpython-314.pyc b/infrasynth/notifications/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index f5d233aa96c4e8f9962de553a9dffa6f2f555184..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 873 zcma)4&u`N(6tz`o>B%*)`exMTwHWcq8gkg!VR^WRqh;TiiVz=M9vbc--gV19rDW=&RpyMLGB(nYw7pE|fJc_W_M?BMq~nz(N&<7RRmt;gv+Ln@R2`C#68R=X6OI=NqU^9mM7QIJ^s40Vsi|* z>LQ;}E5M>9J;AKAE{C;_Jimu<^Ac`do`qVnLv?V%&5eH49ERvAxheGJ3)7KsQx!t^ z9o+v09{kcYsGI}oav-z%p)}FI0{v~_b>TD@-+3I@Urh>MfSX|Xxb!4`b|A6C1k1*^ oAI2Ly65Bo3K`Q<3`umrsd!P0{?w{q$XWHGVsuYwrSt5VkAD?jR0ssI2 diff --git a/infrasynth/notifications/__pycache__/views.cpython-314.pyc b/infrasynth/notifications/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 24f36f2997c52a5c947e8dafd19e3309ab23893b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2702 zcmcIl&rcgi6rQmcy!hAh!{7$e5CVxXPH_{_RH9LoDiy2`0pwIU)mbg}I9^z<>CBLb zp1Ad2=rQVz|Db<{5eLw!RS&(iQV*#dg&zCfuGe^t(>9g5(!QPdX6EfTGvE6rpH9UI zJiq_CyYu}JA-|v#Jm7mz`xSf+$um--MWRzlXR#eAM)atR>WWmrr=@6dNFSENdQ8T2 zRjOW0DURrI8P^jsp(kb1iwzZ1dRnG+O=|k69GxYTWR8@Ev!oO&M^~c$FXfmQRUtZ3 zR=Uw~FB*qvqG#+xDLGGaso!vFj=EY=e7-O1%#l{bl&qSIxXH)ODzl$j?Bx?C-Asv@ z)lJ7f=#A#0?!=?I={U@O)O2dr^EJzsjJwktO=;CIlht&-u*9AzEA>HrU$VxY4gd4| zAYCey&dm%;6pjWdU~)Dz`3d8eX$9nkPqeBNycf=yPMu?HyK; z!i}1?eMDVFFuNx3zqj12LcQ5wg+`N`FU;M-7H@h(3zk#krZ{k9{iAJDu)?-k*=0_( zCth%Rh$0FvfNWxU@4!tyXVUn3pYa0$+*|ycr7!o6%mN&N=8*iT&7W%dV=aHG6^^yS ziFT)@-f2S@IU*amF;_JV$80deaN~y2Xjb=aY$px)t7&(7G{dM_Tu9q;nA0>2ZzuQ! z>IuR$JdKAO*Uv`-GX@RuDVRUMgWNf)ZWn(Dzau zM7%nPcIrTZIv5JqL7**xN~y9VD4~i#u<)wwhtT*h6;UDqM1Vl~5~P8;z$*9H;HIG% zpg0W9P|T`)5_$wQW|;$1z}7LK)rQ*H5BTRhR0TI$k2Go=s5b%d2f)5nxyjQty?6mTFKX_EJ`W2TK<#+~L&NCPgE27B4$yUQ1<+R@o;-vIA2VN!4f0pUI8 z#yX12y~TLk_mp@TE&yf=|4h8n@$S6Dg`f+6PrMRBvG)ZQ8SE{UV^#QgQGQ+ZzJH}W z;3&N)Z(nNr4Cvxv+vUaX0H1Y=la~>yjgC3K|=aC@vCLN=9U)_E~;PWOEqwBPF S;~skJ6y5xH_uqo!Z_r=lEoSim diff --git a/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b3213369ff905b3d59dd3e176cd81ba009b11fd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 191 zcmdPq_I|p@<2{{|u766{DY#pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX znV(mzpPZ4HmzSDTtREkrnU`4-AFo$Xd5gm)H$SB`C)KWq6=)gAImIBxCuT-Q#v*1Q F3jhxtGQI!+ diff --git a/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 50c6bdcb08b091bca3262b7aacc12522e5db83d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2525 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 diff --git a/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc deleted file mode 100644 index 2b038dda900795d0e725e80382496c9873aee324..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5281 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 D&n>1v diff --git a/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc deleted file mode 100644 index 7505cb380c7a0168a91260bbfbd4dd15c279b082..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4378 zcmb7HU2qfE6~4Qxf610C|A8^~f*r7cShP4L1p~nZVhZtywc>PIll6MFwiYCt^0 z{+#=B?z!jZ+uhAgAp)f>lsosw03lyu$1UDkW1T^xOkN=pcakut<`@Tk{v-bT&_hd8ItLhWQ5k-VKm@}2b$3ANF%#PyMc z|7+}yB{(a5EUOd>S}r|T)S&B=G_#ar$QV&ft;STH#SX3QMAqMfz5?=}C5)2@<6miF zLI*M6Hq3L@n~F3pa@x!<@hl)kBu^*FaV&T?G(q-pC}S=l`GCfTYKvG{YO;BLn-@SH ztcciSL3=EOW2{LEv*roHqLKjjYPO^K3k#+(~W|7UYj$;0jen(afTXsacZizw=-=}wP#OFB@Tq1 z2IjQEWM-V#3R6?K>eZ4?wM@chffP*EGsrNCxVMrMGun&-8b=A)9?`gVvFy3{@QIUe@(80 z$MTq3Zia?o5yj`HO+ZXMdR7 zStTLKgYkL3Muk~E#~v&n)mVuZs2>KwpF9~|WeMK$%@{PVFsn(H)tq9QI4sL1F5O9ZoF|r|u3*cU#7nhow8`ZmYlPyQ=L|-*0BNu?aTy z0EM^-l*bm1?c?`!!4|cW^nyY9Xd85`U_FdjezP=AC+miu z!b@x@U|z&E&A#s%J_M_7(u-#_N>E#0^iBBw&vUp(=WKx}k@iVKT6o;i(tMIH`X@Z@ z?io0}n@FAszvSHkr%wWWa7xOC6mHL{x1qdGNVO@9Bpb?;wI}%`|NffJCj2nx3BW=M z?60|YF^~?V@aZ=c*QJZ0bSSlT0~5MZ*wu!6&)E^Y=l_@;ac4K^3*^H0k(xjP^w13@ zLVnB_!$0`?&FL`CbocLTv=?#1me2)zi9VVuHogW%)eXf*GS8uu#o#2mo?TL4l^cr` zO#6mvI`3_i;E5JHi4JI&bRFKgGL`tPL<$vyM}mE|TIY0fP}oXzE)kpG=0-UY61QmM z0ueHc=mfzMAd~slF~bHQ08GRpH7@8GEn{bI%fqyb5GqE~FP^qSMTMH0oHsI>6-EH{ zqGp=PtOgm)Yj11$9JB!go^z&AuygF%m9Kc&19zKDHS4@Q3W8NFoiw?i%j%eL`FMyh<0(( z6o9Bg#%gn~?}8W{wd3t!$jfC?jgeT}l}lGHEnd3Uy7~Hn_YYKhj;^$hR3am5@hz+I z-Ie(6C3f?T8*eQ7{~qnA&)K)qI#h`aRr%)Nz!$O2OZ(Si9ZQ~@(WU4uf7L5=HZQ*L zFhF9RYXgH<{Y#PM*i-j9d#^9Nzpxf-yWVuIX*Jfr9P7V5^rypj4uAA?CDy+ZORcqc zuWjnS8MzU8;P-55UL38)No>=Vh06=?yt(LEi?&|5c=_Uz=gOtGFRgX;+#I+uaO==Y z*N;}a#w%UpACx})`RbuF%ZJWXhNa3g%G}#? zOKtDGSqb!f`=ABRSd9)WM+Y7dJ~(hM5?_sUFGsp7TaSFyc{g&hw$1O|8YFW3;ggP`6EWe_5C`o~V_u-&$=K+& z7!21%Am5joF1k5rqe%M)+^Y-5-KyGC0%E^(9&V)jZ1V`E=q@J{gQu|y0(dqK$~%nivHTuM1?0|0)t zq~vOxs4UOu)MPompcM>Y`(-&}sIpA)=2jqm{Pgh`q@yodk=lQO%;0C1{uG!LbD0&k zfwmK#Xg>}=k7N{i?SQ?(&2?1<2d4UTiD@Pr(b9m6)ELSIW2TMq*O zeg28S>EQ_u9{L-6ZAWE(*fSBa{1~hB!Ytj7B6mLeUm7&C3dHb1tx(FFjyg6!&V~2` zf0(9?=>XorFBU~GY6T(6owlPOMX*Z`BEcgmCU8dr`-dbj>^#k3`U_y72F$%csshJx weDiTFf7$oIcYy1^dj1;%mA&JC0Y!YkoB#j- diff --git a/infrasynth/notifications/migrations/0001_initial.py b/infrasynth/notifications/migrations/0001_initial.py index 7572b17..b9e542e 100644 --- a/infrasynth/notifications/migrations/0001_initial.py +++ b/infrasynth/notifications/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.db import migrations, models @@ -9,23 +9,11 @@ import infrasynth.shared.enums class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("tenancy", "0001_initial"), + ] operations = [ - migrations.CreateModel( - name="ChannelConfig", - fields=[ - ("slug", models.SlugField(max_length=100, primary_key=True, serialize=False)), - ("channel_type", models.CharField(choices=infrasynth.shared.enums.ChannelType.choices, max_length=20)), - ("display_name", models.CharField(max_length=200)), - ("config", models.JSONField(default=dict)), - ("is_active", models.BooleanField(default=True)), - ("priority", models.PositiveSmallIntegerField(default=0)), - ], - options={ - "db_table": "notifications_channel_config", - }, - ), migrations.CreateModel( name="NotificationTemplate", fields=[ @@ -38,10 +26,19 @@ class Migration(migrations.Migration): ("is_html", models.BooleanField(default=False)), ("namespace", models.CharField(blank=True, default="", max_length=100)), ("metadata", models.JSONField(default=dict)), + ( + "tenant", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "notifications_template", - "unique_together": {("slug", "namespace")}, }, ), migrations.CreateModel( @@ -72,6 +69,15 @@ class Migration(migrations.Migration): ("created_at", models.DateTimeField(auto_now_add=True)), ("completed_at", models.DateTimeField(blank=True, null=True)), ("request_id", models.UUIDField(blank=True, null=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "template", models.ForeignKey( @@ -87,4 +93,49 @@ class Migration(migrations.Migration): "db_table": "notifications_dispatch", }, ), + migrations.CreateModel( + name="ChannelConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100)), + ("channel_type", models.CharField(choices=infrasynth.shared.enums.ChannelType.choices, max_length=20)), + ("display_name", models.CharField(max_length=200)), + ("config", models.JSONField(default=dict)), + ("is_active", models.BooleanField(default=True)), + ("priority", models.PositiveSmallIntegerField(default=0)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ], + options={ + "db_table": "notifications_channel_config", + "constraints": [ + models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_channel_config_slug_per_tenant") + ], + }, + ), + migrations.AddConstraint( + model_name="notificationtemplate", + constraint=models.UniqueConstraint( + fields=("tenant", "slug", "namespace"), name="uniq_template_slug_namespace_per_tenant" + ), + ), + migrations.AddConstraint( + model_name="notificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("tenant__isnull", True)), + fields=("slug", "namespace"), + name="uniq_global_template_slug_namespace", + ), + ), + migrations.AddIndex( + model_name="notificationdispatch", + index=models.Index(fields=["tenant_id", "status"], name="notificatio_tenant__a73911_idx"), + ), ] diff --git a/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index eea71bffd7ba0cbd949c34889e6cb576fca6f07a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4287 zcmc&%O;FoN7MA>P^AnjE2w+TXOeQA5h9nLXhs^9G3<(J&jv(2o?1Ca&jZq>?L~66c zoO0OO^Bg&5Pg{HA$dO}?vd2|&^<`5vr@gr*)LdtuEEz;%vYEryQia|9^z?qd@4eUU zOT9fI3jTh3BE3-0P}I-nrv2x&7w_}1culQSEWJo!8ophNu6u6mzURT7F-z{nzJ4lB zvF<^N^{lz`y=^1M-`CvM8v9v#oJx8BZEhc?TqIN$ODc~=Mb?O~tO!WbipQ@})QG)# z|Hfy=u?8`)6n3o#v74o_Crx4Rny-^bh{wgc*8=%Ir#WjKr`83QW<9KT){g6{i}itS zq?7JiSN`0`^89cIS+s*J_OWa|!un@jmK;0>PV>C}mJQC*M|vU9JO8n@IJFjLyBvDm zpqK2V2RS?SdO+_&Cp}=fj290I+`PWW82g@DR1=yc}KP&pTT(icQT$hE#~##PT=(aV{-ho2tQyl33~sH z$%)f!r71S|h-NRqB>2L?(#6wkLhnS`lw)R0g63!RE_(^|E_dR+)sL4Q7N67vd^T}e zB0)1UZHq4=LII-Qg2c-&Rg-87i$ED+Uf?lL22SSKHc!q}Wbsu6aabuKyopqDBCC18 zm&e6T5>!!9+!B$DiQf*9c+8-PPs2Q}04!Tb7DTy}a;rV2|KlKfkg6&w2Z3sQ2}o4O z+s}!g$7Up$^vLLY%&7=N3Gmp~0pKf=L>M3fn~yb_-_kY}OhPuOrYddYEGvKEctL=Q zQGf{qki%0FQW0dQVGdXc93>qx>&DCrX+KPf@`lQ5J2KwnPGFRF?%J=WyjJvP?~$M) zb8u4wU!+?_Ci_B*Q{uwPhk&;|{?okfsR0v#IDDi$urmh}@#k&)T+ksV&Yl(cgp&7d z@S=nS;#+8agCx+x39u#s)$|T80)B6^SMJ0SpzN@Pe013ULVq zXnm74xNMWa%D1cB!zW8iB&0xy_6@wB#X6$=%NG>%i}0E74NPmw}qQz@fN zSyB0I{$(bwDrVDanPViT3cQ9g1-|$aP<6;N&Z^Qf$JhSM^z`%$XVa11+EL-6PqjYG zQK#`h$=|3(Z{O>?@6PtWUTXTN;8`OOs|8|>@cDP)!QBC4C{-Iu?X4UL`qMwvhyJWH zYmM;On*k#}Q;W~+Pa3%g)!YLk_qdvSY~qxE``p=&jo5tpmP3dQWE- zn?3$WZ?lgIkLb}{)9vc-J?!rZhMG}F*k8O0_y4$U3{KVtC-*&Xlln7Gm(}{9rZc$V z%&0zOId&x377gz%7}3kM=;ggu5nYdVu{gj*IfwPYr)#nF z-s-^>oqbY|J=K{%+F4K4MyK|K`shuanQNX6LDuJ5Szl`~aXooMAI~+tZb*M54CyC7 zJN*{OrqBKHddv#r_vLz|z3;9^=XBFo~pHHWFbcpIVp$bni<+}D{0HcE4~=-k2Mdi1W&+_RCqUQ1lxUw!MT zCzf^Qp#$;#`M1gX#3P-_H^QU4+s4?{+St{DNPTQUXYSgSm#xLK`|}4-j!QbTZ0lue zW0^gnt@$nzYsEZKi%c|PiN_I|p@<2{{|u766{nw(pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX znV(mzpPQLp1ZBp@XXa&=#K-FuRNmsS$<0qG%}KQ@Vg*_Ua!@gd@rjv{k+Fyw$N~V3 CY%{R{ diff --git a/infrasynth/notifications/models.py b/infrasynth/notifications/models.py index 3f68445..41ce035 100644 --- a/infrasynth/notifications/models.py +++ b/infrasynth/notifications/models.py @@ -1,9 +1,13 @@ from django.db import models +from django.db.models import Q from infrasynth.shared.enums import ChannelType +from infrasynth.tenancy.mixins import GlobalOrTenantModel, TenantOwnedModel -class NotificationTemplate(models.Model): +class NotificationTemplate(GlobalOrTenantModel): + """A template. ``tenant IS NULL`` is the platform default; a tenant row overrides it.""" + slug = models.SlugField(max_length=100) name = models.CharField(max_length=200) channel = models.CharField(max_length=20, choices=ChannelType.choices) @@ -15,10 +19,20 @@ class NotificationTemplate(models.Model): class Meta: db_table = "notifications_template" - unique_together = [("slug", "namespace")] + constraints = [ + models.UniqueConstraint( + fields=["tenant", "slug", "namespace"], + name="uniq_template_slug_namespace_per_tenant", + ), + models.UniqueConstraint( + fields=["slug", "namespace"], + condition=Q(tenant__isnull=True), + name="uniq_global_template_slug_namespace", + ), + ] -class NotificationDispatch(models.Model): +class NotificationDispatch(TenantOwnedModel): class Status(models.TextChoices): PENDING = "pending", "Pending" SENT = "sent", "Sent" @@ -47,10 +61,11 @@ class NotificationDispatch(models.Model): class Meta: db_table = "notifications_dispatch" + indexes = [models.Index(fields=["tenant_id", "status"])] -class ChannelConfig(models.Model): - slug = models.SlugField(max_length=100, primary_key=True) +class ChannelConfig(TenantOwnedModel): + slug = models.SlugField(max_length=100) channel_type = models.CharField(max_length=20, choices=ChannelType.choices) display_name = models.CharField(max_length=200) config = models.JSONField(default=dict) @@ -59,3 +74,6 @@ class ChannelConfig(models.Model): class Meta: db_table = "notifications_channel_config" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_channel_config_slug_per_tenant"), + ] diff --git a/infrasynth/notifications/services.py b/infrasynth/notifications/services.py index 674a9e4..5bd1ca3 100644 --- a/infrasynth/notifications/services.py +++ b/infrasynth/notifications/services.py @@ -53,7 +53,7 @@ class NotificationService: else: from .tasks import deliver_notification - deliver_notification.delay(dispatch.id) + deliver_notification.delay(dispatch.id, str(dispatch.tenant_id) if dispatch.tenant_id else None) return dispatch @@ -88,7 +88,9 @@ class NotificationService: else: from .tasks import deliver_notification_with_failover - deliver_notification_with_failover.delay(dispatch.id, channels) + deliver_notification_with_failover.delay( + dispatch.id, channels, str(dispatch.tenant_id) if dispatch.tenant_id else None + ) return dispatch @@ -143,6 +145,9 @@ class NotificationService: return snapshot def _execute_send(self, dispatch: NotificationDispatch) -> None: + if self._rate_limited(dispatch): + self._mark_failed(dispatch, f"Rate limit exceeded for channel '{dispatch.channel}'") + return channel_backend = self._get_channel_backend(dispatch.channel) if channel_backend is None: self._mark_failed(dispatch, f"No channel backend configured for '{dispatch.channel}'") @@ -152,6 +157,28 @@ class NotificationService: result = channel_backend.send(dispatch.recipient, dispatch.subject, dispatch.body, is_html=is_html) self._finalize(dispatch, result) + def _rate_limited(self, dispatch: NotificationDispatch) -> bool: + """Fixed-window per-channel, per-tenant rate limit (``RATE_LIMIT_PER_CHANNEL``).""" + rates = get_setting("INFRASYNTH_NOTIFICATIONS", "RATE_LIMIT_PER_CHANNEL", {}) or {} + rate = rates.get(dispatch.channel) + if not rate: + return False + try: + count_part, period = str(rate).split("/", 1) + limit = int(count_part) + window = {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(period.strip().lower(), 60) + except (ValueError, AttributeError): + return False + from django.core.cache import cache + + key = f"tenant:{dispatch.tenant_id or 'global'}:notif:rate:{dispatch.channel}" + try: + count = cache.incr(key) + except ValueError: + cache.set(key, 1, window) + count = 1 + return count > limit + def _execute_failover(self, dispatch: NotificationDispatch, channels: list[str]) -> None: max_retries = int(get_setting("INFRASYNTH_NOTIFICATIONS", "MAX_RETRIES", 3)) is_html = bool(dispatch.template and dispatch.template.is_html) @@ -176,7 +203,7 @@ class NotificationService: dispatch.save(update_fields=["attempt"]) dispatch.channel = last_channel - self._mark_failed(dispatch, last_error, max_retries) + self._mark_failed(dispatch, last_error, max_retries, increment=False) def _get_channel_backend(self, channel_type: str): channels_config = get_setting("INFRASYNTH_NOTIFICATIONS", "CHANNELS", {}) @@ -209,14 +236,19 @@ class NotificationService: channel=dispatch.channel, status=dispatch.status, ) + self._maybe_drop_log(dispatch) else: self._mark_failed(dispatch, result.error or "Unknown error") - def _mark_failed(self, dispatch: NotificationDispatch, error: str, max_retries: int = 3) -> None: + def _mark_failed( + self, dispatch: NotificationDispatch, error: str, max_retries: int = 3, *, increment: bool = True + ) -> None: + if increment: + dispatch.attempt += 1 if dispatch.attempt < max_retries: dispatch.status = NotificationDispatch.Status.RETRYING delays = get_setting("INFRASYNTH_NOTIFICATIONS", "RETRY_DELAY_SECONDS", [60, 300, 900]) - delay = delays[min(dispatch.attempt, len(delays) - 1)] if delays else 60 + delay = delays[min(dispatch.attempt - 1, len(delays) - 1)] if delays else 60 dispatch.next_retry_at = timezone.now() + timedelta(seconds=delay) else: dispatch.status = NotificationDispatch.Status.FAILED @@ -231,6 +263,15 @@ class NotificationService: channel=dispatch.channel, error=error, ) + self._maybe_drop_log(dispatch) + + @staticmethod + def _maybe_drop_log(dispatch: NotificationDispatch) -> None: + """``STORE_DISPATCH_LOGS=False`` keeps no delivery history.""" + if get_setting("INFRASYNTH_NOTIFICATIONS", "STORE_DISPATCH_LOGS", True): + return + if dispatch.status in (NotificationDispatch.Status.SENT, NotificationDispatch.Status.FAILED): + dispatch.delete() def send_notification_direct(dispatch_id: int) -> None: diff --git a/infrasynth/notifications/tasks.py b/infrasynth/notifications/tasks.py index e7dd2a9..ac62e82 100644 --- a/infrasynth/notifications/tasks.py +++ b/infrasynth/notifications/tasks.py @@ -5,29 +5,41 @@ from celery import shared_task logger = logging.getLogger(__name__) +def _bind_dispatch_tenant(tenant_id, dispatch): + from infrasynth.tenancy.context import tenant_context + from infrasynth.tenancy.models import Tenant + + if tenant_id: + tenant = Tenant.objects.filter(pk=tenant_id).first() + else: + tenant = dispatch.tenant + return tenant_context(tenant) + + @shared_task( name="infrasynth.notifications.deliver_notification", bind=True, max_retries=3, default_retry_delay=60, ) -def deliver_notification(self, dispatch_id): +def deliver_notification(self, dispatch_id, tenant_id=None): from .models import NotificationDispatch from .services import NotificationService try: - dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) + dispatch = NotificationDispatch.all_objects.select_related("template").get(pk=dispatch_id) except NotificationDispatch.DoesNotExist: logger.warning("Notification dispatch %s not found", dispatch_id) return None - service = NotificationService() - try: - service._execute_send(dispatch) - except Exception as exc: # noqa: BLE001 - logger.exception("Dispatch %s failed", dispatch_id) - raise self.retry(exc=exc, countdown=60) from exc - return dispatch_id + with _bind_dispatch_tenant(tenant_id, dispatch): + service = NotificationService() + try: + service._execute_send(dispatch) + except Exception as exc: # noqa: BLE001 + logger.exception("Dispatch %s failed", dispatch_id) + raise self.retry(exc=exc, countdown=60) from exc + return dispatch_id @shared_task( @@ -36,20 +48,68 @@ def deliver_notification(self, dispatch_id): max_retries=3, default_retry_delay=60, ) -def deliver_notification_with_failover(self, dispatch_id, channels): +def deliver_notification_with_failover(self, dispatch_id, channels, tenant_id=None): from .models import NotificationDispatch from .services import NotificationService try: - dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) + dispatch = NotificationDispatch.all_objects.select_related("template").get(pk=dispatch_id) except NotificationDispatch.DoesNotExist: logger.warning("Notification dispatch %s not found", dispatch_id) return None - service = NotificationService() - try: - service._execute_failover(dispatch, channels) - except Exception as exc: # noqa: BLE001 - logger.exception("Dispatch %s failover failed", dispatch_id) - raise self.retry(exc=exc, countdown=60) from exc - return dispatch_id + with _bind_dispatch_tenant(tenant_id, dispatch): + service = NotificationService() + try: + service._execute_failover(dispatch, channels) + except Exception as exc: # noqa: BLE001 + logger.exception("Dispatch %s failover failed", dispatch_id) + raise self.retry(exc=exc, countdown=60) from exc + return dispatch_id + + +@shared_task(name="infrasynth.notifications.retry_pending_dispatches") +def retry_pending_dispatches(batch_size=None): + """Re-delivers dispatches whose backoff window has elapsed (beat-scheduled).""" + from django.utils import timezone + + from infrasynth.shared.settings_utils import get_setting + + from .models import NotificationDispatch + + if batch_size is None: + batch_size = int(get_setting("INFRASYNTH_NOTIFICATIONS", "RETRY_SCAN_BATCH_SIZE", 100)) + + due = list( + NotificationDispatch.all_objects.filter( + status=NotificationDispatch.Status.RETRYING, + next_retry_at__lte=timezone.now(), + ) + .order_by("next_retry_at") + .values_list("id", "tenant_id")[:batch_size] + ) + for dispatch_id, tenant_id in due: + deliver_notification.delay(dispatch_id, str(tenant_id) if tenant_id else None) + return len(due) + + +@shared_task(name="infrasynth.notifications.purge_old_dispatches") +def purge_old_dispatches(): + """Deletes finished dispatches older than ``DISPATCH_LOG_RETENTION_DAYS``.""" + from datetime import timedelta + + from django.utils import timezone + + from infrasynth.shared.settings_utils import get_setting + + from .models import NotificationDispatch + + days = int(get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_LOG_RETENTION_DAYS", 90)) + if days <= 0: + return 0 + cutoff = timezone.now() - timedelta(days=days) + deleted, _ = NotificationDispatch.all_objects.filter( + status__in=[NotificationDispatch.Status.SENT, NotificationDispatch.Status.FAILED], + created_at__lt=cutoff, + ).delete() + return deleted diff --git a/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc b/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b0cb710e0ae86212da270b063e6165e659928462..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7haoROMRnv+_j qA0MBYmst`YuUAlci^C>2A0%p5#0oSYp z^Xk{|+vv$YMB)&4YdO=KcS@d3*obYykoNdmEhmH4yrtFJrP&VR#pW z0Xjn1c!gdX(pW_$R7E&bMmT$DJjy11GK0-qsAm1t*P5Z6t#0>u6t?`fn>ZvKs*Gs( z5Dp4?50Nx5l9?k@W*1QeRiueC(!yDp!)6)DJhr5LfTV+SkUy{H-nhri>jjJ_>CaZY z6B@Roy4S3CB98^4{>*Q0^<=c;v%ouB@WU3Tq94lRx)_J5DJ_|3-EnuiMH@cAW)C+& zxWeBB{0GL~csklMC7i)oY+?)NaQ<;>Uq#r4t{t8MCz#6I4x_Ti8D?!?NZwb@q_}b> zz|QXNQ*l~JO@7#3udoly?MXih*DEyiDxE0wWyHa>uX3U8ZX8D) z*62i>zNe>+JszDfSBl0}-Um%8SffeZQx3-t8-kGwStKX@Wm_SZD~; z4ipr3fDO>Oz4_fPf3quJ?MhrM$Meha(ssPC6qi;mW{^|8L^-E01XuJK?bZs)CPWW} z5LF-qRs(hhJslXKut8vh72-umsjPrDS-=;xTp~nB zDt(twDY@V5NhTn6o)9;nLU_I_T^0m{z?08TDIrX+*e1ULoa-GKF%tx_3hd8EfEnL> z^2Joi53ZMK-1ji`qKV;Y>;Q#;t diff --git a/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc b/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 2bad1261ee691861cb9d4d3dee15f12befd29f37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1385 zcmah|%}*0S6rcSrP0L52d?^}3i~&QJ5Dp%UHw_#lWHB)uV6wDRy0qPenJFJPPQ*kH zJ@)98|3*(XVqBw6x^c*Yd*%pJ0s=my_@SBJeN^wZjGo)VS z7_dq~s4S&I`ZCBYQ+6xzwB=E=z!&=aD%YOeQ_4b z($bzUn7ila%PcJP?7Q7cxLb@F7P-SSWn%Rl`E_85C4dXk9G!b-eM@EPZyV)L6N}B% zy~ciHyP2A5JZa1{Q{(mh`u6pZl}UcE`4mL(83g>vjZ2%4;$jRTgD{Q|J4~X5UhSZ1 zwCpaRAyNPrOp#_m>HpL_=>4RhgWEd%r@Epw|hP?!-bELNLq zfWj=}7`hVJVANLTjuYcQQW19flZNg(< zV7PGLhx-wT9iOpKcC2s_%bjBpYN*Zo7L)Dv$CvjfREg*9XY_(Uh7b+2x&YUPrfFYE h_6J$Mu@hRdk^3^c@_BaUhQQ)x(z)IFMPL#C*dL<R diff --git a/infrasynth/scheduler/__pycache__/models.cpython-314.pyc b/infrasynth/scheduler/__pycache__/models.cpython-314.pyc deleted file mode 100644 index b33472fb41471bf9e5902bb6f271d72c3e1785de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3934 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 Ang9R* diff --git a/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc b/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f517a0825a1f07a7272d21e6b667ffbc174109c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 369 zcmdPqRcUxIXMGTvef&P>ls%<1X8U zrt0VB7bTV@X6pwO%*)q(k})( zvska7@)n0pZa&B@c11iu;~9atSOrLYU}j`wyvv~UP*8lbL<8SVe$noV2A-Qx9-l~e KMiX}tD5L?MUSpa7 diff --git a/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc b/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index c19e55d0a41d250bf89d57f60150c79477e69f5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 830 zcma))&ubGw6vt=w_a@y&BnHt#h~gngcP$NwPSg2*w)I(WiyO}mWcf-tVYjcWT zRJ_!ae}n(cNULudNBm-|a6Re+cGVMREX7L~KC#=91)Ce6M!q)Xn? zO;ELd2vC)n-ig9~(n3rb2DDv;%({;bf}~HqIH3qvWmas2ZPZHosI?Q2-fjmSHxOl| zjN%3jXhJex6ru;uP?*q89A#9coRU$2%&qXs6!3D!en3zZ3=qqq>=qdn7s3T)8kv?F zU0R&Z2m@PHi{)G0AUcfg1osIRBgYC4tb`GvPY*D!aUA2Gji-$ftcpYRi1$OVcnnw< z!4OoNX_$f0VTbTBFh!WMJdb=#97N!BNn2wsG7=Atg>H;w8yR zMB6K(MQ1+hkNy Zr@Qa=-tL`Ql{4kWM3xHDi#!%j=@%|1+-?8> diff --git a/infrasynth/scheduler/__pycache__/views.cpython-314.pyc b/infrasynth/scheduler/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 410cb4e76d449c307def374c2feabf0f838100ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5241 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 diff --git a/infrasynth/scheduler/migrations/0001_initial.py b/infrasynth/scheduler/migrations/0001_initial.py index eb5a251..500f258 100644 --- a/infrasynth/scheduler/migrations/0001_initial.py +++ b/infrasynth/scheduler/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.db import migrations, models @@ -7,14 +7,16 @@ from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("tenancy", "0001_initial"), + ] operations = [ migrations.CreateModel( name="ScheduledTask", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=200, unique=True)), + ("name", models.CharField(max_length=200)), ("task_path", models.CharField(max_length=500)), ( "schedule_type", @@ -29,6 +31,15 @@ class Migration(migrations.Migration): ("is_active", models.BooleanField(default=True)), ("queue", models.CharField(blank=True, max_length=100)), ("priority", models.IntegerField(default=0)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "scheduler_task", @@ -65,10 +76,27 @@ class Migration(migrations.Migration): to="infrasynth_scheduler.scheduledtask", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "scheduler_execution", "ordering": ["-started_at"], }, ), + migrations.AddConstraint( + model_name="scheduledtask", + constraint=models.UniqueConstraint(fields=("tenant", "name"), name="uniq_scheduled_task_name_per_tenant"), + ), + migrations.AddIndex( + model_name="taskexecution", + index=models.Index(fields=["tenant_id", "status"], name="scheduler_e_tenant__28f692_idx"), + ), ] diff --git a/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index fe0f7c4a2b61876abcff0a4db8a6c38e047ced92..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3027 zcmb7GOHdm}8lKT(^gxIa2rvSK^}uU^EkSU?CQjKVBE)v$pdCmmNi9r`W*RYOG(&n? z#Q5aH)}C|P;^;kX?afDyKKdxDR3%kkLRC(=sbVVE?B5zm$Q7~WP({z*-QWC=@BgM- zi{arQ2Y~6HbaH8hF$_A-3u8mb#C$ih;Ck#Xg`eUE83p2kRLR4Jj33$tp$)K^h%8 z#Clo5$MOL>rU4saT`}~hh=z_3E?PE{O{I*ehmGwsNO>X~$Sje4DpXAU`wF4~0s-Vh zh3xC+@I7aEJOUNbVY`!nOpXuPyFPW_(p6+T+atYg)iU?=Qii8K4ecovgHVrxOK`>= z{Lp>|bX!(bq8}mZg?JU_%Ud)Q^T6@shYBpLlVj@FiZW4(2BP7vcMJ>mLB@^QxkrUP z9T}QUeb%9Kjt(<&K7E4J3Y*igiVOr?v@6mzD%eCJ6~G@NQ`5~7_+h6k9%n`K0Ui0l!UqV;`<6`{ z!dVJ5h`OoSPm{X2hZXzSB>Qr&x@_0doHA*^GG(|KATAAIWUv-H9dr?kj&;#k?m4D@ z3v1AIv-EW6=0!38g)7GcM0->Wym<03!pyIm1mPpap#BFPi(xbfLp|#-!b~VDW(5$! zqxO?|!70Xzuh1b8q<|}?2_dOZatwISXIE5E2avtx7;J4%(Txg5)VJXnnJMa*+Xcxo z4azcHgCKFRL$drmL}lE;Y($p#bZiqtH<4+?ox}`Sz#q zh~21|U2!z@iC$WRk7R?Xg$DBb3htN>w>H0YOpm6kpj1P*3E(ZuK#J+?gcvm?)Olr? z(Xy?VQOCnJdO{pCxM5+Wm(0)M!xOZ}idnL#ODlqkS^tn}+?@kLd9)L(JBKJAc@>``^Ap1xNV*P4;O zrC0fSx=vOAarG?&`agD#fjju`Fl9d}?F0-hH_!8L1ddPq#9coZ@;op6 R7;K2?*JAoNj?LOG{s)ZR`-%Vn diff --git a/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 606cba263677ea86bae865b6032304b14ab22dee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 189 zcmdPq_I|p@<2{{|u766{Vk%pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7haoROMRnv+_j zpPQLplvt9PpI59OAD@|*SrQ+wS5SG2!zMQ$q` bool: + try: + params = inspect.signature(func).parameters + except (TypeError, ValueError): + return False + return "tenant_id" in params or any(p.kind is p.VAR_KEYWORD for p in params.values()) + + if accepts_tenant_id(task_func): + kwargs.setdefault("tenant_id", str(task.tenant_id)) try: apply_async = getattr(task_func, "apply_async", None) if callable(apply_async): @@ -73,6 +91,7 @@ class TaskService: execution.save() task_failed.send( sender=TaskExecution, + tenant_id=str(task.tenant_id), task_name=task.name, task_id=execution.id, error=str(exc), @@ -80,9 +99,10 @@ class TaskService: ) return execution - task_scheduled.send(sender=TaskExecution, task_name=task.name, eta=None) + task_scheduled.send(sender=TaskExecution, tenant_id=str(task.tenant_id), task_name=task.name, eta=None) task_started.send( sender=TaskExecution, + tenant_id=str(task.tenant_id), task_name=task.name, task_id=execution.id, worker=None, @@ -96,6 +116,18 @@ class TaskService: task.save(update_fields=["is_active"]) return task + @staticmethod + def _prune_history(task: ScheduledTask) -> None: + """Keeps the newest ``MAX_EXECUTION_HISTORY_PER_TASK`` executions per task.""" + limit = int(get_setting("INFRASYNTH_SCHEDULER", "MAX_EXECUTION_HISTORY_PER_TASK", 1000)) + if limit <= 0: + return + keep = list( + TaskExecution.objects.filter(task=task).order_by("-started_at", "-id").values_list("id", flat=True)[:limit] + ) + if keep: + TaskExecution.objects.filter(task=task).exclude(id__in=keep).delete() + def get_queue_status(self) -> dict: """Returns active/scheduled/reserved task counts per queue.""" from celery import current_app diff --git a/infrasynth/security/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 15e08f213a944b47e6c2ea03849ad8ee69312d3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHJV rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{>Uvb diff --git a/infrasynth/security/__pycache__/apps.cpython-314.pyc b/infrasynth/security/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 70380254158559eebb416f93fce5ca60b724a38a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1056 zcmYjQ&1(}u6rW8t$>yUi{YooXx>grQ!DAoCE)5I zfDt-D*w{yVhBQ{uBB~*rSwc8_Y;0y@KbgViJyg&A)@t2Q#a6$+69yfx>%xz}wCWVq`w-yP0-L5EWj1@g2Zl1MdSB&pF!Thkp{@Pomut1SSx z!}nqMBjYfoMtdd)XK)sqIEVAN@FclkMc9ItiyWxcl+5fhDhHfl*7bzsk+Ra-!`XBw zd(Cb+4ijRy2{W9sKza_9UKp&~6!egL!$jTKZLQm_!+ralMFP<4w>x{Sw<<3pAz80( zDl?!xrphi84)^*xhbp+NLkGT879?fekVgmgthzfvQ73LuGHLeiyen9=?W`>ZC$A$F0fCV93&n0vbpgx2<*e70w zlq%=bHuL$s#*2gq32)*MDkb;Y1IYx$&JyDIR0!8|q{Dol5ZHjJ2_uB*joapPAh}+d z8({(vt04Y;f-%Q;o_;l}l;EQR5e!_C{5oi{<{vzXqRgG>O mbrEGfiznaWHBfbRu>j&SYZ%4_s{P4N8>P?ht`Yp*MEwVP=q4io diff --git a/infrasynth/security/__pycache__/models.cpython-314.pyc b/infrasynth/security/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 80ca907d4aea0fbaf4ee448499fa79b31c31a3b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7159 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 diff --git a/infrasynth/security/__pycache__/serializers.cpython-314.pyc b/infrasynth/security/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 794ef72caa4d4dea33f398ca8eb183c6f79adb5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4945 zcmb7H-EULL6`yP0d*jd8**I}_lO;|TQcOY|AOu7yyX7Md1+v+@K&7&9bz@JGyScH= z+;Jdmh5UfFAg#1K_NhXBf;ajf^s$e`MC)j@l`8e2>f5$SyM1fVnQJ>v_Jx~ZKlRLyvE|VFBD}&^YV)!omX8hbg zqjGgd=S)f%4cUA2SzZ>wAzCx-T^!gT{oW!%R* zVAX(JRTtg6R~^4bxg~sdP6Tb&bk|%?S6-0WbO(#TmV}zOR^U{wQ(6?^9CfqIE^@n& z7vUmhE4J%G-DOdPY9(OEO$SB5G;x+`ijZlp6mlg8>k-quTe6(mNX#@BZ02&u&eMFs zG+BpS0?^bE`hrcJoGaptS~;_jch&$bdwNXMwWMNf2L?&3NLmCc17t);9-I&qiPMk8cF}*Z_>N11NAuvoCa(z(x-Wyb=s2 zdw_ndAU zEzGl~&>~(InltCuJciPG&CJktL?8?5M+0F8;gmuL^}AqdA$q@PGf=d})4`UEqO)Xu zDBef$0g6@xOVxY?qey&1lRc=(&pb_zz3R`j&?FngCHJUn2>Vc=`ETv0YXa3Z;i(IU z-%4GHw(5#|F6JHe)zemAJ$u(}Rv43Rtw~{;R2VyiXCFZ^j^ZaE_R$U6@>}cXD1h;{ zG<4I8y1DS~x*5fwrS**i(kcWUYq>_CxGiC-?@G-%wB0!$b&hEn@5t?)HuQxE(RH#wt-jBP)A~ zJe%G)TMSUCJrHP0REH60tk(4g+zwOk8IU9JZX*OTVT5_lO~QMP2n4*Sh@!J&{(``B^Vq6jWgr)Y9|pQn97 z%P!eYPITnxf>m;OT45&vGYuml%W(?#%si|v0*u}*fw#$J>me48j#lC)B(QZJDrNEo z_Q^8WjH>t5U2?6Bf5=vb+*HmhDuJ{5W~DPp`x#<7B#vkbAHrHu*(km0xw=RO^}EAi2^_NRS+W zbI;Z9S<_PWl?GuPI8kQPexccrf4HKYRbeU!r#@(Eqq{uap!CaS@qN^t1;#f{*x^7+khZ#A5%gj27>!{v*c(Y|VQxDp+Hetjc) zyga=b>ZyhXE1|*X2Vb4ZZG>p~!VaXrnm$)apL?DDc3P)JN2WkYJEQt5T0^y z(^>d6idw5}-GNF|3ZB8(*xI|-ZBu&o_Wpk-!PZ3VYGKjNH#sq&Idl&)ODa>@TH@0> zkwM#GGay6&Ga{LM2a2Gydj*zjnbO?A87R<}-4qB&PGcW&w!zw!M1MmW>#IWB?ccr@)z$4R>=gK#6u)@k@_qU_`SDciZqh5|2P9}wXe z{S6`vnk}qS)HiRD H$o>Bx&*Ju!x+m^xj!6Ni%em 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 diff --git a/infrasynth/security/__pycache__/signals.cpython-314.pyc b/infrasynth/security/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index 056b3784d7af603408b3131e737da932c76c20e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 566 zcmY+BK~KUk7=~M>hzJZs5fc-mUL;0#@!-LDmV?INS(mbvQ3mVMb|?pv#j6*S{R;mL z*ER8Ayy(q@KVTuluK+G-~qH@3tT|~ z_rX0p0B4CUGAYthw34FL6lGJimZDsW)>D*kXO2Ka` z(xuBboHZvx76~h zK?py<;b&zhs)wa`vmPCXdaQ51K81z2x*MHOKsm1MMZF2AF5Dzku0^@97|*tsBGc8h EKkxdRdH?_b diff --git a/infrasynth/security/__pycache__/urls.cpython-314.pyc b/infrasynth/security/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 66c804bdd371d588a9b160ac0b6f7507f67f4c66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2300 zcma)-&rjQC7{~44*l*&Pp9usgKhmxwfhF!*y3)d`p-o#@e~7tNCk=VX1e1h>7`=W2 zi?;*YWlXzdr*Z5tyXCkee?o+j$Sh4e?6kwAvQFw{hrMqc6E_-BE#>j^VW!!lIRsGMI$=pL7piRdscc<3}UcPOFb*z z6pPpthqx3#Amu~8lppz1JmOOU6qur9CPK}(S|ULfRwtZ=+INYOIf;?Hckqhj!qork zSecjDH_&miPvYJ{2V}nlUekFZl;o4a>ZCo1+v18};zfF%wzE<|5}b);!u59H`dnOp z2RG1#8+36)9bE8IE+h@RxRDMnEC(*#->8cl>)^(_a93R1LcU-jaWfrURAw*zUt%sU-oedw;oSS1>)_s&=?FRL)9zDd`_37g_t;KF}n)N#%W9VP~B2W1te)Dq{2973CjL|!fHcm}Yc z=xR~fQ7tdTkZ!RV(pa}R*lejAY(dN9iXxs`(~;$EMuuO(vm}~6Zo-fT28Tnu6VW0B zb`0JTkpMt-3#xinB%Pa{5op$(h)rkJbY8T2GI;CWX3$!u<=@;=L|sLtUD5J3?}iVj z61F=)s(UKTZI$heXa($W-4^r{ty^I5Z3NG3mkfVfW(0^}Gp8#X1r?9o@|s-22?U~n zYFfj5S+wvjZMO+0E}{XYfYMn-#J@s8EoN{(--U1V+toxn)7#S9Q;CMqQrNLYJ&q4r z{N}b&%xK9HEa;HDharQFHH`mdw7v!LBiYwrJ_(zrONU-k^zPxar9=D$p&&;LAjU@w z`MyA&m;g2wYjb2rnVgQ#r^PAyI>9Z5tZ4Dr;)9GUMg3>$C1xn=6EUmpsN#+Wl|3ad zu0m~FO(R{*74bZJxromUId9^IlFqBeO;;X2*>nl!P+26+le=XLEbVBUrGk1N&f?bO z4X*!%?Ta3orhlid|3=+-#Q@s#io)i+X@Zo0; z_w-ix>(M*r(84cYpE3_|tpS1!5IzIbCt&*dOf_1M+%&SQ8q diff --git a/infrasynth/security/__pycache__/views.cpython-314.pyc b/infrasynth/security/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 81b2c5847ad1b1b605304b50c828ce061f53f31e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19786 zcmd6P3ve4pn%>|&cn$!91o#p`@%<1eOO#|;Pg*1mS`+}o!7(FU=B#ObL(v{7s%ar;zLG$;lkZeSf# zp=h(%OybU|mT0Tk8f_EXqU~aPbc?tp+97t3Jl9k>+9`HMw~AY%U1C?XTkMYZh&?3F zJ=GiS6Z=TqGu0m*5C@`z;$U=093pAV)Npj0xGfqHBOTOfQztcA847Wvk?N#aZ;)br zr|q-fP=~7eJ8Dwm;!{M@50VXEAi0A`20*g$3nX_E$tI8tg5+}bscKAn`Ys|F0?Fnt zklalqTR^h)v~4z{)>Qq4)LivWjX^4}#;dYJ+FB|I(kek2Q2r16zKM zkDeJY%dW(VxXfk5)M7&9WX}>O##aQ6kDt#bxwLFaXO~hL*$O||6>-2MThFI1r80tS z6~u(NBFOZG6n9bJL}9=nTN8_7Dw`QF%7L+JrI0e2NTs=??49NK^C>}qlyNST;*uaq zkEPSui({F~GBYLUNh-8%0?vH;?`ZA_;)0 z)0AjnDbe_HyJ%{nveapF#2{KuTS09NtTAF>O?v>&@M6uus$$#fN?3FfbX^InPQqT1 zu+=<;)=4;6yXcHjqKkFFAIb+N?s}7f12!3%`RGAKH!KP-#3?Q$r z0cs-3n}%Dew@;8U>t$HWo(d}FOfgemrSQ^A6xBv8ko5woUG?_~ zPpxg4Vb)tMp~hw#)L8YGwT@QCh_$iw9vD}=U@RJy81&h$N-P^Q!|t7*xad^`DlD@;>ZbhYW|e$ zK63o{(Wwa~VM1mqx6%3e+2b?Qr?iB*v11c+Q}YwDX*zL9_KzJoGBGz7uSm~8CQyUL zMNSanA}+bG{!C1=6LZlT5jU@_%8JZggKXn6i~QxB$fM$4@sSlXEUb`<&aR{*KlcJ` z$%c`;7J%`@rFdeA!$DbG;bB;pMG=sz5E64PAuh`dwmyDhZi0=EJvT8k zFH-|%emf4j8D9#&1EUVIW z-u#J!@&?vip^~fpeOG%q*m~>g&8y|`U@5$}7~cE#;0NJ}azo3l{WtfQ8oG)NU8~2W zh8^YL@GtBZw`apeHS`tw4oLok1=qnc({SVJjjQ>qYyRFs-!qbbU%|ESBifx`x=**Q z`L~q(eee7G?wBS2w!CdE*m8SMsdc2-IwA$P=Ur=|*4xkht}E~UDAZNx-uw2U_r|5r z3wifiOHZM9U*W(^VRl|>Igw{{2`8kM7$lGag?+~hFT|zLH=sy!ccJH*!u}Tub7!RH z#k^V9)yvGN4Y3( zD9?Plae(qRZ%{^iq}(wC{V0dG!E0N$K*DE&AEw8TTAHF(>OCtH4Vm5x8X&A8lt}Ri zq4WX?swI@_atkD-Lg0W35yGe~=*F5H6KmFTObZ&>WG$>!w|JX@yi^uuc(!)MjD&t^ z#OMi9rdp{5jiSzKR8p%FjCCN8b%^BxLJ_%Q2G-6x)K;@j)j9ymUE({F;H~6G5EZ4A4B9!vs1NKy-rv%88tciDAs0 z!QeOqvJFrZ!K4h>AdGSzd|`W96^8!A@ithShKlP#c4b(m(AmM{+SJ^fy_M2UP|fzpITmoiC0EQbi@Lq((rR$l^vYEoBeiWwH?dQ6 z^=V~Y$4v9KoU4^0ZS+7J9XU6&(G6{MYEm9@@6zs>g`nCPmSMd*b@#@+vzq0j%7DFU z0q*UK8DplHnFSj`$JOi0TVo6xs4q|0ucqd}enABY`?cllzr?X6oLV_!sl!XH=g5Pz#Jrm{0R)sV-Ujt*$zLC0f9uM zc?8P%7a=y_(*e~2W}U|1WegCvKlTTcXtGvonA!F(g*eek($^RNwgHM&6 z8BQy~CouU828$RFq{F$rnUlc~U_RC&H|P zYMrL-(}uOCt*d*brfvD>)>^mRzF6wmS?t&;wT|Ye*O-<<>-NHqsrN=p%3q)sxoIFT*YAT zE{D2)b86iLvA+Z+gapQbeMFl0yyQPxa2>_WX~SP`cq{Z~Sfw{Z6?!xLnSeU>?o*!e z5cLD<;P?>rgF{9L|IE}hVWEB)a*y|$e%NQga40xoH2uiJOax6oYBE4bCU?#917M)= zpx=PkpD-vNZUlU4#IS&CHo5A$+>9zK>7o`; zA<3m~O5}_S*miP(V@zW4UAU-Y3m>&Wu-~BPxpP$YiFXY%@Cbyqy9Pqn1egreHG zy+55eeN{=n1Z?$G8NxBPi!tX->o%;}t zmK(3-Ut8Vt*8V@(U+Ovx{|a4)kvhijaD}$fcVeXjr-}zo-EVlQ;CgAz+bwx}^Om)6 z_v(pK&v>zC{QEEdhlO_+q#jlZ&lTwQiW!tQuQ9<7y@5BAx0Y@$mA%2=3$6xMFWxgs z%{$B9rr)!znpd;;_eAf9rlio(vbV1=aBv-s&_^bl*Rf97><%*IcY%SsWjan~1-LsL zaoqJ;Nplc)#pB;xNu+W5z#S8hpH1>B8M&(U&Y&!Of8Cw9L&%-IFa1CL@0(+O&NH;8>`0vU#{?X zAo%p}!kScW*?POBU~c-U#rfv0)!nQ8_bo#YjbjE&=#h!C4zH8w!$~7$b?NeHEgNDF z1GSlZv`mOS&||(Af|`{C zl@;)Yeni6?)gw(6u>q$Mkv3ggz6tWR6{0Hi;#0HgU{@2VdMk$NB+i+kCT+#27Wx`B zS)hzYo~t#n6!yPruCTB{!Wx0nn_`v`ZR-LC& z^}HE;IwfCE(bpsS`tnwA8#lX;=$v>I?&U9&XvN$>^1)j^>%dH)y*3n|?=)KtY z7QVYsI&i#r;JDN}TcAVbkzF^K>r5duRHTOqAck)Dmc6aFPnEq}3*DpZHZvlg6%o%` zLA;vjXod!n>3Ejd)%6$odef2B7Dn7V)Cderf*Jy&?aIL3n4#_jtuxiZB1O1^t7cDd zbKTkmS~e`;T1qZWP0*24Z*q63)BlNW)<_1_tC1(IUQ5hUk49g*cA&S+&6F2cLvFR|8`Eu$$Jxzkz)eZ|FK(-qUk5lU**58B_kNf2(UUJ8({ zTK=|Kgt1MR+E=jq>ykp2qNxRScq!Hv(>eG+*=hb3RCuLvCL1Q25l&^opo>f3Fc%`S zc@ehuKl9-RKW_A(-yiMQ$Y*ZVs6~MYe--MOM_0XaAOJjMNlq9D@c$6fb=E0vd?*lM zg&m93*~>h39?y7G@Pd@%(LW*EvS)}BUM5@-b~FHn08$k4v6rsDe=uvaliL7#tw-9c_8oA^b6 z|0fu;Dov3MaY89IvsOVRm(pzoTIKe~z)7F|pP)M7|3U!7%@?}WakHc3>n{4hKmN4j z+ncvOU>b=Pc2M#kD!2~SdZeuq)0a0tU>fxPd@w%=)Pb-6)$6a`-d$q)-)H(kV)a{+ ze^0@+XU)?odAjnZa&ud$d8pVtBsFi#d)5M73K*6Gdy9d+Z(sc0Yu|mX5ZEgPrmx!p zE8D_(>wUVp+|h-M9RzD-G@|4(@wNn~_)ZXaYiNxT2y$ z&N6SA)erfxdGJS&u{gN>(>Ux?S5ZxmXi;pmqgt9c`w>QMV^tDeF*6;z+w z0cRTL4H*q3-|sFZibI)mpwnIXDVbEmu9Qf#))+zEk4Ytj>)(8yw*4gjS=k zQ)i^>RyWNz=>}5==@;2NP9;AJ05s=#ZW~&FfYF2$Mdso3k*y-Z|5NDtXAL_h$B1LH ziiHuLAk;sGZ2k`+089Bd;Aci|AP;~_i3O`{l`H;7H`YQ>2u{OA2E3aQ9HE&leM{9MdM zdWq=08Y}f2F7_Ojd;pEO>-q#FWbWUuk-HkLu9)Vqoj3+=NBq&!suUb7TsXhtS zAA|!N#hAe-k&E1E))v?<#*C(NEMwYf5V&6liirr$ySSm;onYG(>F_GK6!NXf6NGFh*459>%_d!S^sAjKic;1k?cm z1{~+RLMCjLC&;14rh6sl|6m!kv93|;X4>xCG@0)${Ym;eX)>LUJd5kYri-P)0c2N~b4ShA%mI*2Uk0)aWw|y%&CqV9K!qZV`A8IFwXtQa=fQt~|6%X8=9@Hny@IFg9T1pD5_Y7pZ2A7Joj7!WAb zbAtbfsa0a|FW?r)5NUl<@s2;n3-AP(GI()*QldMR?O7&%sAX5MFJ z$kPr<{=)^=;j+K6q-qm!S|i<=@@XInhu3sK0a4ZsLCv5@rS%{@eHx-@xGSVenfRp#BmA zPx0EX;_bh}0Qo+lcKpA`*nh_0O$=^g@Y@jRee!=F6EVQKFT4do#V0>dXdZmbDeqD| z=irlv*u%D3r+b5CKf2W+6Jieswao9+Z(q4@dH$g>VA+KNM-A&3eK@R15Qo1*$%Wz& zdl=F(wc1$rxaRW&rm2Ia^A{4Y{L^<0li zJIQw)I82)^puyP2O;^2xcU3>{S16--eZN8(%~z>z{lbYYZJCD+1CxG3h% zrM^LBc^Sml7l-WX>+=O}wcLc`oeejpuTPg;ZADkx?UyCjaBa#Tq<)q~5ePlEH z9eFF9F$KEYvKlLdh9zcOf!?O5sRCrqxIQbyfdNiBrOQtSts=@`fJ!DzK;&~a z5>1w_3M@hFp+mzJ0y;KJgk*kfgqYG+talOqYnM6=ALSvbKBUxTXV|e#S9OEn^IY*F zecCQMPCMZnFBfYHQlcBb>Vhphi^u@rcv&j|jtu~Zd?cp=&Mp{>&rI7=$wi207d9UL z(~r>P1gOz6zO3O{)uVb^rs^v+$iROHmBZ0~JdvRIJ!tsK0=`B(b6GY41c6KCBxI4* zr&lq{T)3SD=i~Xy;82%aYC06r=RJcjIt-<+QEN=&jjPwMmYCim)4RF?)2zfiRb-x$ z7&yAQ^Kq;}a!W?wLsDG%;x}r@8G&AFSW=GycTz8FJ}1!WFOm&w9zk==@2pZw+q4oqg(u?zd(^b#^2_DkQl+0OQjAal@$rZzCVdlZJODO93 z#5p*OQTf(IHbn6IPjF+MO$mT{(nHdxYLMRoPum8r~Bf|+$DvKo`PIuzrh5R57?AN<|RzUg8cu)0QVu`lM6h{ zf}hRM@GYvC$iQbR*oTU5N|_iQIGq5WW}L6=WXNr`Q=>Nb08WlHRB?^^34P!rU#R5U z`o3@L>Qj=hCvRQT?srJOUT`EewY_=r_6xT!++Hp;^ufunhW1;BZXPN(H2=79>*{W) zaS*=_Aji}Di^2YN8x`n!M41DQJiYFqLg6=DNb&?5|<41^Cb8v|5+;V?uLAbhflT#J_B$h|pGZmAHwzc3ptqn}t^ zme4u{fzmCkauNP(S7pLckC27_|Hr^80s;;)tX1>^4*FOdOn4eT-Ga|{RTP|tf#9z| zBjNj-^vcrT7#3m^KpD}kuQ;=`IlzAeX*v+(@kPraT_aHz=HP;$1zzk;)U&A+wKH7@xl3a$w_ z*N;Bn|3WwG71&=_f$P1zUtlZSF!&e)%?iU$!n5b#3%bUzhzY39=-AcXkD8|4c_^Q)nsW|ryx-Jh4n7Nf9=9S#p`nnqq+-F9Tua$ zc+qy67VW1Uz*C&85j0a{E20PVlVL3|Ev&MOe2T9s=&wO%YqZh~jUu!3HHdsYh5}#q zR=QV@qv%Liopo643^dhL*)U(iKRN)emNS-u0 zzFIfy`(C5h9KQ5zq|0ke;Ku9MUsv`Q?o8gBmYA^;^L&wc{=Hoi^FoO^QDja?%!>v3 z#mALg8$OoQ1!My%-h2VMP~X2UtDY%U-BD208i<_*s+_rN+O*Q&t>`Q+@_L;miKAnIb}Io>>AC3cJ9(^r?;+lYVU-stUOW7W@ySL#Z*-B z3)WE+G>FFlXQ%Kq)I!$Lb@&>sQF%4EzXaASYGAKk15x=-8a}ZaEW6h!2$XhXRlg$I z=T`pAg{ppOY#IF5E^Yq&%BUYt{@90J{TiXQ$|{O($1l=3aQ5O~3#s(DM(Hj;(L(z6&5<-jna$ z`7ow8h!?Z)M{)2^^Hkf>2|47r1OG0{c%H)pY^lYHdXkgZ!~snlIv3PY?W5kx1mk<5QZ6F97m-hZB)l|RV$!32YUl;DIA z_F)Z%|4MoOJ9Si|jy|Av7O9;-p@tt&(+{Y_5cvu9^q=FuLAd>ZI{b;zYB2tS!oWir zh8|GQ7O7_+P-8!JwMeeEYxWK6OGbm^&Hj>qpy(gipzv=T31veZ00YeB=28sbF3O5@Om!SnK>nr;EHYm8+*y1tlE}I=Cb8FGu hN+Qigb2Evw70qpBvrEa^u!G>n)-NgjF-nuc`hN=MwKV_$ diff --git a/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 3bbbf07268f47f37eed43a4c4a6fa34190c8a2a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 184 zcmdPq_I|p@<2{{|u766{??+pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= ym{XFRk*FUZpP83g5+AQuPKRZ7J3`tB0kSt44v-u$wC~GFcZMq@Ti9JaMY>#_q z0tD?tR8?wrrD|970bVMFcBQ3n>|6Jt(r)|Gz9j@?GfkyRT^@J~3M!~C?KyXBC%Cj+ z+2`JS?z#7#d+s^kxpUmttNgpRU!Xzh3JkWsYWE_EUoQ&gQ98ef!8W^WR5=}8p zOt<6)wLMmot;U2(Z)h3y_V-d$*XMfRcT=a=W0|UUu{oW_l!B@C7)2!?D^lH5Gp42& zdNUlTjDIYsHxK{q7?5+^GD(B^27XRR7PtqM7MCoqEGvbAZYriK%htX(?9#h? z%nxc9dw{%NCa*})>3MY{VnTkWQN)z(g(YTjvATWLNmq`9C(vfg%Nq= zN2#PVg)=`KAD+;<;Q-)m4d4xWA#211%Ug4FS<7020q$XWvfA97mRZc1OO{8yRn(|z z$cjl@L4Udr$$lV~TglDq;8P3v+YJ+AgFPotpNYGv6NhjE@NEMyK?hHi7t~u6PipZ6 z{U*hGr+5KYgFeeV*m8k5xL@1_DyX?RdjlWadkLU}X}fFx12EaZ6}nB{xv%$_+IOl z+Zpg|;W%vp4d1|;N?eiclQ8oCutstq;z&GDP7;7Rm{&O|rVVCJOEmP{BEp&FDlFz_ zRZ7w0SMIZ0PkvI?r4u0pJ7X--tzAdc2 z>R{E^2CKd`*_eH@N(8_}LeO~_V%z~oT_*d4+`TJ-KLv=jdWV0;_FCDK8rYw?}JW7m~70(;?Q)fF$9qZI|8JS1bL#NG&E5J)j!B_V(;s%nxU4G@-wStNMJ~V@M+b@RwrdRucP6?>7A$*! zM9D$G>JFlj_xXcF?;wB(5CSPu&Dvl$dOVcu^vg3B7N(TzHbh!ee{#HNZ&?8SxIh*#2YF#M%NpoUmX3i`#0Uc z?S0&M;%PAQU2xys^LNg#PJKCZe`c-o)Wh_{v9m2OO2NzP!OI)L zi50Qj7<}9qg=v3}b*{J{iwDbM^Jl(Ke5?6YdByiwJpI%keEqVO1fnJX@pb?4mxOb- zTEWN`5B0wcjKH6@eb7Vx*b^FZ@qco1K;MoUAAsmPQG;Ws!SDmoqtDTez+Wi|9&(dl zqev|uRDgpX10i}Gh~+dFi#he($Z;Bk4V=c1!{v;;AVBUVH!E2I!|sPeeGsH+eunEt%&n9nfAJb3GsU$m?gZTnb=*lY3D7t^jlkJbV0b++yb%~(87%t)pMCtv z$M=R-&pr%2={WP`K!3?U@W?+<4o5y;S_{N59V)-P_~|IS>EFQzhQ|_RxuE3njp~u* zyq;amVH;krKV4LEl@Y%T=tB)NrxnzK4#N$yoYgb3Ob?@V-a|47#0tT)8eY6w23|HM z)n?)328cKBdU$8lTuzqboSpO_Bat82IS&&sMS|FCbOCw&3$Vgd;r&+vJczgORs8B0 zq&=4HUbTZ8{}X+gorO(|H`@1h2 dict: - difficulty = get_setting("INFRASYNTH_SECURITY", "ALTCHA_DIFFICULTY", 10000) - expiry_seconds = get_setting("INFRASYNTH_SECURITY", "ALTCHA_CHALLENGE_EXPIRY_SECONDS", 300) + difficulty = int(get_setting("INFRASYNTH_SECURITY", "ALTCHA_DIFFICULTY", 10000)) + expiry_seconds = int(get_setting("INFRASYNTH_SECURITY", "ALTCHA_CHALLENGE_EXPIRY_SECONDS", 300)) challenge_id = secrets.token_hex(32) salt = secrets.token_hex(16) expires_at = timezone.now() + timedelta(seconds=expiry_seconds) @@ -33,7 +39,11 @@ class ALTCHAService: "algorithm": "SHA-256", } - def verify(self, challenge_id: str, solution: str, number: int) -> bool: + def verify(self, challenge_id: str, solution: str, number) -> bool: + try: + number = int(number) + except (TypeError, ValueError): + return False try: challenge = ALTCHAChallenge.objects.get(challenge_id=challenge_id, is_verified=False) except ALTCHAChallenge.DoesNotExist: @@ -43,17 +53,22 @@ class ALTCHAService: return False expected = hashlib.sha256(f"{challenge.salt}{number}".encode()).hexdigest() - if solution != expected: + if not solution or solution != expected: + return False + if int(expected, 16) % challenge.difficulty != 0: return False challenge.is_verified = True challenge.save(update_fields=["is_verified"]) return True - def compute_solution(self, salt: str, difficulty: int) -> tuple[str, int]: + def compute_solution(self, salt: str, difficulty: int, *, max_iterations: int = 5_000_000) -> tuple[str, int]: + """Reference solver (clients do this in JS; used in tests/tools).""" + difficulty = max(1, int(difficulty)) number = 0 - while True: + while number < max_iterations: h = hashlib.sha256(f"{salt}{number}".encode()).hexdigest() - if h.startswith("0" * (difficulty // 10000)): + if int(h, 16) % difficulty == 0: return h, number number += 1 + raise RuntimeError("Could not find an ALTCHA solution within max_iterations.") diff --git a/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index cad05370efea07a4d19904e9ba5d48f8a6b5c230..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= wSXz>yA0MBYmst`YuUAlci^C>2KczG$)vkyYXa~p@#UREfW=2NFB4!{9014GE7XSbN diff --git a/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc b/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc deleted file mode 100644 index 5889f9386e2d4e6c2b9c7ce1f342ff01622ea409..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3334 zcma(TTTCO@_0HJi2N=xG0xk;_0~?4#Fb$xkT}Tt6KtY=jXYis{f}PHeFUu_B8RyP5 zENB(+S0Yty^R;RF5%QJYuddok{oQ`_-xMUsh*eTm{pcSNs2laCJ!kB}#!96g*=No@ z_uO;OJ@-8BP;*lQ0r<~t^Zug{A%Dk#4^o9VGlAG4cZed)lR1G4JxGi5;#`0SJUTER zoRhfZ(ZTu9T$qP}78PkeG8g619@0j7i4sZ>B|I*cwekj!7Xe;$MH&xOxQ$9fKS?$I z6Nf3mZPwSQzOEKD$Jw-54mc5R1+X`5Lvjp=;c*E#S6Z=FDn_NpWRp zIx~NZZt3Nn%D7?-IeQz%8XR(ka6utlyc6Ytc4EVkc~A)}fdnxHF0F)=V1kTSH1Mz@ zB?ylwAs$^Kyg?Dc^oZNAxaDw~zwJ=A{o1r;TU&X%=p-GFn=Ar5xoPmVq@&vf>LfKM zS)eR$I1ZTaq!)DGCfo&Sa82awr$FqGtS~7I0)=o7_+6xjq{$Mo$}GSNjs$L{Gi^g#AIUKLKGnH1Vpyw8bG9tLHhdQ@Z{)6~uTv{m!yj>| zUStN}8o|9sw1T0o(=8`m*m5JPYFGwWRrhMu$7!D#!|jIw?U3X4u1Dst%sp{8`18i& zbKd_6d0+uomDrI6uIy z0QUT$`8Vt)YK%hH`4d-h6(-XT`CGh|wE}f%L%b4i!#+<2uP_5tJ;WdwM+0DtxGh(A zdbxaAue(AD48$v72|%@0hE>(!47^se47}#?JlQr_08YEozxVd%w?4bI_xe+z6dx@` zMm<|o5{si+^e<~d)QkwXN(l?8M!XhF%Tw5(K~?j1u4p11Q`HZOnpv5NtLm!39BvvG zwQQIMqjI*cs!RqwZlu5<85rMU-$C8o$ZJCrA*pp$v&@ zrm8*>+@=~PEW;~$)4L)TMSY0y?+gL@=4Wu&FM^ZjK}kxSHj_~E*PFwwR>r&+igK1t+oj^wJkR{>U zLMnryR|j7B1?!kF%%H|PPy|Kv3wj0}TnB!ojFC}syyCTjxFlY#VG0CwCs`6R7*chh z1n&s`4x4ZiHolBW=s^ZUrw%e0%XN?qC?O?0ft3j$gCSoBzMTarD_z(I@!mz2G4wr9 zBK`^1gyjyO`;(c>HJ`42eT)C6u4q+PR?2i$SNfE{GWR{S;@f4Gzw1k9l0H@a_FnX< z?}MLm^`~j5TJZns$x0ShJrBU5mwdYVRT|TR5}T;pbPrkfkAWw6xhm&VN|P^}mBye0 zCCM^Y84p4v2gL?ET+JOwRjUm$8R#a4kZN|NxokG0y27w4s0*%;-cHP08=7h4l8`J( z=pM;co8>j0&O)yW6&PJL9=K5~(KGb5Nq-h0#b4emB9X=ahmFlIA`-G_w%xncXR*F4?Os$p`J_DU(ksi326&~hpC%67=>WN5_)Me2ZEOxwgR%OKha^na=|wASyvXv)Aq5}?xd zY6=r|d%-Wa=+@e4H>S*YV{h>FW z1)eP)49y=6y|+K~-oeoOrB{?v|Kfq1-3@;kZ~x+I;#3So-x7|yuYbDo>y^i2Plbc- z(T^5(XU{|AYR9AW$LS+^a9*n@`9(~L>?#&P9DfprP$O- zy!(^Yqk$Xy12>N16Mv0Qc#}UkkZ+b^H;)_TqsDIdl^VPE29Ns2_xr~G_~1_ufB*0g zA0GD2)QX<;41W6XHxGaN;coaa(sdk(|1$PbY;U2|Keih?jEtYOT-zJ`JpEbvu;sO< z6Q%fMDKh!Zd4jZDJ0}91#Z&d>fjnJ`O`isk^KS!ps1ql!1XkMM^jFOtJ3o36ydq7V5!c<1FQ1p9hzC_o!y z_!4qE>SX8xofhx`?|6alhTt7RK_MnUIdHubpfAc6dhf5uRQb8_L-sCAVA=6HJh)O2 oo|7BTN$+zq^_<-N2kCqfiVI_36F}$J+l8r8=kUJ>d_86V1&-nCB>(^b diff --git a/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc b/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc deleted file mode 100644 index 02ba1f46b585ddfa41deb8a22955d8ad1f55f171..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1558 zcmZux%}*Og6o30^gAImmh*83a6AHyNwlB0*OB5vt8c>VtF2*O4(Xw9f!r~=66eMxF9hp@TEn0L1D@$48RT0ijzKo|J0D~Pzjwo8q(Lo@Y`ay6-PpFmG6!$o zYp|MCXSQiDE3<2}TE^nYjO%51cO089nsU?y+8BNbfcV9Mzz<{ zq2#$iUj@6q?iqBy&T6?egIa~P_H*cn7pP@6Je%zocv}&G=6Ck}WM`xP*yWVZcY-)G z=vSy7!!HA4#{=Vs-6sj{C~^HHJ^bat7YE-=wQkNdZ_XU2ZyzrFno57YcdDh&MX77> zL>q2t<4tY6rDdC1_WK(@Og^8Q`O$bb`23Fk^OSz9Eg#OEtB~w#B}STwktcht#N<(8 z^4oGNcfXmt|4eS>mX2~uCtCXOqtlf3^1KWBuC-DV&D6vT5V{6^EF_`*oAzUWKfOCU z4o}DXXFrghzAvEu4zgmgn2q~!Lii&k#P25jU(#^6pCsf_!*Ifx6d~)jN10>St-6cT zF+!@YNr*Ryx%~k`C^PUMKn%t_d#%ANilBZ%Ob6LewM}MPjzb9YtfwQ0%lKou>!ne9 z{CWe$pCx?*)yvQETu2-zb}MT*YW|$+MI58fzJ@UF|K%Le*AX(T<&=Mq4Jg~N8s@a!Wrbx;GHuVd7-9+ ND^FJc1T?|ge*w~Jb29({ diff --git a/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc b/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc deleted file mode 100644 index ab8d5e3c506aa859ea2ffea65f1fb9329144a159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1573 zcmZ`(&2Jk;6rc63zw&|G#Lb6NoNZGjav}DCsL+B^jB#WGwXKb7R4L79vpb15S?{_t zB_-O)8+g#HDG01BF0d=5iOy1tlNk!7f$`p?sK! zr_nid0cpW;q=jzF3y@n39?`-xC>i-vERvEJ&op_BINUOHZn@5qZrQ{H{n^jHth{al zW!NMJZ8bQ+!41MOBb-~#h9PqKL+XRW%K&t!`ZnT{hIn8#!sRjK!Wp=t1;!B%YBCR1 z5f5t$R21~av#z^kkvBHJe^n#8$y6|?>(s5Qx})YRmGXSav0JLq4N{5It&EuJw#94e z5}^*^DI?&Q1+(ZTbRly13cxN}@he3d)T9Oc4mAQ*seC%FpQ7yFMt?1)YG9$Cr6E`4 zxxQrsb~%qUc?M0Pm9Xz#m5+UuDk?{P{mEQajy-}ERF0ikd{hbg9+lvGff=Ntl`eaP zJ%r3*vF|imfw?!dhd!j#-;62`s3-VI|%uM;my z^=&^zpNz!xV*g3cL@l$kDhy9c*W5Zu*IlaL)wj}R>TVK)v$W-`Q=PROUYlRj8A-3{ z2At76rB5?rG^xc~X&v53x>p8EHCoV?lSf0mOaH^$)M>9lsEB<7Vi!FdyU@P)e*5Z; zhhKHZR`&*<#HW5N{ZM)wpL-Oadob9EFYHDCh)p~l9X|*t(NCpk6VpFOI}?}p3QrOj zA1AInN?iH1)k$RbN(UiyCjR}-cRN4fPHg_c*X`J1JF@ulD2UEX9wAA@j?A{_KIx2P z+e6s{S!iA{QOa-UGc)M-neogu`4?17M!X2_HW*`X0OPuAHf;iU2;)0V-4?BfRSe^G zi!yFo4sl#igfTW<17kV~DZO(TGY-FqfpyNQwbtZ>fzw%x4O?f-v$tnMtmUv8v*h1TWH!-CHuEB5WIeXKpcX$=UGffy-5l*!El>(vhC8X6!EFW zdfb|O-Hw@6Z`fpWo1eZQ!9)eJdJ`waNU@p@6i;=37PrS{^-XjC7i74}Et_@ULGKh( zqRLA`h?9Adk9vJwq)AwcBeMhuxE4v;N7wdI?ge^xAAN8X8kVlN6Y0MY%-#5Z0W2S- AuK)l5 diff --git a/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc b/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc deleted file mode 100644 index f557bf7af507f32b6b59bde42920433b12e01e34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1279 zcmaJ>&2JM&6o32SkAQ;tTYIDu;>;0F<;;!$pe=`_BdQ)y550vZYEjig-(=S&qEbh@Z{N&&@ArN)Z{E(F z7(E6!PW`j7{-p`7IV z8rfw30xBMoxCzo|gEUw2(mD+>Qo>Yb6=be$$$ST7p>1R0oI3j-;MWxHe9(&fN$5!` z98PZ~RLDjmD0jL%?l~Cf_=nsa)^+-v3MAL?@pOj6Mh$mb-2VfWZCD=AgbtExW}dao zJ(vJEhnCh+gv{2`NNt+S_njJYJ{16fTRp+!I@oTzhZT6VzSxd<+bZth<8x31@Q4)%o4t z@HRI3TdLgWu}_5{83i@-KX<3`hO;h%U?{fXdHM9+%?J6Na_y&b?cv4m=N`@N%q-yZ z!_30-u?x?}X1`t7GxMXB-N|=$Cfy$=-N%Jzlb60be%JnWthzg0yLWwe`rZ52U*=%^ z%*zp&y<)sNI054m2Vjg=)LY|w7JmO0Co$WT+I$V3){f7AWIg@RK=}w3>qS*0B=UNc z5Os`@UK}K$)=PxkO1v<$lnLoFE@a3e8pUWX5E8^bA^a2qs|q1PVki1UmzAw1*d^$6 zk`O=igb0`~eHw;@U=NQTS;R1Xxjx~V9?uViR)hwA^$i};&-UD3_DHU>2PpP&HT7J? zYj7r5_oA=jMj}}#8Ue+|OMeJl|5H>UZOQafrSRiA9GeRcQ>x-zy5Y;*!8OfAR8ioe X8OATr{0(Lg@+G74!2J{Oa~Sw9B40gH diff --git a/infrasynth/security/auth/api_keys.py b/infrasynth/security/auth/api_keys.py index 68c9111..58044c3 100644 --- a/infrasynth/security/auth/api_keys.py +++ b/infrasynth/security/auth/api_keys.py @@ -7,10 +7,12 @@ from ..models import APIKey class SystemUser: - """Anonymous system user with scopes as permissions.""" + """Anonymous system user with scopes as permissions and a bound tenant.""" - def __init__(self, scopes=None): + def __init__(self, scopes=None, tenant=None): self.scopes = scopes or [] + self.tenant = tenant + self.tenant_id = tenant.pk if tenant is not None else None self.is_authenticated = True self.is_superuser = False self.pk = None @@ -25,7 +27,7 @@ class SystemUser: class APIKeyAuthentication(BaseAuthentication): - """Service-to-service authentication via X-API-Key header.""" + """Service-to-service authentication via X-API-Key header (tenant-scoped).""" keyword = "X-API-Key" @@ -37,7 +39,8 @@ class APIKeyAuthentication(BaseAuthentication): prefix, secret = raw_key.split(".", 1) except ValueError: raise AuthenticationFailed("Invalid API key format.") - api_key = APIKey.objects.filter(prefix=prefix, is_active=True).first() + # Keys are tenant-owned; look up across tenants and bind the tenant. + api_key = APIKey.all_objects.filter(prefix=prefix, is_active=True).select_related("tenant").first() if not api_key: raise AuthenticationFailed("API key not found.") if not check_password(secret, api_key.key_hash): @@ -46,4 +49,9 @@ class APIKeyAuthentication(BaseAuthentication): raise AuthenticationFailed("API key expired.") api_key.last_used_at = timezone.now() api_key.save(update_fields=["last_used_at"]) - return (SystemUser(scopes=api_key.scopes), api_key) + + from infrasynth.tenancy.context import set_current_tenant + + if api_key.tenant is not None: + set_current_tenant(api_key.tenant) + return (SystemUser(scopes=api_key.scopes, tenant=api_key.tenant), api_key) diff --git a/infrasynth/security/migrations/0001_initial.py b/infrasynth/security/migrations/0001_initial.py index 57417e0..5d8c2e1 100644 --- a/infrasynth/security/migrations/0001_initial.py +++ b/infrasynth/security/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.conf import settings @@ -9,6 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ("tenancy", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -26,51 +27,6 @@ class Migration(migrations.Migration): "db_table": "security_altcha_challenge", }, ), - migrations.CreateModel( - name="Role", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=100)), - ("slug", models.SlugField(max_length=100, unique=True)), - ("description", models.TextField(blank=True)), - ("permissions", models.JSONField(default=list, help_text="List of permission codenames")), - ("is_system", models.BooleanField(default=False, help_text="System roles cannot be deleted")), - ], - options={ - "db_table": "security_role", - }, - ), - migrations.CreateModel( - name="APIKey", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=200)), - ("prefix", models.CharField(help_text="First 8 characters visible in UI", max_length=12, unique=True)), - ("key_hash", models.CharField(help_text="PBKDF2 hash of the full secret", max_length=255)), - ("scopes", models.JSONField(default=list, help_text='["read:users", "write:billing"]')), - ("is_active", models.BooleanField(default=True)), - ("expires_at", models.DateTimeField(blank=True, null=True)), - ("last_used_at", models.DateTimeField(blank=True, null=True)), - ( - "created_by", - models.ForeignKey( - null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL - ), - ), - ( - "rotated_from", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="infrasynth_security.apikey", - ), - ), - ], - options={ - "db_table": "security_api_key", - }, - ), migrations.CreateModel( name="TwoFactorConfig", fields=[ @@ -101,6 +57,49 @@ class Migration(migrations.Migration): "db_table": "security_two_factor_config", }, ), + migrations.CreateModel( + name="APIKey", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("prefix", models.CharField(help_text="First 8 characters visible in UI", max_length=12)), + ("key_hash", models.CharField(help_text="PBKDF2 hash of the full secret", max_length=255)), + ("scopes", models.JSONField(default=list, help_text='["read:users", "write:billing"]')), + ("is_active", models.BooleanField(default=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "rotated_from", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="infrasynth_security.apikey", + ), + ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ], + options={ + "db_table": "security_api_key", + "constraints": [ + models.UniqueConstraint(fields=("tenant", "prefix"), name="uniq_api_key_prefix_per_tenant") + ], + }, + ), migrations.CreateModel( name="Grant", fields=[ @@ -117,6 +116,15 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "user", models.ForeignKey( @@ -128,7 +136,9 @@ class Migration(migrations.Migration): ], options={ "db_table": "security_grant", - "unique_together": {("user", "codename")}, + "constraints": [ + models.UniqueConstraint(fields=("tenant", "user", "codename"), name="uniq_grant_per_tenant_user") + ], }, ), migrations.CreateModel( @@ -146,6 +156,15 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "user", models.ForeignKey( @@ -157,7 +176,48 @@ class Migration(migrations.Migration): ], options={ "db_table": "security_revoke", - "unique_together": {("user", "codename")}, + "constraints": [ + models.UniqueConstraint(fields=("tenant", "user", "codename"), name="uniq_revoke_per_tenant_user") + ], + }, + ), + migrations.CreateModel( + name="Role", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("slug", models.SlugField(max_length=100)), + ("description", models.TextField(blank=True)), + ("permissions", models.JSONField(default=list, help_text="List of permission codenames")), + ("is_system", models.BooleanField(default=False, help_text="System roles cannot be deleted")), + ( + "tenant", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ( + "users", + models.ManyToManyField( + blank=True, + help_text="Users assigned this role", + related_name="roles", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "security_role", + "constraints": [ + models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_role_slug_per_tenant"), + models.UniqueConstraint( + condition=models.Q(("tenant__isnull", True)), fields=("slug",), name="uniq_global_role_slug" + ), + ], }, ), ] diff --git a/infrasynth/security/migrations/0002_add_role_users_m2m.py b/infrasynth/security/migrations/0002_add_role_users_m2m.py deleted file mode 100644 index cb19748..0000000 --- a/infrasynth/security/migrations/0002_add_role_users_m2m.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:30 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("infrasynth_security", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="role", - name="users", - field=models.ManyToManyField( - blank=True, help_text="Users assigned this role", related_name="roles", to=settings.AUTH_USER_MODEL - ), - ), - ] diff --git a/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index 02ef98b176473ff88415c2e7ac2e1f633c864934..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6454 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 B7k>Z% diff --git a/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index f70c174edeeac578581c4981aa5cc3b0b5639763..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= zo0(peSdy8aSF9f&pP83g5+AQuP diff --git a/infrasynth/security/models.py b/infrasynth/security/models.py index 1a90935..5395ee2 100644 --- a/infrasynth/security/models.py +++ b/infrasynth/security/models.py @@ -1,10 +1,15 @@ from django.conf import settings from django.db import models +from django.db.models import Q + +from infrasynth.tenancy.mixins import GlobalOrTenantModel, TenantOwnedModel -class Role(models.Model): +class Role(GlobalOrTenantModel): + """A permission role. ``tenant IS NULL`` is a system role; a tenant row overrides it.""" + name = models.CharField(max_length=100) - slug = models.SlugField(max_length=100, unique=True) + slug = models.SlugField(max_length=100) description = models.TextField(blank=True) permissions = models.JSONField(default=list, help_text="List of permission codenames") is_system = models.BooleanField(default=False, help_text="System roles cannot be deleted") @@ -17,12 +22,20 @@ class Role(models.Model): class Meta: db_table = "security_role" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_role_slug_per_tenant"), + models.UniqueConstraint( + fields=["slug"], + condition=Q(tenant__isnull=True), + name="uniq_global_role_slug", + ), + ] def __str__(self): return self.name -class Grant(models.Model): +class Grant(TenantOwnedModel): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -40,10 +53,12 @@ class Grant(models.Model): class Meta: db_table = "security_grant" - unique_together = [("user", "codename")] + constraints = [ + models.UniqueConstraint(fields=["tenant", "user", "codename"], name="uniq_grant_per_tenant_user"), + ] -class Revoke(models.Model): +class Revoke(TenantOwnedModel): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -60,12 +75,16 @@ class Revoke(models.Model): class Meta: db_table = "security_revoke" - unique_together = [("user", "codename")] + constraints = [ + models.UniqueConstraint(fields=["tenant", "user", "codename"], name="uniq_revoke_per_tenant_user"), + ] -class APIKey(models.Model): +class APIKey(TenantOwnedModel): + """A tenant-scoped service credential (``prefix.secret``, secret hashed).""" + name = models.CharField(max_length=200) - prefix = models.CharField(max_length=12, unique=True, help_text="First 8 characters visible in UI") + prefix = models.CharField(max_length=12, help_text="First 8 characters visible in UI") key_hash = models.CharField(max_length=255, help_text="PBKDF2 hash of the full secret") scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) @@ -76,6 +95,9 @@ class APIKey(models.Model): class Meta: db_table = "security_api_key" + constraints = [ + models.UniqueConstraint(fields=["tenant", "prefix"], name="uniq_api_key_prefix_per_tenant"), + ] class TwoFactorConfig(models.Model): diff --git a/infrasynth/security/password_validation.py b/infrasynth/security/password_validation.py new file mode 100644 index 0000000..1b1eeef --- /dev/null +++ b/infrasynth/security/password_validation.py @@ -0,0 +1,51 @@ +"""Configurable password policy (``INFRASYNTH_SECURITY``). + +Wire it in the consuming project with:: + + AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "infrasynth.security.password_validation.PasswordPolicyValidator"}, + ] + +The knob names are the ones documented in ``INFRASYNTH_SECURITY``: +``PASSWORD_MIN_LENGTH``, ``PASSWORD_REQUIRE_UPPERCASE``, +``PASSWORD_REQUIRE_DIGIT``, ``PASSWORD_REQUIRE_SPECIAL_CHAR``. +""" + +from __future__ import annotations + +import re + +from django.core.exceptions import ValidationError + +from infrasynth.shared.settings_utils import get_setting + +__all__ = ["PasswordPolicyValidator"] + +_SPECIAL = re.compile(r"[^A-Za-z0-9]") + + +class PasswordPolicyValidator: + def __init__(self) -> None: + pass + + @staticmethod + def _config(key: str, default): + return get_setting("INFRASYNTH_SECURITY", key, default) + + def validate(self, password: str, user=None) -> None: + errors: list[str] = [] + min_length = int(self._config("PASSWORD_MIN_LENGTH", 8)) + if len(password) < min_length: + errors.append(f"This password must contain at least {min_length} characters.") + if self._config("PASSWORD_REQUIRE_UPPERCASE", True) and not any(c.isupper() for c in password): + errors.append("This password must contain at least one uppercase letter.") + if self._config("PASSWORD_REQUIRE_DIGIT", True) and not any(c.isdigit() for c in password): + errors.append("This password must contain at least one digit.") + if self._config("PASSWORD_REQUIRE_SPECIAL_CHAR", True) and not _SPECIAL.search(password): + errors.append("This password must contain at least one special character.") + if errors: + raise ValidationError(errors) + + def get_help_text(self) -> str: + min_length = int(self._config("PASSWORD_MIN_LENGTH", 8)) + return f"Your password must be at least {min_length} characters and meet the site's strength rules." diff --git a/infrasynth/security/permissions.py b/infrasynth/security/permissions.py index eb913fe..8c5dd52 100644 --- a/infrasynth/security/permissions.py +++ b/infrasynth/security/permissions.py @@ -1,29 +1,86 @@ +"""DRF permission classes built on :class:`AuthorizationService`. + +Enforcement model +----------------- +* A superuser is always allowed. +* A **tenant owner** (``TenantMembership.is_owner`` for the resolved tenant) is + allowed — ownership is a capability, not a permission row. +* Otherwise the request user must hold at least one of the view's + ``required_permissions`` (``HybridPermission``) or all of them + (``require_permission``). +* A view with no ``required_permissions`` only needs authentication. + +The underlying :class:`AuthorizationService` is deliberately strict (owners are +not implicitly granted every codename) so it stays a pure permission resolver; +ownership is handled at the HTTP boundary here. +""" + +from __future__ import annotations + +from typing import Any + from rest_framework.permissions import BasePermission from .services import AuthorizationService +def is_tenant_owner(user: Any) -> bool: + """True when ``user`` owns the currently bound tenant.""" + if not user or not getattr(user, "is_authenticated", False): + return False + # System users (API keys) are not database-backed memberships. + if not hasattr(user, "_meta") or getattr(user, "pk", None) is None: + return False + from infrasynth.tenancy.context import get_current_tenant + from infrasynth.tenancy.models import TenantMembership + + tenant = get_current_tenant() + if tenant is None: + return False + return TenantMembership.objects.filter( + tenant=tenant, + user=user, + is_active=True, + is_owner=True, + ).exists() + + class HybridPermission(BasePermission): - """DRF permission class that uses AuthorizationService.""" + """Allows when the user is an owner or holds any ``required_permissions``.""" def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): return False - required = getattr(view, "required_permissions", []) + if getattr(user, "is_superuser", False): + return True + if is_tenant_owner(user): + return True + required = getattr(view, "required_permissions", []) or [] if not required: return True - authz = AuthorizationService() - return authz.has_any_permission(request.user, required) + return AuthorizationService().has_any_permission(user, required) + + +class IsAuthenticatedAndPermitted(HybridPermission): + """The idiom for kit views: authenticated, then permission-checked.""" + + def has_permission(self, request, view): + if not getattr(getattr(request, "user", None), "is_authenticated", False): + return False + return super().has_permission(request, view) def require_permission(*codenames: str): - """Decorator/class for DRF views requiring all listed permissions.""" + """View (or view-decorator) requiring *all* listed permissions.""" - class PermissionRequired(HybridPermission): + class PermissionRequired(IsAuthenticatedAndPermitted): def has_permission(self, request, view): if not super().has_permission(request, view): return False - authz = AuthorizationService() - return authz.has_all_permissions(request.user, list(codenames)) + user = request.user + if getattr(user, "is_superuser", False) or is_tenant_owner(user): + return True + return AuthorizationService().has_all_permissions(user, list(codenames)) return PermissionRequired diff --git a/infrasynth/security/services.py b/infrasynth/security/services.py index db724a3..15bf0d2 100644 --- a/infrasynth/security/services.py +++ b/infrasynth/security/services.py @@ -57,10 +57,27 @@ class AuthorizationService: @staticmethod def _get_role_permission_lists(user) -> list[list[str]]: + from infrasynth.tenancy.context import get_current_tenant + from infrasynth.tenancy.models import TenantMembership + + from .models import Role + + role_perms: list[list[str]] = [] roles = getattr(user, "roles", None) - if roles is None: - return [] - return list(roles.values_list("permissions", flat=True)) + if roles is not None: + role_perms.extend(list(roles.values_list("permissions", flat=True))) + + # Roles assigned via membership in the current tenant (TENANCY.md §6). + tenant = get_current_tenant() + if tenant is not None: + slugs = TenantMembership.objects.filter(user=user, tenant=tenant, is_active=True).values_list( + "role", flat=True + ) + for slug in set(slugs): + role = Role.objects.filter(slug=slug).first() + if role is not None: + role_perms.append(role.permissions or []) + return role_perms def has_all_permissions(self, user, codenames: list[str]) -> bool: return all(self.has_permission(user, c) for c in codenames) diff --git a/infrasynth/security/throttling.py b/infrasynth/security/throttling.py new file mode 100644 index 0000000..78d71e4 --- /dev/null +++ b/infrasynth/security/throttling.py @@ -0,0 +1,82 @@ +"""Login brute-force protection (``INFRASYNTH_SECURITY``). + +Two layers, both cache-backed and tenant-prefix-free by necessity (they run +before a tenant is known, so they key on IP + credential, never on tenant data): + +* a short per-credential/IP rate limit (``LOGIN_RATE_LIMIT``, e.g. ``10/m``); +* an IP blacklist that trips after ``IP_BLACKLIST_THRESHOLD`` failures inside + ``IP_BLACKLIST_WINDOW_MINUTES`` and blocks the IP for the same window. + +Failures are recorded on any rejected login and cleared on success. A blacklisted +IP gets ``429 RATE_LIMIT_EXCEEDED`` with a ``Retry-After`` hint. +""" + +from __future__ import annotations + +import time + +from django.core.cache import cache + +from infrasynth.shared.exceptions import RateLimitError +from infrasynth.shared.settings_utils import get_setting + +__all__ = ["LoginAttemptGuard"] + + +def _parse_rate(rate: str) -> tuple[int, int]: + """Parses ``"10/m"`` into ``(count, seconds)``. Defaults to 10/minute.""" + try: + count_part, period = rate.split("/", 1) + count = int(count_part) + except (ValueError, AttributeError): + return 10, 60 + seconds = {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(period.strip().lower(), 60) + return count, seconds + + +class LoginAttemptGuard: + def __init__(self) -> None: + self.rate_count, self.rate_window = _parse_rate(get_setting("INFRASYNTH_SECURITY", "LOGIN_RATE_LIMIT", "10/m")) + self.blacklist_threshold = int(get_setting("INFRASYNTH_SECURITY", "IP_BLACKLIST_THRESHOLD", 100)) + self.blacklist_window = int(get_setting("INFRASYNTH_SECURITY", "IP_BLACKLIST_WINDOW_MINUTES", 15)) * 60 + + @staticmethod + def _ip(request) -> str: + return request.META.get("REMOTE_ADDR", "") or "unknown" + + def _blacklist_key(self, ip: str) -> str: + return f"login:blacklist:{ip}" + + def _failure_key(self, ip: str, credential: str) -> str: + return f"login:fail:{ip}:{credential}" + + def check(self, request, credential: str) -> None: + """Raises :class:`RateLimitError` when the attempt must be rejected.""" + ip = self._ip(request) + if cache.get(self._blacklist_key(ip)): + raise RateLimitError( + "Too many failed login attempts from this address.", + code="RATE_LIMIT_EXCEEDED", + details=[{"field": "ip", "issue": "blacklisted"}], + ) + count = cache.get(self._failure_key(ip, credential), 0) + if count >= self.rate_count: + raise RateLimitError( + "Too many failed login attempts.", + code="RATE_LIMIT_EXCEEDED", + details=[{"field": "credential", "issue": "rate_limited"}], + ) + + def record_failure(self, request, credential: str) -> None: + ip = self._ip(request) + key = self._failure_key(ip, credential) + try: + count = cache.incr(key) + except ValueError: + cache.set(key, 1, self.rate_window) + count = 1 + if count >= self.blacklist_threshold: + cache.set(self._blacklist_key(ip), time.time(), self.blacklist_window) + + def clear(self, request, credential: str) -> None: + cache.delete(self._failure_key(self._ip(request), credential)) diff --git a/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 42b46611c1a09f31c1056dbe4ea9f7e6db8e1378..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHKA zQl1~5mY7_UU!)%&pP83g5+AQuP=P(!K}sh+OgKN?PedasO{(8kd{~N(_?{odXWen}(4i1&xw<^Ra0O)ndV()^u*u zN!!Ypt9oUZiu6<`=feOrNa_LNQW|m3LqC_VAsbTU-Xw(oMB=`*CyF4r%>7GACg zgyOVaZC?vr9GkK-Hq{A>4Nu>XZ4qi_OkLxqZN)$z%QHe*tdO;1S#68NOdiwAspX-P zr0(Vf0R;!RZSY3Cm(u!MYU-@)n z_0_%aTGd7c8SuTkP!H-Z+nZ=i?nb5*_d8xenT4WX)r<|(QsWYB1V5(*4`(!7;TJKc zi%l?1l&9PAR>z;!n8rD+*oI7owLH%fiwiCi!|`rvJnJ-1eKs|pTEwaQi!+}(O^j=l zvx3R9RFKc{+be6(Tg;JH33r-c6JlvAIdE?QT;r|dhxKwa-~@7-Keh>5u=&(ulkvFh zcp1rM96u$G@`Q0m<{6C%!`ruG&gW{@-XQ9RO|>m;T}@HD98oo`4At1S#j}YOjS+Q4 z)7Ob*)ag}5^gK2Bw#o}O&Xl`c-MG2}lAGI3aFg25hzz@-``*#8$^RG$y09NW?4r}) zwXX^XN-5ZX9PEEq`1;B7Cna?b-a~cnq%Ha?5ZN35a(sWJ)H!(6Irwv6u%;{pdXECV z2cxAx;y921{h3E;44+1BmLl<^NW2tD97Ph}-2HC++wsFl;$>vw>Ex?mWN&A0=jqNt zU#a&FyobGaPNO&W3(tbzca);{j-&TZJNpjiUUm*0wh#TTpiukeg%5>#O2L7n;J^iv z8pE%eLnpzuGatae*$pV-$KkQb82Tw02KfSY3mNN}9F<>uD1m&A7UI5QNYyq?Rq#X2 z*{eXYV)zY>o#$$?qox9)sTHEB#ps)AxQcC9+E(i~)w4ujSL2ON1ICuNK`?fjFy63@ zd`{qIj34E-Ty-UgamJ*K=S+)OHZ1!wHf$YZD((a-TF2?Y7^n(V#)5R_N}dx2mSK$b zoW_`8>Rcze9LDeqXsJ_zo46PzsAyB_cZC2X9&bO zELI#ApdmxXnr5xqNtU%K*YljwGVR~1s`K2;v7|vZ;V{bgJ{9+`qd>tV$I6{miQOeO vy(NSQ?lg>7j^ra6hp9-EiE7VeNs?ZpTd&c`ujqpd<+{|qul|W(DEs~k0aeCf diff --git a/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc deleted file mode 100644 index b1223c5c90dccac17964ef67eee45a4512b4cc63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7149 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 diff --git a/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc deleted file mode 100644 index 97d886b7ab257524fba3ccb0b29e3f6891dfd443..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1108 zcmah|%}*0i5TCcZw1om8)L3IG1T`kB$a)~uLqkkV480KCLoX6GOZ#XS+FgA6D%4XC zp7h|=gC-t0$lX7L_z{y;PsRf`3|GPE&GzF9VxENxALn{ll?xI=kpPBeuca);d%#_4{X za>X^UM-J)GWm_b}I%vt7Y-%5a3Z6h&*P$y=r6*f+7M5^WMm|9*wa}hhP89womf`eD$pHA??m2)U)7Ov z+B#^)bK9L{)bso>jI^(%=czI5mBtlcM%9HA5aQ-9QhWE0$h^;Sbc|VGKFA?C<)ef_ z7tZd!a(Z*FV>QEk&7RB5_jyHbgi2u%a*;;dNKB05i112YL+)ad(NXmcHHtdP*JUUb zq2E^0^V_=Nak-krEEng|>B6_{g$^7iqYM<28Hfn}?4%>6Z2!o+jE&b$6S zSBIXB!FZE&D+t?K#t3m3x1!0S=?#yOGPkTak>m4;;3834++${oXNWM*GK88DEu-^) zX6JjmV3qc}#ds%v3da?Mr{I33~u<$v%a9}O)jcjpv4{Oj6EIX|x&?K1}&W(OA?wO3% zHvO<3&GFl0Ks-clvk!buZ|0d&E2_1da#2m;F?6-~3aTSh2=Nuhzr)l|m^n$|dp*ev I_~q??01mI!>;M1& diff --git a/infrasynth/security/two_factor/middleware.py b/infrasynth/security/two_factor/middleware.py index b474596..3e5bdba 100644 --- a/infrasynth/security/two_factor/middleware.py +++ b/infrasynth/security/two_factor/middleware.py @@ -1,3 +1,12 @@ +"""Enforces 2FA for session-authenticated users. + +Bearer/cookie JWT requests never reach this check: an access token is only ever +minted *after* the second factor succeeds (see ``security.views``), so holding a +minted token is proof the factor was completed. This middleware therefore guards +the session-authenticated surface (Django admin and any session-based view). +""" + +from django.http import JsonResponse from django.shortcuts import redirect from django.utils.deprecation import MiddlewareMixin @@ -5,28 +14,40 @@ from ..models import TwoFactorConfig class TwoFactorMiddleware(MiddlewareMixin): - """Enforces 2FA verification for users who have it configured.""" - EXEMPT_PATHS = [ - "/api/auth/login/", - "/api/auth/logout/", - "/api/auth/2fa/verify/", - "/api/auth/2fa/recovery/", - "/api/auth/check/", + "/api/v1/auth/login/", + "/api/v1/auth/logout/", + "/api/v1/auth/2fa/setup/", + "/api/v1/auth/2fa/verify-setup/", + "/api/v1/auth/2fa/verify/", + "/api/v1/auth/2fa/recovery/", + "/api/v1/auth/check/", ] def process_request(self, request): if not hasattr(request, "user") or not request.user.is_authenticated: return None - if request.path in self.EXEMPT_PATHS: + # A minted JWT proves the second factor already happened. + if getattr(request, "auth", None) is not None: return None - if request.path.startswith("/admin/"): + if request.path in self.EXEMPT_PATHS or request.path.startswith("/admin/"): + return None + session = getattr(request, "session", None) + if session is not None and session.get("_2fa_verified"): return None try: tfa = TwoFactorConfig.objects.get(user=request.user) - if tfa.is_enabled and tfa.is_configured: - if not getattr(request, "_2fa_verified", False): - return redirect("2fa-verify") except TwoFactorConfig.DoesNotExist: - pass - return None + return None + if not (tfa.is_enabled and tfa.is_configured): + return None + if request.path.startswith("/api/"): + return JsonResponse( + { + "code": "AUTH_2FA_REQUIRED", + "message": "Second-factor verification is required.", + "details": [], + }, + status=403, + ) + return redirect("2fa-verify") diff --git a/infrasynth/security/urls.py b/infrasynth/security/urls.py index 3c9e7da..2d36928 100644 --- a/infrasynth/security/urls.py +++ b/infrasynth/security/urls.py @@ -9,6 +9,7 @@ from .views import ( RevokeViewSet, RoleViewSet, TwoFactorViewSet, + UserPermissionViewSet, ) router = DefaultRouter() @@ -22,6 +23,16 @@ urlpatterns = [ path("logout/", AuthViewSet.as_view({"post": "logout"}), name="auth-logout"), path("refresh/", AuthViewSet.as_view({"post": "refresh"}), name="auth-refresh"), path("check/", AuthViewSet.as_view({"get": "check"}), name="auth-check"), + path( + "select-workspace/", + AuthViewSet.as_view({"post": "select_workspace"}), + name="auth-select-workspace", + ), + path( + "switch-workspace/", + AuthViewSet.as_view({"post": "switch_workspace"}), + name="auth-switch-workspace", + ), path( "2fa/setup/", TwoFactorViewSet.as_view({"post": "setup"}), @@ -57,5 +68,15 @@ urlpatterns = [ ALTCHAViewSet.as_view({"post": "verify"}), name="altcha-verify", ), + path( + "users//permissions/", + UserPermissionViewSet.as_view({"get": "permissions"}), + name="user-permissions", + ), + path( + "users//roles/", + UserPermissionViewSet.as_view({"get": "roles", "put": "roles"}), + name="user-roles", + ), path("", include(router.urls)), ] diff --git a/infrasynth/security/views.py b/infrasynth/security/views.py index f9fb9f0..ce9cbef 100644 --- a/infrasynth/security/views.py +++ b/infrasynth/security/views.py @@ -1,17 +1,28 @@ import secrets +from datetime import datetime, timedelta from django.contrib.auth import authenticate, get_user_model, login, logout +from django.utils import timezone from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import AuthenticationFailed, PermissionDenied +from rest_framework.exceptions import ( + AuthenticationFailed, + NotAuthenticated, + NotFound, + PermissionDenied, + ValidationError, +) from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken +from infrasynth.shared.exceptions import NotFoundError from infrasynth.shared.settings_utils import get_setting +from infrasynth.tenancy.services import TenantService from .altcha.services import ALTCHAService from .models import APIKey, Grant, Revoke, Role, TwoFactorConfig +from .permissions import IsAuthenticatedAndPermitted from .serializers import ( APIKeySerializer, GrantSerializer, @@ -20,28 +31,203 @@ from .serializers import ( RoleSerializer, ) from .services import AuthorizationService -from .signals import login_failed, user_logged_in, user_logged_out +from .signals import ( + api_key_created, + api_key_rotated, + login_failed, + two_factor_verified, + user_logged_in, + user_logged_out, +) +from .throttling import LoginAttemptGuard from .two_factor.services import RecoveryCodeService, TOTPService +from .two_factor.utils import generate_pre_auth_token UserModel = get_user_model() -class AuthViewSet(viewsets.GenericViewSet): +class _AuthSupport: + """Cookie/JWT/2FA helpers shared by the auth and 2FA viewsets.""" + + @staticmethod + def _mint_tokens(user, tenant): + refresh = RefreshToken.for_user(user) + if tenant is not None: + refresh["tenant"] = str(tenant.pk) + return str(refresh.access_token), str(refresh) + + def _cookie_config(self): + return { + "secure": get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True), + "httponly": get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True), + "samesite": get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax"), + "access_cookie": get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token"), + "refresh_cookie": get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token"), + "pre_auth_cookie": get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_COOKIE_NAME", "pre_auth_token"), + } + + def _encrypt(self, value): + from infrasynth.shared.crypto import encrypt as crypto_encrypt + + try: + return crypto_encrypt(value) + except Exception: + return value + + def _decrypt(self, value): + from infrasynth.shared.crypto import decrypt as crypto_decrypt + + try: + return crypto_decrypt(value) + except Exception: + return value + + def _set_auth_cookies(self, response, access_token, refresh_token): + cfg = self._cookie_config() + response.set_cookie( + cfg["access_cookie"], + self._encrypt(access_token), + max_age=3600, + secure=cfg["secure"], + httponly=cfg["httponly"], + samesite=cfg["samesite"], + path="/", + ) + response.set_cookie( + cfg["refresh_cookie"], + self._encrypt(refresh_token), + max_age=86400 * 7, + secure=cfg["secure"], + httponly=cfg["httponly"], + samesite=cfg["samesite"], + path="/", + ) + + def _set_refresh_cookie(self, response, refresh_token): + cfg = self._cookie_config() + response.set_cookie( + cfg["refresh_cookie"], + self._encrypt(refresh_token), + max_age=86400 * 7, + secure=cfg["secure"], + httponly=cfg["httponly"], + samesite=cfg["samesite"], + path="/", + ) + + def _user_from_refresh_cookie(self, request): + raw = request.COOKIES.get(get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token")) + if not raw: + return None + try: + refresh = RefreshToken(self._decrypt(raw)) # type: ignore[arg-type] + user_id = refresh.get("user_id") + except Exception: + return None + return UserModel.objects.filter(pk=user_id).first() + + def _complete_login(self, request, user): + """Issues tenant-bound tokens or a workspace-picker challenge.""" + memberships = TenantService().get_active_memberships(user) + if len(memberships) == 1: + tenant = memberships[0].tenant + access, refresh = self._mint_tokens(user, tenant) + response = Response({"detail": "Login successful.", "tenant": str(tenant.pk)}) + self._set_auth_cookies(response, access, refresh) + return response + if len(memberships) > 1: + _, refresh = self._mint_tokens(user, None) + response = Response( + { + "detail": "Select a workspace.", + "workspaces": [ + {"id": str(m.tenant.pk), "slug": m.tenant.slug, "name": m.tenant.name} for m in memberships + ], + } + ) + self._set_refresh_cookie(response, refresh) + return response + raise PermissionDenied("No active workspace membership for this user.") + + @staticmethod + def _requires_two_factor(user): + return TwoFactorConfig.objects.filter(user=user, is_enabled=True, is_configured=True).first() + + def _start_pre_auth(self, request, user, config): + """Stores the pre-auth session and cookie; returns the challenge response.""" + token = generate_pre_auth_token() + request.session["_2fa_user_id"] = user.pk + request.session["_2fa_pre_auth_token"] = token + request.session["_2fa_started_at"] = timezone.now().isoformat() + cfg = self._cookie_config() + response = Response( + { + "detail": "Two-factor authentication required.", + "requires2fa": True, + "method": config.method, + }, + status=status.HTTP_200_OK, + ) + lifetime = int(get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_TOKEN_LIFETIME_MINUTES", 5)) + response.set_cookie( + cfg["pre_auth_cookie"], + token, + max_age=lifetime * 60, + secure=cfg["secure"], + httponly=cfg["httponly"], + samesite=cfg["samesite"], + path="/", + ) + return response + + @staticmethod + def _valid_pre_auth(request) -> bool: + user_id = request.session.get("_2fa_user_id") + token = request.session.get("_2fa_pre_auth_token") + if not user_id or not token: + return False + started = request.session.get("_2fa_started_at") + lifetime = int(get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_TOKEN_LIFETIME_MINUTES", 5)) + if started: + try: + started_at = datetime.fromisoformat(started) + except ValueError: + return False + if timezone.now() - started_at > timedelta(minutes=lifetime): + return False + return True + + def _finish_two_factor(self, request, user): + login(request, user) + request.session["_2fa_verified"] = True + request.session.pop("_2fa_user_id", None) + request.session.pop("_2fa_pre_auth_token", None) + request.session.pop("_2fa_started_at", None) + two_factor_verified.send(sender=self.__class__, user=user, method="totp") + return self._complete_login(request, user) + + +class AuthViewSet(_AuthSupport, viewsets.GenericViewSet): permission_classes = [AllowAny] @action(detail=False, methods=["post"]) def login(self, request): serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) + credential = serializer.validated_data["username"] + guard = LoginAttemptGuard() + guard.check(request, credential) + user = authenticate( request, - username=serializer.validated_data["username"], + username=credential, password=serializer.validated_data["password"], ) if not user: + guard.record_failure(request, credential) login_failed.send( sender=self.__class__, - credentials_key=serializer.validated_data["username"], + credentials_key=credential, ip=request.META.get("REMOTE_ADDR"), reason="invalid_credentials", ) @@ -49,46 +235,23 @@ class AuthViewSet(viewsets.GenericViewSet): if not user.is_active: raise AuthenticationFailed("User is disabled.") + guard.clear(request, credential) + + if get_setting("INFRASYNTH_SECURITY", "ALTCHA_PROTECT_LOGIN", False): + altcha_ok = ALTCHAService().verify( + request.data.get("altchaChallengeId") or request.data.get("altcha_challenge_id"), + request.data.get("altchaSolution") or request.data.get("altcha_solution"), + request.data.get("altchaNumber") or request.data.get("altcha_number"), + ) + if not altcha_ok: + raise ValidationError({"altcha": "A valid ALTCHA proof-of-work solution is required."}) + + config = self._requires_two_factor(user) + if config is not None: + return self._start_pre_auth(request, user, config) + login(request, user) - refresh = RefreshToken.for_user(user) - access_token = str(refresh.access_token) - refresh_token = str(refresh) - - cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) - cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True) - cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax") - access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") - refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") - - try: - from infrasynth.shared.crypto import encrypt as crypto_encrypt - - encrypted_access = crypto_encrypt(access_token) - encrypted_refresh = crypto_encrypt(refresh_token) - except Exception: - encrypted_access = access_token - encrypted_refresh = refresh_token - - response = Response({"detail": "Login successful."}) - response.set_cookie( - access_cookie, - encrypted_access, - max_age=3600, - secure=cookie_secure, - httponly=cookie_httponly, - samesite=cookie_samesite, - path="/", - ) - response.set_cookie( - refresh_cookie, - encrypted_refresh, - max_age=86400 * 7, - secure=cookie_secure, - httponly=cookie_httponly, - samesite=cookie_samesite, - path="/", - ) - + response = self._complete_login(request, user) user_logged_in.send( sender=self.__class__, user=user, @@ -97,73 +260,85 @@ class AuthViewSet(viewsets.GenericViewSet): ) return response + @action(detail=False, methods=["post"], url_path="select-workspace") + def select_workspace(self, request): + user = self._user_from_refresh_cookie(request) + if user is None: + raise AuthenticationFailed("No pending workspace selection.") + tenant_id = request.data.get("tenantId") or request.data.get("tenant_id") + if not tenant_id: + raise ValidationError({"tenantId": "This field is required."}) + try: + tenant = TenantService().select_tenant(user, tenant_id) + except NotFoundError as exc: + raise NotFound(str(exc)) from exc + access, refresh = self._mint_tokens(user, tenant) + response = Response({"detail": "Workspace selected.", "tenant": str(tenant.pk)}) + self._set_auth_cookies(response, access, refresh) + return response + + @action(detail=False, methods=["post"], url_path="switch-workspace") + def switch_workspace(self, request): + if not request.user or not request.user.is_authenticated: + raise NotAuthenticated("Authentication required.") + if not get_setting("INFRASYNTH_SECURITY", "ENABLE_WORKSPACE_SWITCHING", True): + raise NotFound("Workspace switching is disabled.") + tenant_id = request.data.get("tenantId") or request.data.get("tenant_id") + if not tenant_id: + raise ValidationError({"tenantId": "This field is required."}) + from infrasynth.tenancy.context import get_current_tenant + + previous = get_current_tenant() + try: + tenant = TenantService().switch_tenant( + request.user, + tenant_id, + previous_tenant_id=getattr(previous, "pk", None), + ) + except NotFoundError as exc: + raise NotFound(str(exc)) from exc + access, refresh = self._mint_tokens(request.user, tenant) + response = Response({"detail": "Workspace switched.", "tenant": str(tenant.pk)}) + self._set_auth_cookies(response, access, refresh) + return response + @action(detail=False, methods=["post"]) def logout(self, request): logout(request) - access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") - refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") + cfg = self._cookie_config() response = Response({"detail": "Logout successful."}) - response.delete_cookie(access_cookie, path="/") - response.delete_cookie(refresh_cookie, path="/") + response.delete_cookie(cfg["access_cookie"], path="/") + response.delete_cookie(cfg["refresh_cookie"], path="/") + response.delete_cookie(cfg["pre_auth_cookie"], path="/") user_logged_out.send(sender=self.__class__, user=getattr(request, "user", None)) return response @action(detail=False, methods=["post"]) def refresh(self, request): - cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) - cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True) - cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax") - access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") - refresh_cookie_name = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") - - raw_refresh = request.COOKIES.get(refresh_cookie_name) + cfg = self._cookie_config() + raw_refresh = request.COOKIES.get(cfg["refresh_cookie"]) if not raw_refresh: raise AuthenticationFailed("Refresh token not found.") - try: - from infrasynth.shared.crypto import decrypt as crypto_decrypt - - decrypted = crypto_decrypt(raw_refresh) - except Exception: - decrypted = raw_refresh try: - refresh = RefreshToken(decrypted) # type: ignore[arg-type] + refresh = RefreshToken(self._decrypt(raw_refresh)) # type: ignore[arg-type] access_token = str(refresh.access_token) if get_setting("INFRASYNTH_SECURITY", "ROTATE_REFRESH_TOKENS", True): new_refresh = str(refresh) + if get_setting("INFRASYNTH_SECURITY", "BLACKLIST_AFTER_ROTATION", True): + from rest_framework_simplejwt.exceptions import TokenError + + try: + refresh.blacklist() + except (AttributeError, TokenError): # blacklist app not installed + pass else: new_refresh = raw_refresh except Exception: raise AuthenticationFailed("Invalid or expired refresh token.") - try: - from infrasynth.shared.crypto import encrypt as crypto_encrypt - - encrypted_access = crypto_encrypt(access_token) - encrypted_refresh = crypto_encrypt(new_refresh) - except Exception: - encrypted_access = access_token - encrypted_refresh = new_refresh - response = Response({"detail": "Token refreshed."}) - response.set_cookie( - access_cookie, - encrypted_access, - max_age=3600, - secure=cookie_secure, - httponly=cookie_httponly, - samesite=cookie_samesite, - path="/", - ) - response.set_cookie( - refresh_cookie_name, - encrypted_refresh, - max_age=86400 * 7, - secure=cookie_secure, - httponly=cookie_httponly, - samesite=cookie_samesite, - path="/", - ) + self._set_auth_cookies(response, access_token, new_refresh) return response @action(detail=False, methods=["get"]) @@ -173,19 +348,30 @@ class AuthViewSet(viewsets.GenericViewSet): authz = AuthorizationService() effective_permissions = list(authz.get_effective_permissions(request.user)) user = request.user + from infrasynth.tenancy.context import get_current_tenant + + tenant = get_current_tenant() return Response( { "id": user.pk, "email": getattr(user, "email", ""), "name": getattr(user, "get_full_name", lambda: str(user))(), "effective_permissions": effective_permissions, + "tenant": str(tenant.pk) if tenant is not None else None, } ) -class TwoFactorViewSet(viewsets.GenericViewSet): +class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet): permission_classes = [IsAuthenticated] + def get_permissions(self): + # ``verify``/``recovery`` are the pre-auth (not-yet-logged-in) steps; the + # router-less ``as_view`` mapping does not apply per-action overrides. + if self.action in ("verify", "recovery"): + return [AllowAny()] + return [IsAuthenticated()] + @action(detail=False, methods=["post"]) def setup(self, request): # type: ignore[override] totp = TOTPService() @@ -225,9 +411,9 @@ class TwoFactorViewSet(viewsets.GenericViewSet): @action(detail=False, methods=["post"], permission_classes=[AllowAny]) def verify(self, request): - user_id = request.session.get("_2fa_user_id") - if not user_id: + if not self._valid_pre_auth(request): raise AuthenticationFailed("No pre-auth session.") + user_id = request.session["_2fa_user_id"] try: config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True) except TwoFactorConfig.DoesNotExist: @@ -239,12 +425,7 @@ class TwoFactorViewSet(viewsets.GenericViewSet): secret = totp.decrypt_secret(config.secret_key_encrypted) if not totp.verify(secret, code): raise AuthenticationFailed("Invalid code.") - user = UserModel.objects.get(pk=user_id) - login(request, user) - request._2fa_verified = True - request.session.pop("_2fa_user_id", None) - request.session.pop("_2fa_pre_auth_token", None) - return Response({"detail": "2FA verified."}) + return self._finish_two_factor(request, UserModel.objects.get(pk=user_id)) @action(detail=False, methods=["post"]) def disable(self, request): @@ -264,9 +445,9 @@ class TwoFactorViewSet(viewsets.GenericViewSet): recovery_code = request.data.get("recovery_code") if not recovery_code: raise AuthenticationFailed("Recovery code is required.") - user_id = request.session.get("_2fa_user_id") - if not user_id: + if not self._valid_pre_auth(request): raise AuthenticationFailed("No pre-auth session.") + user_id = request.session["_2fa_user_id"] try: config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True) except TwoFactorConfig.DoesNotExist: @@ -278,12 +459,7 @@ class TwoFactorViewSet(viewsets.GenericViewSet): if updated: config.recovery_codes_encrypted = updated config.save(update_fields=["recovery_codes_encrypted"]) - user = UserModel.objects.get(pk=user_id) - login(request, user) - request._2fa_verified = True - request.session.pop("_2fa_user_id", None) - request.session.pop("_2fa_pre_auth_token", None) - return Response({"detail": "Recovered access."}) + return self._finish_two_factor(request, UserModel.objects.get(pk=user_id)) class ALTCHAViewSet(viewsets.GenericViewSet): @@ -291,14 +467,11 @@ class ALTCHAViewSet(viewsets.GenericViewSet): @action(detail=False, methods=["post"]) def challenge(self, request): - svc = ALTCHAService() - challenge = svc.create_challenge() - return Response(challenge) + return Response(ALTCHAService().create_challenge()) @action(detail=False, methods=["post"]) def verify(self, request): - svc = ALTCHAService() - result = svc.verify( + result = ALTCHAService().verify( request.data.get("challenge_id"), request.data.get("solution"), request.data.get("number"), @@ -309,30 +482,72 @@ class ALTCHAViewSet(viewsets.GenericViewSet): class APIKeyViewSet(viewsets.ModelViewSet): queryset = APIKey.objects.order_by("-id").all() serializer_class = APIKeySerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["security.manage_api_keys"] def get_queryset(self): return APIKey.objects.order_by("-id").all() - def perform_create(self, serializer): + @staticmethod + def _generate_key(): from django.contrib.auth.hashers import make_password - prefix = secrets.token_hex(4) + prefix_chars = int(get_setting("INFRASYNTH_SECURITY", "API_KEY_PREFIX_LENGTH", 8)) + hasher = get_setting("INFRASYNTH_SECURITY", "API_KEY_HASH_ALGORITHM", "pbkdf2_sha256") + prefix = secrets.token_hex(max(1, prefix_chars // 2)) secret = secrets.token_hex(32) - full_key = f"{prefix}.{secret}" + try: + key_hash = make_password(secret, hasher=hasher) + except ValueError: # hasher not registered in this project — use the default + key_hash = make_password(secret) + return prefix, secret, f"{prefix}.{secret}", key_hash + + def _default_expiry(self): + days = int(get_setting("INFRASYNTH_SECURITY", "API_KEY_DEFAULT_EXPIRY_DAYS", 365)) + if days <= 0: + return None + return timezone.now() + timedelta(days=days) + + def perform_create(self, serializer): + prefix, _secret, full_key, key_hash = self._generate_key() instance = serializer.save( prefix=prefix, - key_hash=make_password(secret), + key_hash=key_hash, created_by=self.request.user, + expires_at=serializer.validated_data.get("expires_at") or self._default_expiry(), ) instance.key = full_key + api_key_created.send(sender=APIKey, key_name=instance.name, created_by=self.request.user) return instance + @action(detail=True, methods=["post"]) + def rotate(self, request, pk=None): + """Issues a new secret for the key and deactivates the old one.""" + old = self.get_object() + prefix, _secret, full_key, key_hash = self._generate_key() + new = APIKey.all_objects.create( + tenant=old.tenant, + name=old.name, + prefix=prefix, + key_hash=key_hash, + scopes=old.scopes, + created_by=request.user, + expires_at=old.expires_at or self._default_expiry(), + rotated_from=old, + ) + old.is_active = False + old.save(update_fields=["is_active"]) + api_key_rotated.send(sender=APIKey, key_name=new.name, rotated_by=request.user) + data = dict(APIKeySerializer(new).data) + data["key"] = full_key + return Response(data, status=status.HTTP_201_CREATED) + class RoleViewSet(viewsets.ModelViewSet): queryset = Role.objects.order_by("name").all() serializer_class = RoleSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["security.manage_roles"] lookup_field = "slug" def get_queryset(self): @@ -352,7 +567,8 @@ class GrantViewSet( ): queryset = Grant.objects.all() serializer_class = GrantSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["security.manage_grants"] def get_queryset(self): return Grant.objects.select_related("user", "granted_by").order_by("-id").all() @@ -369,10 +585,51 @@ class RevokeViewSet( ): queryset = Revoke.objects.all() serializer_class = RevokeSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticatedAndPermitted] + required_permissions = ["security.manage_grants"] def get_queryset(self): return Revoke.objects.select_related("user", "revoked_by").order_by("-id").all() def perform_create(self, serializer): serializer.save(revoked_by=self.request.user) + + +class UserPermissionViewSet(viewsets.GenericViewSet): + """Effective permissions and role assignment for a user (``/auth/users//…``).""" + + permission_classes = [IsAuthenticatedAndPermitted] + queryset = UserModel.objects.all() + + def initial(self, request, *args, **kwargs): + if request.method in ("PUT", "PATCH", "POST"): + self.required_permissions = ["security.manage_roles"] + else: + self.required_permissions = ["security.view_permissions"] + super().initial(request, *args, **kwargs) + + def _get_user(self, pk): + user = UserModel.objects.filter(pk=pk).first() + if user is None: + raise NotFound("User not found.") + return user + + @action(detail=True, methods=["get"], url_path="permissions") + def permissions(self, request, pk=None): + user = self._get_user(pk) + perms = sorted(AuthorizationService().get_effective_permissions(user)) + return Response({"user": user.pk, "permissions": perms}) + + @action(detail=True, methods=["get", "put"], url_path="roles") + def roles(self, request, pk=None): + user = self._get_user(pk) + if request.method == "PUT": + slugs = request.data.get("roles", []) + if not isinstance(slugs, list): + raise ValidationError({"roles": "Expected a list of role slugs."}) + resolved = list(Role.objects.filter(slug__in=slugs)) + missing = set(slugs) - {role.slug for role in resolved} + if missing: + raise ValidationError({"roles": f"Unknown role(s): {', '.join(sorted(missing))}"}) + user.roles.set(resolved) + return Response({"user": user.pk, "roles": list(user.roles.values_list("slug", flat=True))}) diff --git a/infrasynth/shared/__pycache__/__init__.cpython-314.pyc b/infrasynth/shared/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0a688a920c3e8411c6f455258fc442f62c54b240..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 175 zcmdPq_I|p@<2{{|u76<)fdGpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hakyw@frOD22mhuo*gFd+D2v%*e2p?VL+d za@@=wQ0xFa_we4!`**(kox|hN17QO4?@Q%nD@e%S@WmIQX*|3FMwMJ4DxD@%)TUE{ zEgT`L&`Z=nFDZA~0aXM$$Zb)Tzz%UcsLEh>a9dKtU@P1XsS&Uba9dWR10>n`1x}U} zTmm|jd5*jmLlQi%d@DV0(Fe$UYbNj_{mJ(y)XSOq@8&O_ zznp;mCkm#qP+DAp(@7=fbUQIUdqEwayF5ETm7hC5aY>z?zx>1D_BX>nN|-FseoyGe zYGPGmsifrgdxG|W54_XH@yWydrJ@@ ze1Yp=v~PNa93gG=WZ}lA$ygI(nH`Jt$58`5E-ExjeLO8D5Kq7!;cQ@iPm!QHsl&v1q0cimVSta6{mD%?4})z;FfA zazlW#5;F}rYV5&!Q1yedaJz|whZq*cYE=A)&w^1Ue-Q`&9`3$%tQJ4{@v~0&R3m(* z9zOHwStmTj@$;G7{nu#M&E*@*H;o&{dd`WCTn}t0(dx{Xfg-|)+z;R6L+=Na9W*Dj zVB!XWH&=9y7KVhg7gVs_`Z}NN5fNx=3)SuCHnF(#9Di%C61s6|t+E2IPh=+Wex`|& z<~O_mPHAybyM8yPJP8YaA0Tr&n-uwVMW=BlUl+>HDQFB3@clftl20FGa&*m^Nh)NBnxLK6nycnrK82J9QK zbA}sdouHBeJBIcUsK@Xyg0-ml;m&~3!o#Kz2u{+C&aRtlH`dkc{&v69d92Zy zu6L%L&XYCy0Yd)8u!(<6VKrA^}2@V`q@h zxc3D|?q?Eo-1iy}Kqm`13lT!;$QG*G1zvctfVP&j7BK}?aDQ738En}l&$RN5z3wh7 z6xRUsiMw+ni@KpROnxBba+lmCd>vk-IsttcD!y)yejv0ql>YllzoR4?%5Ys7c9djI zOtw+&-#bMH?{$4O=W@n(8!}L8c zfGE3re?9Z@tM>wp(aHOxlk1^(qHjh2)OYNU!%nwao!O^(qvufd<cmbpV$auO&pWYm*E=@kSVKNsmk+PcG~%gxJoV99BYm!(KIf#z zo%lHMPBGW2MYpG_YkN3k15HYlP zN;ilWKVeQyxLuY)g{i#vvml?nW)@e<`U~tV)FBsH7+HYwDgB&`ZAjnlDE5nhabssk@-mf0hpeW4gdfE diff --git a/infrasynth/shared/__pycache__/enums.cpython-314.pyc b/infrasynth/shared/__pycache__/enums.cpython-314.pyc deleted file mode 100644 index bf643aea5046930f32408fc54fe7bbf41826d36b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2857 zcma)8&vV;E6kb_B@u<-Y)qoG3Yv*%=NDCvGtD6u9wLwyByCKQyCn*WW%#`+a)v$*+xP z(g==kf4{er77+TG{TLqNU~=>gOuFa}GB_QraD+RHrqOw1q-K!8-{zM1ejG_ftN^Td zJM}lqN2~;_9AkxuRe&9dv0}ugfgO#pQpAn{n~AY<#Et`-jj>9^=761uu_MOh1vEeP z3)|>CN98KPn%{2P{f+lAHTZG#223o_dm9nXKqPfXApA)bpxc7M&7h@07!i#Wkv0&K z4Ib_-&@r>n2wd9D_4U|LcTsyUZ+NtF$2;HBku&86l23HyG zD7I1=zLOYdz)v7o2Rn{U!bR7= zi>x358nppqMx*t zgRK5_^(*d+na^e(o_{>|XzuaCqlG6^Pwa#In?K~QJk4J@$X`9kz4LYTX;%ME(f|BD ztB33sA74`?;Y(DWSEyo|OrvSiv}rbjdfQ|3G1I)?w!Fbg)->D>WOcN3>6C>d=mL%`tEFflk zi$H)$I1BK{ry%uE%>9<)#GBj^kEDrb{V6Q=V_(SMVa6T33d^zVO%vYWhyd9t8a$B< z0Wwx3iXo8^Lx#*%=xCu~`M%@TIxTq7PSbKd%7@L6Di0bK39VL(D#Y=eZEQ8E&}xSb zJPFSz&!_Pe+sX+B(+o}mP*Gb|^%CW)t5vGpT2X6NwNjxkM zZSZLZkqIxe83WdnLdJiB4#%dt+F?4|EgWWNyDNPdo!Be(uVc~4Hq60F%!bK-vH>iR zX-3<1d{T9G9qf`06(D^BDnGEW@A})|3C01YiVcgdZFvLvBZ=|_y}Z0emd!uhbc6M*2ubBlVYd!-jG0n5b zHG@`>X0N**ZTYOlICUyJAGTcRs!*`l zwb)x8=ua+HNiEd$4UH-lwOTWa>lz&?sO5rIDrrS3Yadi}113t%(A5$obD}RQ$i)mU z!Z|G3$d{;IbEms{|J6TFU>5zq9J=(M4rO$Imwj~y=7@D{-6d2AT8`rZy-#d-~!n`_Xkk_&s2>0`kDR1AwCYR-cWJ?v2|20jo}V4ru)v&*7ST-4&D zb{F*sRx45RM5aY|j`rENhJ6&M@^1gzd<|RhlY=wd0r-{YIPOPusweT>O>Y0K9%5r} zOynl_FZB=%y_~@1_e(tlLvK9Ao!s|&2!>vohdIQ+z~b`{Ut;n3UQXmLvUnJJIUcV4 L=o!T1qrLnEpsacw diff --git a/infrasynth/shared/__pycache__/protocols.cpython-314.pyc b/infrasynth/shared/__pycache__/protocols.cpython-314.pyc deleted file mode 100644 index d593ac1c61882554fcc11fbe4c21bcadb58106b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1937 zcma)6-D?|15TCs}oz9M}_#=v`npCbsKSa`~;5?Kzga%UTJVa?PAq0xOtWW3a?9-jP zw+c8i(dkM3FOs(j$Ri^DTPAbB&(D@cINKXTKTZOaC5Ws+nwF{&CFgdO-}-j zzf0lfpA6s+nv8~>E5n~q*?~21*=<+>1y$0G+ib;9#)_%T6-!xFShdRVKDg!rxYnw9 z&CGUR%bln~ju#NO-+!jIw}{(|B<+d)@r#%E6enrOWM-?A2~!#(yLd||lk zLtI0;Yg{R!nh!+G9AU2^)?4=!-nz#o1TSBFNr7vlv(?Zu-J~mI5OYuZ!VBU^mo|K_ zCH|k6M=OMQTIaS4i%m_~$A72){dLcE27JfU$uM3zhv_&o+D3;-E+d2g(=s z)w8=j>(XF?43lu7`r_R4&wJJfgEM59g38j1ThFUK>*K>|Gz`%#96;lss2$EDfz`+L zB=_C)E#o|yjXksqn!`{4$jddhxa z*a60<&PUxq%_r%l(vv=&mNKl19kCt8qNyjS-I1(z2Mf?ll++fL9aiQHg`2XI6<|is zJb9{&-FvK3N{A;Pp~xy_W`6hXs8ULEyLU!Kg7(HC)kntadEC&D3n*ok9308V5*bO5 zXSqlsO$3=vnTYaSEWbN` zsWF;~g5(^EjOMd*d-s2S+_T;}q^>-X)62L~l1Q0cq*Ga$7ISZ6XY0XFs|gZK+=3>9HP zbwxQa(@3|cudG z*mtdxbn?|kMVQWBLvCN)G94?wWtMiVM!CSR;}U?lv!~Br5mg|hP~s){G!xq7#$9ITX2*d`})C`5BAp%lhcRcX+bsq_uUZG zr--A-agii_1ci^esz;2Hm9{rQo<2yjghlq-fSP)BV3ZpF6cXfwn7}8*B@(Zq=tm=j z{UcOvyaxv^so+68zPossoIMQB_Uh=cGk~#*oinHu_)FNy*YTj~TAtJ46;TXzgZIr^ z(-JY%pZU9SXKgOEym0_I%>#=r4G%Fo-04>99At!@^BM}DMH#{>^LYB0ru~Fk^(w|V zHT;Uh?MYm=;NwmwNGl!Q@mE+UJ76M>AvlF`5?K`e*m0!Mu#cO)L-c7Aa4q;FIljOA zV={dhPWQ5RviRAdJ%h3T0x$6j#w)Z$hBSJ-gHwnMidiqliT3{mIKA!RH1&UR@~HJX z6+tBH$4Db`MSPG@AE%%(Js=f)0gq-1z8=@85ail zzTtET4XQtHlSbUIVKmG--GvdusN3acjdZ*v?={Vu?@1a)r79d*t2V5Ljczo^yW<^y492xzMmqyABK`us~H9!RbrJJoA0Ycbm4LclBF(E>X497NHtLT>mhswM-!8+ z<)29$1>PC?JGIWjduSAS&pDn*(=PWhWZ$V3mzL5#Uh5#72#Y)ajY@MeuZi5 Sm`DHQ_+CVTM^8ypPyY=%6yziT diff --git a/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc b/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc deleted file mode 100644 index 662bf04ebc65467676dbba34b175252013a465da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 872 zcmZuv&1(}u6n~RV)0(uU7D_<`$3%+pBkQ3D2_i^IX;9L-t<}=nWx6{_w%P11Gn3eQ z%E61Ldhp=Eg9k7ECmw}fWWkdcd#gm~)i;|2OYyzdOo{2^vB7N*I%<1XRf4-4hyHRJk><8 z;dOaHSrpO1*r@9{s+7%E4p=+#c{xjw8eG{!+|7Kowro9G->Pk_*z1+0XI6D%D}RUP zmsVFFS1SWm1r-~j!;9G`Vc3$i6OGc!(tqIF7r-wE#nqYb>jh91QkU0R$H&5oMm&z? zFr|dhBZv)@ifNHSUW{wS_|P}bIAKut8d9%eVPSy^{gLC`xn{&HYLYEoN2UjVW?*{m z{fiIXPvUs$R&V)x?($LjZTX#ZoGW}w7sMob6-f(@^#u}_L86HV e?a=M`{Gpgbm|mmQ=}rwo$Pb`rnbD6(Fn<9P3eUU% diff --git a/infrasynth/shared/enums.py b/infrasynth/shared/enums.py index 5c84936..53faf42 100644 --- a/infrasynth/shared/enums.py +++ b/infrasynth/shared/enums.py @@ -53,3 +53,25 @@ class ApprovalStrategy(_ChoicesEnum): ANY = "any" ALL = "all" MAJORITY = "majority" + + +class MonetizationModel(_ChoicesEnum): + ONE_TIME = "one_time" + SUBSCRIPTION = "subscription" + + +class PlanInterval(_ChoicesEnum): + ONE_TIME = "one_time" + MONTHLY = "monthly" + YEARLY = "yearly" + + +class EntitlementStatus(_ChoicesEnum): + TRIALING = "trialing" + ACTIVE = "active" + PAST_DUE = "past_due" + GRACE = "grace" + SUSPENDED = "suspended" + EXPIRED = "expired" + CANCELLED = "cancelled" + REVOKED = "revoked" diff --git a/infrasynth/shared/exceptions.py b/infrasynth/shared/exceptions.py new file mode 100644 index 0000000..69bda2c --- /dev/null +++ b/infrasynth/shared/exceptions.py @@ -0,0 +1,147 @@ +"""Zero-Django exception hierarchy for the InfraSynth kit. + +These exceptions are raised by services and mapped to the namespaced error +envelope (``API-STANDARD.md`` §5) by ``infrasynth.api.exceptions``. They carry a +stable machine-readable ``code``, an HTTP ``status``, and a list of ``details`` +so no caller has to parse a message. + +This module must never import Django (``infrasynth.shared`` is zero-Django). +""" + +from __future__ import annotations + +from typing import Any + +__all__ = [ + "AppError", + "AuthError", + "ConflictError", + "EntitlementError", + "NotFoundError", + "RateLimitError", + "ServerError", + "ValidationAppError", + "ENTITLEMENT_APP_NOT_OWNED", + "ENTITLEMENT_EXPIRED", + "ENTITLEMENT_LIMIT_REACHED", + "ENTITLEMENT_PLAN_UPGRADE_REQUIRED", + "ENTITLEMENT_REVOKED", + "ENTITLEMENT_SUBSCRIPTION_PAST_DUE", + "ENTITLEMENT_TENANT_SUSPENDED", +] + +# --- Entitlement error codes (ENTITLEMENTS.md §7) --------------------------- + +ENTITLEMENT_APP_NOT_OWNED = "ENTITLEMENT_APP_NOT_OWNED" +ENTITLEMENT_PLAN_UPGRADE_REQUIRED = "ENTITLEMENT_PLAN_UPGRADE_REQUIRED" +ENTITLEMENT_LIMIT_REACHED = "ENTITLEMENT_LIMIT_REACHED" +ENTITLEMENT_SUBSCRIPTION_PAST_DUE = "ENTITLEMENT_SUBSCRIPTION_PAST_DUE" +ENTITLEMENT_EXPIRED = "ENTITLEMENT_EXPIRED" +ENTITLEMENT_TENANT_SUSPENDED = "ENTITLEMENT_TENANT_SUSPENDED" +ENTITLEMENT_REVOKED = "ENTITLEMENT_REVOKED" + + +class AppError(Exception): + """Base class for every expected, mappable application error.""" + + default_code: str = "SERVER_ERROR" + default_message: str = "An unexpected error occurred." + default_status: int = 500 + + def __init__( + self, + message: str | None = None, + *, + code: str | None = None, + status: int | None = None, + details: list[dict[str, Any]] | None = None, + ) -> None: + self.code = code or self.default_code + self.message = message or self.default_message + self.status = status or self.default_status + self.details: list[dict[str, Any]] = list(details) if details else [] + super().__init__(self.message) + + def to_dict(self) -> dict[str, Any]: + """Serializable error body (the ``error`` member of the envelope).""" + return {"code": self.code, "message": self.message, "details": self.details} + + +class AuthError(AppError): + """Authentication or authorization failure (``AUTH_*``).""" + + default_code = "AUTH_ERROR" + default_message = "Authentication failed." + default_status = 401 + + +class EntitlementError(AppError): + """Commercial access failure (``ENTITLEMENT_*``). + + Extra keyword arguments (``app``, ``plan``, ``feature``, ``limit``, + ``current``, ``max``) are folded into a single ``details`` entry, matching + ``ENTITLEMENTS.md`` §7:: + + raise EntitlementError( + code=ENTITLEMENT_PLAN_UPGRADE_REQUIRED, app="helpdesk", feature="tickets" + ) + """ + + default_code = ENTITLEMENT_APP_NOT_OWNED + default_message = "This workspace does not have access to the requested app." + default_status = 402 + + def __init__( + self, + message: str | None = None, + *, + code: str | None = None, + status: int | None = None, + details: list[dict[str, Any]] | None = None, + **context: Any, + ) -> None: + merged = list(details) if details else [] + extra = {key: value for key, value in context.items() if value is not None} + if extra: + merged.append(extra) + super().__init__(message, code=code, status=status, details=merged) + + +class ValidationAppError(AppError): + """Request payload failure (``VALIDATION_*``).""" + + default_code = "VALIDATION_ERROR" + default_message = "The request payload is invalid." + default_status = 400 + + +class NotFoundError(AppError): + """Resource not found (also used for cross-tenant access).""" + + default_code = "NOT_FOUND" + default_message = "The requested resource was not found." + default_status = 404 + + +class ConflictError(AppError): + """State or idempotency conflict (``CONFLICT_*``).""" + + default_code = "CONFLICT_ERROR" + default_message = "The request conflicts with the current state." + default_status = 409 + + +class RateLimitError(AppError): + """Throttling failure (``RATE_LIMIT_*``).""" + + default_code = "RATE_LIMIT_EXCEEDED" + default_message = "Too many requests." + default_status = 429 + + +class ServerError(AppError): + """Unhandled/internal error (``SERVER_*``). Never leaks a stack trace.""" + + default_code = "SERVER_ERROR" + default_message = "An unexpected error occurred." + default_status = 500 diff --git a/infrasynth/shared/protocols.py b/infrasynth/shared/protocols.py index 847001d..dd6a62f 100644 --- a/infrasynth/shared/protocols.py +++ b/infrasynth/shared/protocols.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any, Protocol, runtime_checkable +from uuid import UUID @runtime_checkable @@ -20,4 +21,10 @@ class EventProtocol(Protocol): @runtime_checkable class TenantProtocol(Protocol): - tenant_id: str | None + """Contract of every tenant-owned model. + + ``tenant_id`` is non-null on tenant-owned rows; a model with a null + ``tenant_id`` is a global row and does not satisfy this protocol. + """ + + tenant_id: UUID diff --git a/infrasynth_base.egg-info/dependency_links.txt b/infrasynth/tenancy/__init__.py similarity index 100% rename from infrasynth_base.egg-info/dependency_links.txt rename to infrasynth/tenancy/__init__.py diff --git a/infrasynth/tenancy/apps.py b/infrasynth/tenancy/apps.py new file mode 100644 index 0000000..7fd84ac --- /dev/null +++ b/infrasynth/tenancy/apps.py @@ -0,0 +1,25 @@ +from django.apps import AppConfig + + +class TenancyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.tenancy" + label = "tenancy" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "tenancy", + name="Multi-Tenancy", + description="Tenants, membership, tenant context and scoped managers", + default=True, + category="system", + ) + FeatureRegistry.register( + "tenancy_memberships", + name="Workspace Memberships", + description="Invite and manage members within a workspace", + default=True, + category="system", + ) diff --git a/infrasynth/tenancy/context.py b/infrasynth/tenancy/context.py new file mode 100644 index 0000000..a5b6dd0 --- /dev/null +++ b/infrasynth/tenancy/context.py @@ -0,0 +1,54 @@ +"""Request/async tenant context (``TENANCY.md`` §3.2, §7). + +``current_tenant`` is a ``ContextVar`` so the scoped managers can resolve the +tenant without threading it through every call. It is bound by +``TenantMiddleware`` for requests and must be bound explicitly by Celery tasks +and management commands. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar, Token +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .models import Tenant + +__all__ = [ + "current_tenant", + "get_current_tenant", + "set_current_tenant", + "reset_current_tenant", + "tenant_context", +] + +current_tenant: ContextVar[Tenant | None] = ContextVar("current_tenant", default=None) + + +def get_current_tenant() -> Tenant | None: + return current_tenant.get() + + +def set_current_tenant(tenant: Tenant | None) -> Token: + return current_tenant.set(tenant) + + +def reset_current_tenant(token: Token) -> None: + current_tenant.reset(token) + + +@contextmanager +def tenant_context(tenant: Tenant | None) -> Iterator[None]: + """Binds ``tenant`` for the duration of the block and always restores it.""" + token = set_current_tenant(tenant) + try: + yield + finally: + reset_current_tenant(token) + + +def tenant_id_or_none() -> Any: + tenant = get_current_tenant() + return tenant.pk if tenant is not None else None diff --git a/infrasynth/tenancy/filters.py b/infrasynth/tenancy/filters.py new file mode 100644 index 0000000..19f3bf5 --- /dev/null +++ b/infrasynth/tenancy/filters.py @@ -0,0 +1,35 @@ +import django_filters + +from .models import Tenant, TenantInvitation, TenantMembership + + +class TenantFilter(django_filters.FilterSet): + class Meta: + model = Tenant + fields = { + "slug": ["exact", "icontains"], + "name": ["exact", "icontains"], + "status": ["exact"], + } + + +class TenantMembershipFilter(django_filters.FilterSet): + class Meta: + model = TenantMembership + fields = { + "tenant": ["exact"], + "user": ["exact"], + "role": ["exact"], + "is_active": ["exact"], + "is_owner": ["exact"], + } + + +class TenantInvitationFilter(django_filters.FilterSet): + class Meta: + model = TenantInvitation + fields = { + "tenant": ["exact"], + "email": ["exact", "icontains"], + "role": ["exact"], + } diff --git a/infrasynth/tenancy/managers.py b/infrasynth/tenancy/managers.py new file mode 100644 index 0000000..98e9ca5 --- /dev/null +++ b/infrasynth/tenancy/managers.py @@ -0,0 +1,69 @@ +"""Tenant-scoped managers (``TENANCY.md`` §4). + +* :class:`TenantManager` — default manager of every tenant-owned model; fails + closed (empty queryset) when no tenant is bound. +* :class:`AllObjectsManager` — the ``all_objects`` escape hatch for migrations, + admin, and platform staff. Never called from a view. +* :class:`GlobalOrTenantManager` — for resources that exist globally and can be + overridden per tenant (roles, pipelines, templates, feature flags). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from django.db import models +from django.db.models import Q + +from .context import get_current_tenant + +if TYPE_CHECKING: + from django.db.models import QuerySet + +__all__ = ["TenantManager", "AllObjectsManager", "GlobalOrTenantManager"] + + +class TenantManager(models.Manager): + """Default manager on tenant-owned models. Scopes to the current tenant.""" + + def get_queryset(self) -> QuerySet[Any]: + tenant = get_current_tenant() + if tenant is None: + # Fail closed: no tenant context must never mean "all tenants". + return super().get_queryset().none() + return super().get_queryset().filter(tenant_id=tenant.pk) + + def unsafe_all(self) -> QuerySet[Any]: + """Explicit, greppable escape hatch for system code. Never from a view.""" + return super().get_queryset() + + +class AllObjectsManager(models.Manager): + """Unscoped manager exposed as ``all_objects`` (admin/migrations/staff).""" + + +class GlobalOrTenantManager(models.Manager): + """Global rows (``tenant IS NULL``) plus the current tenant's overrides. + + Never exposes another tenant's rows. Use :meth:`resolve` to apply the + documented precedence: tenant override → global default → ``None``. + """ + + def get_queryset(self) -> QuerySet[Any]: + tenant = get_current_tenant() + qs = super().get_queryset() + if tenant is None: + return qs.filter(tenant__isnull=True) + return qs.filter(Q(tenant_id=tenant.pk) | Q(tenant__isnull=True)) + + def resolve(self, **filters: Any) -> Any: + """Returns the tenant override for ``filters``, else the global row.""" + tenant = get_current_tenant() + if tenant is not None: + override = self.get_queryset().filter(tenant_id=tenant.pk, **filters).first() + if override is not None: + return override + return self.get_queryset().filter(tenant__isnull=True, **filters).first() + + def unsafe_all(self) -> QuerySet[Any]: + return super().get_queryset() diff --git a/infrasynth/tenancy/middleware.py b/infrasynth/tenancy/middleware.py new file mode 100644 index 0000000..9d33b98 --- /dev/null +++ b/infrasynth/tenancy/middleware.py @@ -0,0 +1,137 @@ +"""``TenantMiddleware`` — resolves and binds the request tenant (``TENANCY.md`` §3.2).""" + +from __future__ import annotations + +from typing import Any + +from django.apps import apps + +from infrasynth.shared.settings_utils import get_setting + +from .context import reset_current_tenant, set_current_tenant +from .models import Tenant, TenantMembership + +__all__ = ["TenantMiddleware"] + +_DEFAULT_ALLOWLIST = ( + "/api/v1/auth/login/", + "/api/v1/auth/refresh/", + "/api/v1/auth/select-workspace/", + "/api/v1/auth/altcha/", + "/api/v1/auth/2fa/", + "/api/v1/billing/webhook/", + "/api/v1/schema/", + "/api/v1/tenancy/accept-invitation/", + "/healthz", + "/readyz", +) + + +class TenantMiddleware: + """Binds ``current_tenant`` from the token claim or a tenant-scoped API key. + + Runs after authentication. Rejects tenant endpoints with no resolved tenant + (except the allowlist), and rejects a request whose membership was revoked + immediately rather than waiting for token expiry. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not get_setting("INFRASYNTH_TENANCY", "ENABLED", True): + return self.get_response(request) + + tenant, error_code = self._resolve(request) + token = set_current_tenant(tenant) + request.tenant = tenant + try: + if error_code: + return self._reject(error_code, request) + if self._should_reject(request, tenant): + return self._reject("AUTH_TENANT_REQUIRED", request) + return self.get_response(request) + finally: + reset_current_tenant(token) + + # --- resolution --------------------------------------------------------- + + def _resolve(self, request) -> tuple[Tenant | None, str | None]: + claim = get_setting("INFRASYNTH_TENANCY", "TENANT_CLAIM", "tenant") + tenant_id = None + + auth = getattr(request, "auth", None) + if auth is not None and hasattr(auth, "get"): + tenant_id = auth.get(claim) + + if tenant_id is None: + tenant_id = self._tenant_from_api_key(request) + + user = getattr(request, "user", None) + is_authenticated = bool(user and getattr(user, "is_authenticated", False)) + + if tenant_id is not None: + tenant = Tenant.objects.filter(pk=tenant_id).first() + if tenant is None: + return None, "AUTH_TENANT_NOT_FOUND" + if ( + is_authenticated + and not TenantMembership.objects.filter(tenant=tenant, user=user, is_active=True).exists() + ): + return None, "AUTH_MEMBERSHIP_REVOKED" + return tenant, None + + # Fallback: a user with exactly one active membership is auto-selected + # (mirrors the login auto-select in TENANCY.md §3.1). + if is_authenticated: + memberships = list( + TenantMembership.objects.filter(user=user, is_active=True, tenant__status__in=["active", "trialing"]) + .select_related("tenant") + .order_by("joined_at") + ) + if len(memberships) == 1: + return memberships[0].tenant, None + return None, None + + def _tenant_from_api_key(self, request) -> Any: + raw_key = request.META.get("HTTP_X_API_KEY") + if not raw_key or "." not in raw_key: + return None + prefix = raw_key.split(".", 1)[0] + try: + api_key_model = apps.get_model("infrasynth_security", "APIKey") + except LookupError: + return None + api_key = api_key_model.all_objects.filter(prefix=prefix, is_active=True).first() + if api_key is None: + return None + return api_key.tenant_id + + # --- rejection ---------------------------------------------------------- + + def _should_reject(self, request, tenant: Tenant | None) -> bool: + if tenant is not None: + return False + if not get_setting("INFRASYNTH_TENANCY", "REQUIRE_TENANT_BY_DEFAULT", True): + return False + path = getattr(request, "path", "") or "" + if not path.startswith("/api/"): + return False + allowlist = get_setting("INFRASYNTH_TENANCY", "TENANT_ALLOWLIST_PATHS", None) or _DEFAULT_ALLOWLIST + if any(path.startswith(prefix) for prefix in allowlist): + return False + user = getattr(request, "user", None) + is_authenticated = bool(user and getattr(user, "is_authenticated", False)) + has_api_key = bool(request.META.get("HTTP_X_API_KEY")) + # Unauthenticated requests are left to the auth classes (401), not 403'd here. + return is_authenticated or has_api_key + + def _reject(self, code: str, request): + from infrasynth.api.exceptions import error_response + + messages = { + "AUTH_TENANT_REQUIRED": "A workspace context is required for this endpoint.", + "AUTH_MEMBERSHIP_REVOKED": "Your membership in this workspace is no longer active.", + "AUTH_TENANT_NOT_FOUND": "The workspace could not be resolved.", + } + return error_response(code, messages.get(code, "Tenant resolution failed."), status_code=403, request=request) diff --git a/infrasynth/tenancy/migrations/0001_initial.py b/infrasynth/tenancy/migrations/0001_initial.py new file mode 100644 index 0000000..a9dcdb4 --- /dev/null +++ b/infrasynth/tenancy/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 5.2.17 on 2026-09-24 14:10 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Tenant", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("slug", models.SlugField(max_length=100, unique=True)), + ("name", models.CharField(max_length=200)), + ( + "status", + models.CharField( + choices=[ + ("trialing", "Trialing"), + ("active", "Active"), + ("suspended", "Suspended"), + ("archived", "Archived"), + ], + default="active", + max_length=20, + ), + ), + ("locale", models.CharField(default="es", max_length=10)), + ("timezone", models.CharField(default="UTC", max_length=64)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("suspended_at", models.DateTimeField(blank=True, null=True)), + ("archived_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "tenancy_tenant", + }, + ), + migrations.CreateModel( + name="PlatformStaff", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("reason", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="platform_staff", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "tenancy_platform_staff", + }, + ), + migrations.CreateModel( + name="TenantInvitation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("email", models.EmailField(max_length=254)), + ("role", models.CharField(default="member", max_length=50)), + ("token", models.CharField(max_length=64, unique=True)), + ("expires_at", models.DateTimeField()), + ("accepted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "invited_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="invitations", to="tenancy.tenant" + ), + ), + ], + options={ + "db_table": "tenancy_invitation", + "indexes": [ + models.Index(fields=["tenant", "email"], name="tenancy_inv_tenant__5dfb1c_idx"), + models.Index(fields=["tenant", "token"], name="tenancy_inv_tenant__f9fed9_idx"), + ], + }, + ), + migrations.CreateModel( + name="TenantMembership", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(default="member", max_length=50)), + ("is_owner", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("joined_at", models.DateTimeField(auto_now_add=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="memberships", to="tenancy.tenant" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tenant_memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "tenancy_membership", + "indexes": [models.Index(fields=["user", "is_active"], name="tenancy_mem_user_id_4a284b_idx")], + "unique_together": {("tenant", "user")}, + }, + ), + ] diff --git a/infrasynth/tenancy/migrations/__init__.py b/infrasynth/tenancy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/tenancy/mixins.py b/infrasynth/tenancy/mixins.py new file mode 100644 index 0000000..1390c67 --- /dev/null +++ b/infrasynth/tenancy/mixins.py @@ -0,0 +1,71 @@ +"""Abstract model mixins for tenant-owned and global+override resources. + +Using a mixin keeps the ``tenant`` field and managers consistent across every +app without repeating them. Concrete models still declare their own ``db_table`` +and constraints. The mixin is the sanctioned way for another app to depend on +``infrasynth.tenancy`` (see the dependency graph in ``PLAN.md`` §3). +""" + +from __future__ import annotations + +from typing import Any + +from django.db import models + +from .context import get_current_tenant +from .managers import AllObjectsManager, GlobalOrTenantManager, TenantManager + +__all__ = ["TenantOwnedModel", "GlobalOrTenantModel"] + + +class TenantOwnedModel(models.Model): + """Base for every tenant-owned model (``TENANCY.md`` §4). + + Provides a non-null ``tenant`` FK, the fail-closed default ``TenantManager``, + and the unscoped ``all_objects`` escape hatch. On save, an unset tenant is + filled from the bound context so writes inside a request/task land in the + right tenant; a write with neither is rejected by the database. + """ + + tenant = models.ForeignKey( + "tenancy.Tenant", + on_delete=models.CASCADE, + related_name="+", + editable=False, + ) + + objects = TenantManager() + all_objects = AllObjectsManager() + + class Meta: + abstract = True + + def save(self, *args: Any, **kwargs: Any) -> None: + if self.tenant_id is None: + tenant = get_current_tenant() + if tenant is not None: + self.tenant = tenant + super().save(*args, **kwargs) + + +class GlobalOrTenantModel(models.Model): + """Base for resources that exist globally and can be overridden per tenant. + + ``tenant IS NULL`` is the platform default; a non-null tenant is that + tenant's override. The default manager only ever exposes the global rows + plus the current tenant's rows, never another tenant's. + """ + + tenant = models.ForeignKey( + "tenancy.Tenant", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="+", + ) + + objects = GlobalOrTenantManager() + all_objects = AllObjectsManager() + + class Meta: + abstract = True diff --git a/infrasynth/tenancy/models.py b/infrasynth/tenancy/models.py new file mode 100644 index 0000000..68eabfb --- /dev/null +++ b/infrasynth/tenancy/models.py @@ -0,0 +1,119 @@ +"""Tenancy models (``TENANCY.md`` §2, §6).""" + +from __future__ import annotations + +import uuid + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from .managers import AllObjectsManager, TenantManager + +__all__ = ["Tenant", "TenantMembership", "TenantInvitation", "PlatformStaff"] + + +class Tenant(models.Model): + """One customer company. UUID PK so ids are safe to expose and embed.""" + + class Status(models.TextChoices): + TRIALING = "trialing", "Trialing" + ACTIVE = "active", "Active" + SUSPENDED = "suspended", "Suspended" + ARCHIVED = "archived", "Archived" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE) + locale = models.CharField(max_length=10, default="es") + timezone = models.CharField(max_length=64, default="UTC") + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + suspended_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "tenancy_tenant" + + def __str__(self) -> str: + return f"{self.name} ({self.slug})" + + +class TenantMembership(models.Model): + """A user's membership in a tenant. The only correct user↔tenant link.""" + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="tenant_memberships", + ) + role = models.CharField(max_length=50, default="member") + is_owner = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "tenancy_membership" + unique_together = [("tenant", "user")] + indexes = [models.Index(fields=["user", "is_active"])] + + def __str__(self) -> str: + return f"{self.user}@{self.tenant.slug}" + + +class TenantInvitation(models.Model): + """A pending invite to join a tenant. Tenant-owned and expiring.""" + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="invitations") + email = models.EmailField() + role = models.CharField(max_length=50, default="member") + token = models.CharField(max_length=64, unique=True) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + expires_at = models.DateTimeField() + accepted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + objects = TenantManager() + all_objects = AllObjectsManager() + + class Meta: + db_table = "tenancy_invitation" + indexes = [ + models.Index(fields=["tenant", "email"]), + models.Index(fields=["tenant", "token"]), + ] + + def is_expired(self) -> bool: + return self.expires_at < timezone.now() + + def __str__(self) -> str: + return f"invite:{self.email}@{self.tenant.slug}" + + +class PlatformStaff(models.Model): + """Marks an InfraSynth team member allowed explicit, audited cross-tenant access. + + ``User`` is external (``AUTH_USER_MODEL``), so this cannot be a field on it. + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="platform_staff", + ) + reason = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "tenancy_platform_staff" + + def __str__(self) -> str: + return f"platform-staff:{self.user_id}" diff --git a/infrasynth/tenancy/serializers.py b/infrasynth/tenancy/serializers.py new file mode 100644 index 0000000..92b6a39 --- /dev/null +++ b/infrasynth/tenancy/serializers.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + +from .models import Tenant, TenantInvitation, TenantMembership + + +class TenantSerializer(serializers.ModelSerializer): + class Meta: + model = Tenant + fields = [ + "id", + "slug", + "name", + "status", + "locale", + "timezone", + "metadata", + "created_at", + "suspended_at", + "archived_at", + ] + read_only_fields = ["id", "status", "created_at", "suspended_at", "archived_at"] + + +class TenantMembershipSerializer(serializers.ModelSerializer): + tenant_info = TenantSerializer(source="tenant", read_only=True) + + class Meta: + model = TenantMembership + fields = ["id", "tenant", "tenant_info", "user", "role", "is_owner", "is_active", "joined_at"] + read_only_fields = ["id", "tenant", "user", "joined_at"] + + +class TenantInvitationSerializer(serializers.ModelSerializer): + class Meta: + model = TenantInvitation + fields = ["id", "tenant", "email", "role", "token", "expires_at", "accepted_at", "created_at"] + read_only_fields = ["id", "tenant", "token", "expires_at", "accepted_at", "created_at"] diff --git a/infrasynth/tenancy/services.py b/infrasynth/tenancy/services.py new file mode 100644 index 0000000..7c64ed6 --- /dev/null +++ b/infrasynth/tenancy/services.py @@ -0,0 +1,215 @@ +"""``TenantService`` — tenant lifecycle and membership (``TENANCY.md`` §3, §6, §8).""" + +from __future__ import annotations + +import secrets +from datetime import timedelta +from typing import Any + +from django.utils import timezone +from django.utils.text import slugify + +from infrasynth.shared.exceptions import ( + ENTITLEMENT_TENANT_SUSPENDED, + AuthError, + EntitlementError, + NotFoundError, + ValidationAppError, +) +from infrasynth.shared.settings_utils import get_setting + +from .models import PlatformStaff, Tenant, TenantInvitation, TenantMembership +from .signals import ( + membership_created, + membership_revoked, + tenant_archived, + tenant_created, + tenant_reinstated, + tenant_suspended, + tenant_switched, +) + +__all__ = ["TenantService"] + +_INVITATION_TTL_DAYS = 7 + + +class TenantService: + """Public API for tenant resolution, membership, and lifecycle.""" + + # --- resolution --------------------------------------------------------- + + def get_active_memberships(self, user: Any) -> list[TenantMembership]: + if not user or not user.is_authenticated: + return [] + return list( + TenantMembership.objects.filter( + user=user, + is_active=True, + tenant__status__in=[Tenant.Status.ACTIVE, Tenant.Status.TRIALING], + ).select_related("tenant") + ) + + def get_membership(self, user: Any, tenant_id: Any) -> TenantMembership | None: + if not user or not user.is_authenticated: + return None + return ( + TenantMembership.objects.filter(user=user, tenant_id=tenant_id, is_active=True) + .select_related("tenant") + .first() + ) + + def select_tenant(self, user: Any, tenant_id: Any) -> Tenant: + """Validates active membership and returns the tenant (or raises).""" + membership = self.get_membership(user, tenant_id) + if membership is None: + # A cross-tenant id must not reveal existence. + raise NotFoundError("No active workspace membership for this user.") + tenant = membership.tenant + if tenant.status == Tenant.Status.ARCHIVED: + raise AuthError( + "This workspace has been archived.", + code="AUTH_TENANT_ARCHIVED", + status=403, + ) + if tenant.status == Tenant.Status.SUSPENDED: + raise EntitlementError( + "This workspace is suspended.", + code=ENTITLEMENT_TENANT_SUSPENDED, + status=403, + app=None, + ) + return tenant + + def switch_tenant(self, user: Any, tenant_id: Any, *, previous_tenant_id: Any = None) -> Tenant: + tenant = self.select_tenant(user, tenant_id) + tenant_switched.send( + sender=Tenant, + tenant_id=str(tenant.pk), + user_id=getattr(user, "pk", None), + previous_tenant_id=str(previous_tenant_id) if previous_tenant_id else None, + ) + return tenant + + def is_platform_staff(self, user: Any) -> bool: + if not user or not user.is_authenticated: + return False + return PlatformStaff.objects.filter(user=user).exists() + + # --- creation / membership --------------------------------------------- + + def create_tenant(self, name: str, owner: Any, slug: str | None = None) -> Tenant: + if not owner or not owner.is_authenticated: + raise ValidationAppError("An authenticated owner is required.", code="VALIDATION_OWNER_REQUIRED") + tenant = Tenant.objects.create( + slug=self._unique_slug(slug or slugify(name) or "workspace"), + name=name, + locale=get_setting("INFRASYNTH_TENANCY", "DEFAULT_LOCALE", "es"), + timezone=get_setting("INFRASYNTH_TENANCY", "DEFAULT_TIMEZONE", "UTC"), + ) + membership = TenantMembership.objects.create( + tenant=tenant, + user=owner, + role="owner", + is_owner=True, + ) + tenant_created.send(sender=Tenant, tenant_id=str(tenant.pk), slug=tenant.slug, name=tenant.name) + membership_created.send( + sender=TenantMembership, + tenant_id=str(tenant.pk), + membership_id=membership.pk, + user_id=getattr(owner, "pk", None), + role="owner", + ) + return tenant + + def add_member(self, tenant: Tenant, user: Any, role: str = "member", is_owner: bool = False) -> TenantMembership: + membership, created = TenantMembership.objects.get_or_create( + tenant=tenant, + user=user, + defaults={"role": role, "is_owner": is_owner, "is_active": True}, + ) + if not created and not membership.is_active: + membership.is_active = True + membership.role = role + membership.save(update_fields=["is_active", "role"]) + membership_created.send( + sender=TenantMembership, + tenant_id=str(tenant.pk), + membership_id=membership.pk, + user_id=getattr(user, "pk", None), + role=role, + ) + return membership + + def remove_member(self, tenant: Tenant, user: Any) -> bool: + membership = TenantMembership.objects.filter(tenant=tenant, user=user, is_active=True).first() + if membership is None: + return False + membership.is_active = False + membership.save(update_fields=["is_active"]) + membership_revoked.send( + sender=TenantMembership, + tenant_id=str(tenant.pk), + membership_id=membership.pk, + user_id=getattr(user, "pk", None), + ) + return True + + # --- invitations -------------------------------------------------------- + + def invite(self, tenant: Tenant, email: str, role: str = "member", invited_by: Any = None) -> TenantInvitation: + invitation = TenantInvitation.objects.create( + tenant=tenant, + email=email, + role=role, + token=secrets.token_urlsafe(32), + invited_by=invited_by if getattr(invited_by, "is_authenticated", False) else None, + expires_at=timezone.now() + timedelta(days=_INVITATION_TTL_DAYS), + ) + return invitation + + def accept_invitation(self, token: str, user: Any) -> TenantMembership: + # Invitations are looked up by an unguessable token, so all_objects is safe here. + invitation = TenantInvitation.all_objects.filter(token=token).select_related("tenant").first() + if invitation is None or invitation.is_expired() or invitation.accepted_at is not None: + raise NotFoundError("This invitation is invalid or has expired.") + membership = self.add_member(invitation.tenant, user, role=invitation.role) + invitation.accepted_at = timezone.now() + invitation.save(update_fields=["accepted_at"]) + return membership + + # --- lifecycle ---------------------------------------------------------- + + def suspend(self, tenant: Tenant, reason: str = "") -> None: + if tenant.status == Tenant.Status.SUSPENDED: + return + tenant.status = Tenant.Status.SUSPENDED + tenant.suspended_at = timezone.now() + tenant.save(update_fields=["status", "suspended_at"]) + tenant_suspended.send(sender=Tenant, tenant_id=str(tenant.pk), reason=reason) + + def reinstate(self, tenant: Tenant) -> None: + tenant.status = Tenant.Status.ACTIVE + tenant.suspended_at = None + tenant.save(update_fields=["status", "suspended_at"]) + tenant_reinstated.send(sender=Tenant, tenant_id=str(tenant.pk)) + + def offboard(self, tenant: Tenant) -> None: + """Archives a tenant and revokes memberships. Hard delete is a later step.""" + TenantMembership.objects.filter(tenant=tenant, is_active=True).update(is_active=False) + tenant.status = Tenant.Status.ARCHIVED + tenant.archived_at = timezone.now() + tenant.save(update_fields=["status", "archived_at"]) + tenant_archived.send(sender=Tenant, tenant_id=str(tenant.pk)) + + # --- helpers ------------------------------------------------------------ + + def _unique_slug(self, base: str) -> str: + base = base or "workspace" + slug = base + suffix = 1 + while Tenant.objects.filter(slug=slug).exists(): + suffix += 1 + slug = f"{base}-{suffix}" + return slug diff --git a/infrasynth/tenancy/signals.py b/infrasynth/tenancy/signals.py new file mode 100644 index 0000000..ff17ab7 --- /dev/null +++ b/infrasynth/tenancy/signals.py @@ -0,0 +1,27 @@ +"""Signals exposed by ``infrasynth.tenancy``. + +Every signal carries ``tenant_id`` explicitly so receivers never assume the +sender's context leaked through (``TENANCY.md`` §7). +""" + +from django.dispatch import Signal + +__all__ = [ + "tenant_created", + "tenant_updated", + "tenant_suspended", + "tenant_reinstated", + "tenant_archived", + "membership_created", + "membership_revoked", + "tenant_switched", +] + +tenant_created = Signal() # kwargs: tenant_id, slug, name +tenant_updated = Signal() # kwargs: tenant_id, changes +tenant_suspended = Signal() # kwargs: tenant_id, reason +tenant_reinstated = Signal() # kwargs: tenant_id +tenant_archived = Signal() # kwargs: tenant_id +membership_created = Signal() # kwargs: tenant_id, membership_id, user_id, role +membership_revoked = Signal() # kwargs: tenant_id, membership_id, user_id +tenant_switched = Signal() # kwargs: tenant_id, user_id, previous_tenant_id diff --git a/infrasynth/tenancy/urls.py b/infrasynth/tenancy/urls.py new file mode 100644 index 0000000..81cf467 --- /dev/null +++ b/infrasynth/tenancy/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import TenantInvitationViewSet, TenantMembershipViewSet, TenantViewSet + +router = DefaultRouter() +router.register(r"tenants", TenantViewSet, basename="tenants") +router.register(r"memberships", TenantMembershipViewSet, basename="tenant-memberships") +router.register(r"invitations", TenantInvitationViewSet, basename="tenant-invitations") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/tenancy/views.py b/infrasynth/tenancy/views.py new file mode 100644 index 0000000..d1aa0df --- /dev/null +++ b/infrasynth/tenancy/views.py @@ -0,0 +1,102 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from infrasynth.shared.exceptions import NotFoundError, ValidationAppError + +from .filters import TenantFilter, TenantInvitationFilter, TenantMembershipFilter +from .models import Tenant, TenantMembership +from .serializers import ( + TenantInvitationSerializer, + TenantMembershipSerializer, + TenantSerializer, +) +from .services import TenantService + + +class TenantViewSet(viewsets.ModelViewSet): + serializer_class = TenantSerializer + permission_classes = [IsAuthenticated] + filterset_class = TenantFilter + + def get_queryset(self): + return ( + Tenant.objects.filter(memberships__user=self.request.user, memberships__is_active=True) + .distinct() + .order_by("name") + ) + + def perform_create(self, serializer): + tenant = TenantService().create_tenant( + serializer.validated_data["name"], + self.request.user, + slug=serializer.validated_data.get("slug"), + ) + serializer.instance = tenant + + @action(detail=True, methods=["get"], url_path="members") + def members(self, request, pk=None): + tenant = self.get_object() + memberships = TenantMembership.objects.filter(tenant=tenant).select_related("user") + return Response(TenantMembershipSerializer(memberships, many=True).data) + + @action(detail=True, methods=["post"], url_path="members/invite") + def invite(self, request, pk=None): + tenant = self.get_object() + email = request.data.get("email") + if not email: + raise ValidationAppError("email is required.", code="VALIDATION_FIELD_REQUIRED") + invitation = TenantService().invite( + tenant, email, role=request.data.get("role", "member"), invited_by=request.user + ) + return Response(TenantInvitationSerializer(invitation).data, status=status.HTTP_201_CREATED) + + @action( + detail=True, + methods=["delete"], + url_path=r"members/(?P[^/.]+)", + ) + def remove_member(self, request, pk=None, membership_id=None): + tenant = self.get_object() + membership = get_object_or_404(TenantMembership, tenant=tenant, pk=membership_id) + TenantService().remove_member(tenant, membership.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantMembershipViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + serializer_class = TenantMembershipSerializer + permission_classes = [IsAuthenticated] + filterset_class = TenantMembershipFilter + + def get_queryset(self): + return ( + TenantMembership.objects.filter( + tenant__memberships__user=self.request.user, + tenant__memberships__is_active=True, + ) + .select_related("tenant", "user") + .distinct() + ) + + +class TenantInvitationViewSet(viewsets.GenericViewSet): + serializer_class = TenantInvitationSerializer + permission_classes = [IsAuthenticated] + filterset_class = TenantInvitationFilter + + @action(detail=False, methods=["post"], url_path="accept") + def accept(self, request): + token = request.data.get("token") + if not token: + raise ValidationAppError("token is required.", code="VALIDATION_FIELD_REQUIRED") + try: + membership = TenantService().accept_invitation(token, request.user) + except NotFoundError as exc: + return Response(exc.to_dict(), status=exc.status) + return Response(TenantMembershipSerializer(membership).data, status=status.HTTP_200_OK) diff --git a/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 3a53e2211039902f0ddcf1e9a837571168e7593b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpIxjU rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{FW^p diff --git a/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc b/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index ce01c97710425ff779d4c8f98592357952e76299..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1010 zcma)4&ubJh6i(*HHrt=p+7_(fE`qoS+k*u!A{Djh!6KowXlX-sW_CO6bY@L5$a>nF z;K@J1t2d85dhD@+)DkENUer@zMLqg5vtw65@WH&ieEH`4@{(K~9~(vxho5)0zhZ)D>iWH$PXqWW(TF}@^k{y{V zKbs2Urr@#@tF8Le{*oR5Spj;5uQ4yd2fHS{7f_sb)`rej@x!8T!|VsOs7)Q}Uhnt% zPhROic|(8mD1rXU!U97$yNRWMt5gIuXoXUVjwVGPD|#f1RnQV#g-Kkq^pGDkc^WBA zJPvpQ#Pu0rxxl+}a;E2Ks;+ZMg)gi;l_SM>6|?RNj%wtMhMG-aa@W zmG^`m72rX2Vb9;+$k6ox^xEFazTqB{$30L&uhDX?tO;W=-whbk!;I}Fej1s2l(8o% zkNPVUj5R|cRTRcSoWQbU7PK4jMi7Z9(9+Y4Nd-sjF|L#d8>tE;5GNV)A}*yLdddr; zh%vaVu>lZ%GY@T1oP{jROUjZ1g`9)*^EtG9G*ao_IU22Y@BVOHcO9P~lf`twHg_}P zZ}Yg7)XixM^XhW38B=9MW1z{6yv3WM3RB}O4dcYd7$2kRw^A9e_) zQd~w%K*1p_CU`Ct7Zbvg-=x@vb6JsF5fv)RE$1@{kpmeDH(1MMG3{93#JIqTVmiA3 z^6sgEIF~DAQzKj^{W{05Dvdg4x-cgs`E*`P=dvK#l+z^RFtjQfpHHMSTuNzqwniu< zlN`)31tZczdLbLnCss4LL~5-got@_s!fIAr?7hp)E#`7dLa)|iTw_H2q(WvB%bY^a zrL!r;v7A_m^PI@1IYIHHxcNjOBdUy5JekC5(xUAl&K&SFBSb#^d*BvP3z{)1BPN41 zh)8W)jmr}H!9{d6GvpD95v=LFcCdL%)Dm&kRBB}2Is*z@`EK?1Yvbl^GYvZTz_}dK zYpZ$G60L1@xLTuAktUt0=1~-Bu9kEv(yCL{e3t0077%OERWnbqR@N3E^7g1b(pDp) zkx^^J_bm4kaY|z@z!|inHWWbmUbdl`4!s4m?$qgO9<}K^6}8Xk9U+!t?QC5?^oPHb z@PpP}YGF@OGd7$E9(zw8j#;CRd>v?oab+X58=&>rj>qWT)4f`V+M@PoUG)Oz9Z^T* zg#+ZMtg+W1G4QMe>+G&Mp(vF-rpwH{s8f-bb*h@T8xIDwY1jF)j}M+l!F0)V0Y**1<=3L4=_Cm59H(rHTrmvW{v3mS92J(dWuveRISk|{Xv8MzpC>h6FODR+fjA& zC>04;Yji4VI(StqqiZRQp_&NXB63Y{SIt>B^l|V>9(Zp3+QZz24xUj5=Ktw6_vxcY zK^AVWm90N`6*OWS{ukZw)anL}&XN7vE7gH~weK06>i*A)QZ1VHS9iv_UkZUqj1u~7 zYePp7OLm|%x@10z(gciAy&caz+ZM(E&1*$@dd67oE$UWm=6PKz! z(H&ZzCiLUvP40*oTctLM-qc$fbPPD69tN63M09 z{CuxMaoJQpm(Ge{c1-!DwIj7Q%y&ZYWpko$9-F%`m>629v2cn_vrvjv5Mcm8u_Z(i zY^b}WhVIy5rMu6b_u+^875o@sp%ig#r6%Fy+RXgnI49G$uzjZcpbPlZRO{bq$s zCB%ecTjUZcj)&=BBMuw$PvP_i1CBtt8%z+a!YaU`IbLxXW;vZw>bTdrtQc2qsH3u; zcrusb)_mF&e3~?u8o>L~^CC{WP6+-rOE4OZ_|{AVzJYLh7sd$t{XEtr#g?19!zD#Q zaS2=o2(ZA|!AL1)@F^5}B*zKioH)9Y7DUCG$t^6vtZa7^d=^{{-h+E^#pep?OiFhK zcspiVlRO9ADAwu9zEkXIl)V)Sk0~~u`%Zy_-hikWe`_HRyTb8Dp&^fz8ZRH(i#)yw z3Zv~2I5^2%HYF&O)`Oss`5bg)F7O$}%~rY{T}g7PccU=zMbM@Ur+PG?TZ&l_c^*G= z3d3_kK9?1^xTY4Q>Rvew_FOKt%407y<)7OWGN2jNGFBTw$ z1h`mqg@jX6OmI{rI7B(W6DqmPyu#G#WIUbXkApOipJv6aDQFyPCEj=*dkd9~CGjj2 zN>_o({$PWGcc3zV#mucF1-xe`@#oV%B^+4HEpvh89G`eSu@s2#IrWeORX?p~E+KI0 zC4)KF@B`ZI6auNVkWYxo#oqj?!f2DwojHCShQwZvkN_{Jh<4nhrE%MPM)vlVynV9w zm6G?BPX{)=VcE-;ysQ-CBrm(^onNPS>X5rtW;#nuXEC_#IP&1srlY?&@++oIW=@ru zQ!>+AVtUJm+RN_7ZOlAbVou6Tpu_}#^FI9^ExS8Q?#_+xJaKpJ*pWN%6j|KXVsOWW z+{dK0vy$svajZ-_-VVPRe)odx=_q+RWRG9+_&4}1^7QuYbeVGBXYMf%8lRd?vxK!c z_RhG})FTCkq~V*J^sN&4>JEh%uk1Qma-Ec2OOk8pAJ0fHj7yXA(&C-X(@Vv%9g?zK zBep#)?_d7Q%VoOp{=~hBa{ZxlV+;K4+AWQB>(ovI@^#5=eWkX(VsMLWEgwDc(d`dz zKYael(X*J*yxrRQ(ZpX*d_Rm!N4~bztuM;1<0aSeFKt~L)3U8=yZN--e74klR&Ksn zYQDHmy~E(SJMTH)VYaCbnde}{nl$(4XIX-Zdz3o8BP2HuY?%&!i_PSjf*_r!} zdyX>QTc!`|^Q0SP+E=1|Fi(5k4vpMR_rH7ZyAsv>HA%lc`R3$XQ`?T_GDX8!WvUUU zbRV+!zJ5bCN0b{lQi=i2}&I81u%~kbn437qU zj3eL5k0JF@u@^IO(eNp(JnlEFSX5i69>bddiV9hMVh&sfY&H!KLwj6(`r``cV$+bO z->#6@(DPU|@FC)cJa)(wyJ184F0~AAtY%XzslswzsGKobRRTVSTxeasfd0~GQYNYcTA-HDzWWtlYGx@de5#0%kZMS^xjK9JpXX)(Tz{ikG;~F zYnvVHre|8Br+2J~cHbYkH}LMl4~6xCO~)CDJoEcq3vwTU8-zQe8)Uk*M7K&ugJ04i zxIDo8gMcyXUvv$*&}S~+&{^|m&k>OBxgK~=RbA>-C- zw4RRuvqt4rC0COLT!GyMtm#Jy_cu8Cyid(|7WjM6KK4x1`K-WB7SW7;jS*;V?HvFy z{MBGYXjQ?d2WdBpYV;fM{;WmEpSsViYhB**{~XD@XC#t+W+aFu7^~VNYn>f5W~E6$ zgu%E$5xh71z6ka{_I)uTYux1+5ixFNRqT{TEeR_QWih=U>}HF(&kOGY|$s+zO*Z@;>|QJWa4>p>eBtO)GZ z{;wJd0a;B~U`2KM+sir@P5+1H;0JEsSF1VSc1NL&5#<8CIUurBvtPzW9~6s$_oZ+008sH2@@hA&iWv~KLaSIip3edJUFC5UPQ_d*hU4)$@jW=fK!?AClU|%) zZ%jo;Ja(se{8B5^TU`sW>jGdqAss;G=By~#hK2g@s3xb#ykf+0b@`D5ml@(K64%?sww&y zu1gVZHykS4>t*|~lKq%$@09GF8^>kJU!wdEdw$X)jlL?)q@|_I=EdbrDhp`AV=az@ z6?QjO0F2C>mYCBIO|mOcas|p%{a;;@+WH^mKK=7e&vg)I09))@(V?af9a85->C#Q< z)tSwv+nbHE#mQeCJ}DpWDIM;S55G`4{6aDK)>QfIMfvO){7GlW9$epe{%>X;-;^hl zrOBiU7rwSR-hSiFH)LB&$=324+S4jZVlafcXH#}L-I%T$F$rvSn3*la$@ky z#>-MXvu@vR_y0rd-?skUu^;vuL?!;c#!s&)=D>t^jKmOi0xH__@tK{jDJ$)rlU)kjW%wn+uX0bTlo_KRYst-Imv_+o( zHwU9S2>uquhT{(qcK}QbJ~R#ssJh4Z!Kp&T0pb_@Di0n50Q}UQM0O$9t9xXsV^?wY zXsxOsrZYkXi2^CjkH{koL7Xw4+if@Ru@?)f~vVs(-K^Ur#;a=odpbEe20#<8q=m_HH z$n_OE_DjS-`U}+a6>9$vbm7-%;1}rXSE%D_yIZz5e_?NaaADKlS+ss*MJ8w2-dnai m%kKJeQ!{`*lj$4!WrDCibppAglE1xT0?6^ZCUDBsY5Wf*xs!4L diff --git a/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc b/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index da1dde21c4dedb69d04e61b811f4af374d560188..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2881 zcmb7`zi-<{6vvOhDVhEe#j+#EKk6ok;kxyY)D|+5}h!SN%iiO zR9mJXLkEu>vu5kqv4285S-36%iURFsKu)uC=zB-fRvgum3GnIe_~qT{ecpS=g`vSb zf$Pt|9^C)IAmk71WEb@1)&3WL9+Pe2DjS58ZN*hLly#M>>l)YAb*`@)+*micIYWwM zmbltDarG_o{ZRUT$Iq&kbhs^*1}M!Q%A8bMptO4^^HP}uWxj`UKq?17>GV)Ku5yjc z5B`nY-kdkY@J6^3HNxtbVYMCwAs0?I@X&|e$af7cpMT-kf`>kBo{pTnF`8*^H+EQs z26Y}pVWq=KL{B^I_n`5ZY!R-wgsa;Q*W|_(*toi?CMvGG8aG@WwrPm5Z0>BeuYwx) zY4_RRe{=Rhu5IZs6X12VMb*_^bx~PTx#=3*a!r`u5?8y=zxb`s%R&#!d%iIIpUM?3 zazP~uc{vCfG?@oO;E(6vLtEIMhf2>AdC%L6s*M`99nbr*QLd#=hCOdLpp4go&<`We zqb6)t7@+oR!rTq~T9t{S=P_R9LB%U`PJ^8W_Zdh>K-xyy5aYgILFDX$3HM2f2Jz|Mq2 zp=UTuWVkv*txHItbMb0_2&N<8E_VwF*N}msL135=7#0MEEoT0Ir6f4=J|>6+Ptn;F z|2`cCBWyoSro^lBc2}=js4t=!L(|Q_((yC!6j`tvV36kZr^ZVsquFxCnBE zICo^}h5XxG=~Z0e47@2ELxZ&2ND$$fM*bB6lc2-LeW9D|iS8tmvr2Lvwq;ni1KZRo zwnfF&5LcSb2(3#tzqv*N%*51cFbPXItfN*53)0QstMhYt;px#2FT{j?W7oi>+4s^J z-G8(nw490fPQ1}_im|odya=r8xHuY2Y5Fc2nY%dD7oZzG0Tr{tp9b#*_{{amXQJy8 zX|ZLs#@z&t$)X0W_%fN!MWUe}`beP8eb=E2m>V}iY7$5QgorVnS=4rbSm33QG}!JLXtMm7&8 ZR}UsvQ;iLp>BG{>L22b50v-9U{{#55NR$8o diff --git a/infrasynth/webhooks/__pycache__/models.cpython-314.pyc b/infrasynth/webhooks/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 56dc55b5699cbf942105319340e4826daad473b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7652 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 QLl4hCIR9@3iuB9>1#aU3cmMzZ diff --git a/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc b/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc deleted file mode 100644 index d040d4caa3117c774a29e777f4c5c7f87766516b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3965 zcmahMOLG+GvFEYxT|LoC2;tQW;T7^OuOJLEu}yHmq(BUcD9VVb(e6kZnB7@_GfN_0 zvUyYmPKHZNPAN_iH}Vgh9CMO*L}Hw(kV>eOZ!0eqxpGSPH#5>oQ8}NqJzw|N^L^d@ z^=l5bq~d_%&p$SA8DW5b5E6`#KWWr(QiGeI2p7Pir+7g)FFN9R$&oUIOBdwxAt#i9 z8JJ84uWvmEU?y^0@Ng$OBX+?6C~`L_q2r=QJ?=9*F}@bYwMek$#1&x(veB=J=TcU5 zV@1u;3Z`b;ZfMTX&0mUI1O*)NX3UY&V4+fu zJQG3@!-_Z_2pRbwN^}~I35wJWX4Hu(vJ;;MC!vH;u&~>D?v7q|exT18WrGB80VQfJ z>T_DvbksSN%3_NJ+S}hBNGb_`jgJvb2sN07o8rk|0Thwi2UmmzArDuDPvEqu2qVbj z&qaSOAYE#j%QQz>LSK_XUAe54bT?7d?E*6@T%=b0r&_6E>S{$>G%c;@hV8 zlI<{8E*b>~k?@?wN}A)QRF#{U$f|17wu3eIQhvcI>G_hyv^(1E{3Nq(=>^Bm8|68s z*^6ao;hkB{*7LJk;kI5bHqrAx(PtMd>$aU|`n-WBF6JtWZc%PeT?E_w zzuoiyJdpn@f`T5U$Vy0&^OAxAXo{Ob8J-Do?~jo8D^Y}Pn`;;;d@aV;;@j4|m`TtI z#!S+W85#XF<)-*uE50vy=8d9=+_IOrX$yXG;E_UlU~EJizrjfjZUm78Q^J%uC7l+? zR5a#!1Z5x(Q}S+91;*(baZJNxaDqqhs65_o_&flUfd!j*6k%FO!cj3uY8gz4sFjqJ z8Kpl@n@tz_tb^;??>WBC^x~=hOUOzayAf5VLlP%$p>{T0c+TOD=b3mqwTRmb*^;B|6UCZCg$w zTcYiu(T`yFoTdnr$MOQ&N@NLSb0dB_$_hNLklcY}FDks8*v)k$75A zg{oMU)J+P?A8Lb-jsaA{O}mhRcMOz0JW1BuF|KtWJ-vg7=rNgR0U?kw0~fys5QDcs ziP7J2UpI`4*bwx?A=r#gywpB0#0>0*4F4mJ)6#x0glwGAm1FG`t{~WRx$4YX)pGG_ zb#}9kp`W~sJgzW0MV)|Qt5oyv=x!&Ln~G(Wu~=;pqXDt78P51}7=M&@8w!HvRH9%r zki+XACoypBmL4X9uE=BI@S1RK=(v9ZrGd}-w(4OnsC&7hVOKP#ut1%Gi6S%AUqW4@ zbIdB4SQfJq6(yrou^4K?sNPXVFtx?3jD^}Lx}sio#fphP$EGt!aQGQu!qiH$MeU3` zygg9#lHuegOsk-o_8FXbT<_q^_9%iHJpV*r>p8Lf;nn32rrt>64I%xn^nnM~@AXIe z(%C1ZdNgrKJ zzq^ut_hmBmu=ja#_|d0N&OSeQ@Pmc6T~%+pk6H8t=eHSksU;C<<@ZTnW+4m@o;u$CG8-JRdwSsMNF>gu`cE9b5^ zK|611Q)UL6YMy+Mo+O$dbC7N@#svHj@Z=oES1~J)bMQXO4~DLJ zoji?NO{%y-l^xn18M`tarLCyT!$R5GdH?Je7av@F*4n=u?cdTMXg@aXKY{$a+uv*C zcej7g-ftE}M(VK32>!p>Nkp42lXt`~Q|%9i9{g;%W8_8Z;EFu7nt?Xc1gAu zAB_0fJ_3CN=r}jQ+tQNmELcUBCGtfAR|xDUKzGZi#Ik+_&F$(S5eaZ%W^uyX)wR*F z+7Dla+UjB?bn;0@9q{qx6n`u$(+xr!5`^3Ndc6Z@8zR U0#rau)Ct{a84?B>fPf$IU&!P_>Hq)$ diff --git a/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc b/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 563e021b0835880bbaa1b48ec14a44438cdd046b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3740 zcmb7HOK%fb6u$HHBaV|mfCMKv!A+>4GSGq&k~Csbic^VzhICT|BgdYIX(k@`&NV3Q zCW{KO=%P!OEa4yYXCy6B(kpewNb+_xMVBFiGI^ z=U-UD@msvs}H9+bS$fQ8V02z-!4hUodkjV&SN+1USnTkLT3gjRl z(-FwDKn?+N7?2lQXAVl?*@umh9Lb;h8$0q6AMh+{mmK?hi+XvD53N+!-Fmfhr&_7G zc9ro|(D2NH+VK546a8OUj{VG{+pU&x#AtwdSYP+b)UGkxt(Jp*;IG=O9A1XP4tY$N zWDq7lNijtnLxN+-H)J2hR6}8!p~9v$em064MRY zPzo}O87hk#8qBWqZ133%_bgW8G1DwLj%o5_nOY@gRm>9O$$G8QEWahI`2>%fX0^0s zA_KntCyT+&yZgb{Q6C)LySM%Y(sq*ALNM{Gwgmpe1j+%AQ_*ZmmCc-A(Um8^wj%Hdpa1 zGzIWM_llc$|*Fb(Tt)I zfH5o~${%zdOQ>*RaIr%)FUXOsX!Gw07$1n34(@v0j?$ly( zQU8V%AduY0TOz>?k>PfzJlB7AaYKAJBsq@6FAQ?60+Ea1_^6UaU{#yrS}AkKLvJokPt@TW$0;h&p5gV zqAWyVLzxRCstMVuAX_bzt#0Vxx)``F&O>6_Lqdxf9=Dp|W-Tvs-E-@-Z1F^R`#W~l zOWG5Cr5%#jeFr{yC#`qCLj>i^Gvj7pv=yIVEK@`H6>PIWl_i8;Ln;2eBIPTRw0VK;NDOZe<6&jPNB z1_MzjPeoh67`fof6T3US){B>XBroH^d)`#s2hwu6FD(~(PJWD)Xo2AgPfW&50Anr@ zl97!TSXVMW!&TAbu`r27Jb~DBA_A^O5^y%~5hhe0=>6+`y`SkmbIbL7fLdh>@-~sT zq-xYHTb?IseT9LK!HK!J1>o!O4Y`;IxyZj+-pwqWR2Xx(DjJkpn=lqp7>hnH*xkWP z7*4){p0;;u4n6k(bB$x4X>z04!t_W)4AOzPNCy-?*7UaFyR^k*Uso!y7*Bll@c!zT zn9=cAt+eg9B`7?FTn}>auCK`FFp8qV^Lm)wL&BCG-%sa$P3L|~&+Oc7ul;z}cMc9% z&5!UjwLE4*TDWCBcj+b-d(cIy=~1CGL9hsaA|=7cjSt2p9wG1J1`pl))DV#F#jl$B>h2Vk5pZf-xDy6Q>rxoI2IR8s{0Vk0d}v#);G&)(e2-u#z9MR@K%L?Y+L diff --git a/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f8288d8808259ed884270f3486732408f4a5b90c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370 zcmdPq1%v$g=~#~=<2FhLogy?~4chB$^GMo$J0#u7$(hFC^L1{0`s2@?vB z8HLA!!eb3$QfAO(ehJd4$#{z`I5RyjF~?7n?G}GZR$^XyzFtaZaY159az+s|P{}Rn z{L+%7{L;LX_>|O~%(B#?%J|~a9O=-p178e*KZc56}mbkzm Rc2i1Ww#Fp}@gh)A0|3nXW`Y0! diff --git a/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc deleted file mode 100644 index cf9e56c2c6b56c664f3cffb2979fefdc6788978b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2429 zcmb7FU2Gdg5Z=8z|HO_jcHJ~p)3ga`-JBX~@&k=14GI;72o&6Fuo`4)UF=Kj#=bLq zmnNx%WC0JM1=SU;5PT1 z$0nKEvgQ+9T`u%`oHYWk4XvmEe%%k5CUqNXil`>_6qJRWuB2&`na!()spJb|)D1hc zM3Xqm8z!NARx>3`-BUWrE2hD2`vQG0n$h!WG_MooqB0YW5&eRiGL2|fn<9#_pqZH$ zCly1DPAaJxRZDM?N9WYZjIPfZQ6oF8Dd0&xR#>3T$)uubx~Z6IGD!m9#0{U(3quh- zMFQ@6#D=)^ywWW&Ed0*7CQr*8XGAM=2*$jB&wdMKuUFFQReo1U|y#K+^|V zWDlZ*lL6F-W2_!gTbGSpXt2f%)+`EqoNq=lisNR~&&QarXUMTA2nqHei()L(&rlcS z`~U3YLc*#{W)^FH#u(Va+M8o7JM7g&1(%Gi`*Lv?+?Uk@Pw}x!oO$w+(0j4(q~s;c z2^Cm1P`9e3^t4KaOkPP*UQ_3&C!-jdTy_!)D5Lbh{0eQ#sPpOUG?b3yChYlyy^^pJ zq?~C`4^)+*Ov7Qemch0okw|A}CtIl_*+DO#)zbEo~51IucKsn5mts zNnOuLnD{|rgVPGE1tDu-STg!xU^oil{zq(BIlIZG?+2{u*KTXN`@x6yoXJ^Ac2w%O zh9g*Rtf&Y8k0NSEaV}N|5A4-QMsM6%Um}~)FC;6$2~HC zy!P6HZ``-zt?SX-{@NXN|06O5eX9;<`~MYSd<M{zstZ5ggZ&XXG^KTNO6Y7gAxmnd- zizB8&65zAQeK%ftPViJj1^OQzMv2_S?e#&4XBU~P#_1GmJ3u^pD_(+BE+O# z0FkvKd#y8ph1R9dQLj2*fWIg;O`Dxn5;hIig`gp^j%1PDmVy^mlAT&$8uot_c?t9^ zC{@^a8>~t` z8QFj8FuHSi&#Bk>JHr@836DuQ+Y2C54mAlgsY|OkH*w6AnSK+eE6$3HT%D&AjIVnirCp&iC z<)&XpqnsK{ZF)t&?v)>S<$d4r_!Ea5JR&@p4DkjTg4sT3k`i$mMA!z?AJ=&|!hPZ* zMi0XX1I2$sUkN=P)r+h|oqg{3Uf-*k#lpJbbkeVgHKzs`8}VSx$pK#O<#q^bPB^`W zM*K4aNHoKmS3{Zh`q0;zTi<<0kPRJ2sgpy|bJWT(PW@@0A@fSlhL1&-1x01ZG)*;yR2|Q7#oZ zu_1NGr!|{1(awDEfnM=zM6dbOZrD|Qi~4({#F_4RJJe=}96B1v z>ovbzcgY5wL$TZEugLs!8j&RF1>~PW{!h4jp~NKl0+5_#Au;{kY)d@>_3N=O#@gf8 z+l%jZU}GTPYA-$Lz{7!jqkVIu1Mdywh4zhm9ata8bM5(j2iDG1h{jK}#S?AjxiKB>l$UF(s;=MpRvHCC|l_Psc$@ebe~i=-Z=XnE5hEU&pHd1@Jp_ A{{R30 diff --git a/infrasynth/webhooks/__pycache__/views.cpython-314.pyc b/infrasynth/webhooks/__pycache__/views.cpython-314.pyc deleted file mode 100644 index af02c485c28b465070d4c24ec6ac3ab98e6fd114..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5026 zcmcgwO>7&-6`tkqlA^Q}DT?}+C0VkmUrUiK)N)eWk=#{MOT?NH3`Fu~MXoKz6sgP( zr6}k@hoJQ_EeceZ0zRfaxu@pd9D3A7E=Vj~q(FP=&8X-)=%syc$mL&D8}6Y4@$Jle zZ|BXv_syF(nrLhF6KMbbAoKAr1BCn)2X=E;I;X!72stG8h$5^ISzvs zi@9VsbMvut#Up!}7y5$YS`lR*^T~eZms?ni9AE*tm9=u2d!4;yo7hQYK zJIj4Gz%~7#+436AL9Q79%~sI7UYpDARp%Vynr)ydy=Kl~t{DW)_Seig!Zkyn83s++ zwpn{tt&b`lBP162JBnfs%VRK&ROY8fF&J5s*H!_*r{*CN`C9N1Y@@!krvUE~o zdMYMZqM{qSxvZhb+*V{IyOqmlQ_I=ZZZ4f=x6&D=Q!8Y%_u$0}uX8QWxbBjkN$=_O zpqbw^M~|(4C%b7U9 zOnpbEX)Tlf48E(=7NLZjv1n9LX4kjs10F=v$+H1DIXB`lAM`xBRtL^B&A*->VC8{b8RMqmU>P{||&tN~G zs-NVwOhqE8>isk|SSFp-vpH3zL0Ez1&24?GCz)Y6wM@nmKgk1x0IswXg+ZelBsH1Q z41;=*9op6DPTDX4Ic2tP&=wR(_c>7E{W9NbHFS+8x52QUNf}l|RdHLSld8rTO>gCy zZooqN;N4UB5$pY#?c9z&vy-FRp7wAiPIG*5GwJMosu>4awmr3_8T!nYmVBsZQ+52A zeSK>?mwRZ;@C`S;dq8_&MotExW6T2iDJe>W1?hq*T_{KsrZjOPO&!X`=*!E?>g`f9^m+t^aGfsbV@@ ze@fe)o`Pk)(#LJ6fcc~uykX=uIogLj?&!0?yMR(8Z^O?(N28B>3(|;*S!B+X=AJz?tWvC?gI1Gv@A!($8Mh}fnU#}m4#tvg^2=R(E$t}Ee;cQmQMQ1MRUQCexc?*F1C zHfVVgp0g|}KE*$8+w*Qfe_^_Ay$v&GorGAMH6rFVY^p*DjwUHBjj9tJn(dEY95jT zn|-8y6#y>xj8JC-vr$v4*~mNk$p}f4MQ1-r3$a$HUR^1TX$j~^R7)GWs(+f)d4(Ty zP~0$t?%Y_2>l_b)9XND zA&QWri$E+9R<{S2f)uT$X3O~iO5MOxPB&I_Z28l)!6@#e@&ZG0A`w^TFU_l~YYFw% z+6SvkRvVuIOBNNqK`{eao+QMKoh5&mom(1nemyDxVSm}QE zGYCwvXZWZGE(b4N18)C62#|6Kgqo|dF{_ONN>sR{?CASB`jDfOVxF;j8YR=UyOTlr zdNFHT(Izbz@_zD{spKmp(mXl=lrkb9vQ>*@~C+R!a@L z2IWN#KY;n(i=Y;!VqV!>UL*Y=tHES^vjlGA%=UTD?^IZHTOA6dgtctuph32t$b5`hvGdNV_^Im2mnA3z9kdilIwpalP_H!!SR9sdD&L$d0|L^ zya*CuvgC0K{YTq{fw#=jykHJ5AZMf$bO}?avuF-1mIw?=ZEj)o s=x(8Z&g`E_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpAAx* wlwS&zi;vID%PfhH*DI*J#bJ}1pHiBWYFESxvluL;wH) diff --git a/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc b/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc deleted file mode 100644 index 52e57383a6a4a5a8ea705ac0fae49af8eb17f220..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1324 zcmah{&ubGw6rS0gY|?6L?GLM>6fKBB?Sk#WgZLv}S_nl~4T7}8WOqVhH@k6WQpqg` z!GA%I9((lY(f>eC>Ks(?d!t_plu%jhD&WjZ7cvgurU zOh^Q$C&(?@h8k|u`_OTQE+7pUpyTHqI}m8gJu!$=5!rHEibyJLJuO2ejr9QfDyh1b zMV-)C7bvl;nWiz&zjzyurD~l&6Qb;kVPS^t-7N+hWVVsnQ2BG}`YZ zEhY3IG3^UY=+1A7a7!l9o^$_^Y_`*M3-4yF`|Z4Uz0@1ngPe;bNsTa)bGz^_LrZ%v zcu}|R(}?~X7!yr056vTUai2{2riJ2Ar=c61`aDDOfVz?StL7jeTP(; zWm?89H3sJjRj3XCXpB7rOHG4ey=|#Ft${tZPj$HqRjLt&AQWPKM@!xXB*j>~PC7;XkVnb&#{POFZZgrAzo?J2Ym0~9mMidQpuItzd;>Z1S| zceOXH_I&X*tG!?VFK1z!myKoau6<}z^WapNrs}F=XyZ7tnly&~A+6j~X(+X}$M<2a z@L_Aav|Y{L&r+{f$83f3M08OU3IBfV@&Kr(QAz)5@3gE{R81K8%%tug| zOT5{4to{jiwQTpHATWzr2VuFQ87ibCuPV!jyk#`!!m3=(8@r;;f&(AdM*zOMl+ur6 R@taqoH|W!u?*#u@+;1OvG%5f9 diff --git a/infrasynth/webhooks/inbound/handlers.py b/infrasynth/webhooks/inbound/handlers.py index a33d913..cdd170a 100644 --- a/infrasynth/webhooks/inbound/handlers.py +++ b/infrasynth/webhooks/inbound/handlers.py @@ -1,9 +1,59 @@ +"""Inbound webhook handler contract + the built-in HMAC handler. + +An *inbound handler* is the extension point an app uses to interpret a +provider's payload (GitHub, Jira, …). Verification is a hard gate: by default +every handler uses the shared HMAC scheme (``infrasynth.webhooks.signature``) +and a provider that ships a different signature scheme overrides +:meth:`BaseInboundHandler.verify`. +""" + +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import Any + +from ..signature import verify_signature class BaseInboundHandler(ABC): - @abstractmethod - def verify(self, payload: dict, headers: dict, secret: str) -> bool: ... + """Base class for inbound webhook handlers. + + ``verify`` defaults to the shared HMAC scheme over the raw request body; a + provider with its own signature scheme (Stripe, GitHub, …) overrides it. + ``process`` receives the parsed payload after verification and returns a + JSON-serializable result that is stored on the event. + """ + + def verify(self, payload: dict, headers: dict, secret: str, *, raw_body: bytes | None = None) -> bool: + """Return ``True`` when the request is authentic. + + The default implementation validates the shared HMAC signature. The raw + body is preferred over a re-serialized payload so the digest matches + byte-for-byte what the sender signed. + """ + from infrasynth.shared.settings_utils import get_setting + + header_name = get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature") + tolerance = int(get_setting("INFRASYNTH_WEBHOOKS", "INBOUND_SIGNATURE_TOLERANCE_SECONDS", 300)) + signature = headers.get(header_name) or headers.get(header_name.lower()) or headers.get("X-Signature") or "" + if not signature: + return False + body = raw_body.decode("utf-8", errors="replace") if raw_body is not None else _canonical(payload) + return verify_signature(secret, body, signature, tolerance_seconds=tolerance) @abstractmethod - def process(self, event_type: str, payload: dict) -> dict: ... + def process(self, event_type: str, payload: dict) -> dict: + """Handle a verified event and return a JSON-serializable result.""" + + +class HMACInboundHandler(BaseInboundHandler): + """Default handler: verify the shared HMAC and accept the event as-is.""" + + def process(self, event_type: str, payload: dict) -> dict[str, Any]: + return {"received": True, "event_type": event_type} + + +def _canonical(payload: Any) -> str: + import json + + return json.dumps(payload, separators=(",", ":"), default=str, sort_keys=True) diff --git a/infrasynth/webhooks/migrations/0001_initial.py b/infrasynth/webhooks/migrations/0001_initial.py index 21c9a24..86305fc 100644 --- a/infrasynth/webhooks/migrations/0001_initial.py +++ b/infrasynth/webhooks/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,9 @@ from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("tenancy", "0001_initial"), + ] operations = [ migrations.CreateModel( @@ -15,7 +17,7 @@ class Migration(migrations.Migration): fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("name", models.CharField(max_length=255)), - ("slug", models.SlugField(unique=True)), + ("slug", models.SlugField()), ( "source", models.CharField( @@ -33,29 +35,20 @@ class Migration(migrations.Migration): ("is_active", models.BooleanField(default=True)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "webhooks_inbound_endpoint", }, ), - migrations.CreateModel( - name="OutboundEndpoint", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("url", models.URLField(max_length=1000)), - ("secret", models.CharField(max_length=500)), - ("is_active", models.BooleanField(default=True)), - ("retry_policy", models.JSONField(blank=True, default=dict)), - ("headers", models.JSONField(blank=True, default=dict)), - ("timeout_seconds", models.PositiveIntegerField(default=30)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - options={ - "db_table": "webhooks_outbound_endpoint", - }, - ), migrations.CreateModel( name="InboundEvent", fields=[ @@ -75,11 +68,47 @@ class Migration(migrations.Migration): to="infrasynth_webhooks.inboundendpoint", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "webhooks_inbound_event", }, ), + migrations.CreateModel( + name="OutboundEndpoint", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("url", models.URLField(max_length=1000)), + ("secret", models.CharField(max_length=500)), + ("is_active", models.BooleanField(default=True)), + ("retry_policy", models.JSONField(blank=True, default=dict)), + ("headers", models.JSONField(blank=True, default=dict)), + ("timeout_seconds", models.PositiveIntegerField(default=30)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ], + options={ + "db_table": "webhooks_outbound_endpoint", + }, + ), migrations.CreateModel( name="OutboundSubscription", fields=[ @@ -97,10 +126,18 @@ class Migration(migrations.Migration): to="infrasynth_webhooks.outboundendpoint", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "webhooks_outbound_subscription", - "unique_together": {("endpoint", "event_name")}, }, ), migrations.CreateModel( @@ -122,6 +159,15 @@ class Migration(migrations.Migration): ("next_retry_at", models.DateTimeField(blank=True, null=True)), ("created_at", models.DateTimeField(auto_now_add=True)), ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "subscription", models.ForeignKey( @@ -135,4 +181,26 @@ class Migration(migrations.Migration): "db_table": "webhooks_outbound_delivery", }, ), + migrations.AddConstraint( + model_name="inboundendpoint", + constraint=models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_inbound_endpoint_slug_per_tenant"), + ), + migrations.AddIndex( + model_name="inboundevent", + index=models.Index(fields=["tenant_id", "is_processed"], name="webhooks_in_tenant__9d147e_idx"), + ), + migrations.AddIndex( + model_name="outboundendpoint", + index=models.Index(fields=["tenant_id", "is_active"], name="webhooks_ou_tenant__2c9365_idx"), + ), + migrations.AddConstraint( + model_name="outboundsubscription", + constraint=models.UniqueConstraint( + fields=("endpoint", "event_name"), name="uniq_subscription_event_per_endpoint" + ), + ), + migrations.AddIndex( + model_name="outbounddelivery", + index=models.Index(fields=["tenant_id", "status"], name="webhooks_ou_tenant__7f1857_idx"), + ), ] diff --git a/infrasynth/webhooks/migrations/0002_inboundevent_external_id_inboundevent_result_and_more.py b/infrasynth/webhooks/migrations/0002_inboundevent_external_id_inboundevent_result_and_more.py new file mode 100644 index 0000000..ea2df70 --- /dev/null +++ b/infrasynth/webhooks/migrations/0002_inboundevent_external_id_inboundevent_result_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.17 on 2026-09-24 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("infrasynth_webhooks", "0001_initial"), + ("tenancy", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="inboundevent", + name="external_id", + field=models.CharField( + blank=True, + default="", + help_text="Provider event id, used for idempotent re-delivery handling.", + max_length=255, + ), + ), + migrations.AddField( + model_name="inboundevent", + name="result", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddConstraint( + model_name="inboundevent", + constraint=models.UniqueConstraint( + condition=models.Q(("external_id", ""), _negated=True), + fields=("endpoint", "external_id"), + name="uniq_inbound_event_external_id_per_endpoint", + ), + ), + ] diff --git a/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index 21a0e67da104ac76ce83d301aea10d17ba1ffe17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5591 zcmcgwO;Fp~6_$UnjWJjz27>X!KNwz25}O|f^7lfB34#1FB6*9~f=0G3phQT1(lrU0 zX(x+L*Kf&m({$D8bdx2^EVESfG-J=*G}CU{9n)mGeLa$F5RGA+nZD;tq zzH@ai=X<(?6#RaAB0l}=07d=7>@grYvtn6m8nEvLkA24-jBA9-cKpliKFqpFa9h|^I4sDLN_^Wgk3_X>L+va0 zWBoo}?KJ0KgSi_FN8ye&-%3vd<+y=AZpO0~Sm|{PHk$Cv1^>KE2jiRd z+Jn<>#t-OdEA;wLOkmb^i0cGge=8i=3_5UKC)vwU%+e#*`VH@r}dbGm!*8dEgHmB^e&(JRERFNWZqVN)t&<;{cvWi%V+eXA|Ub8OP&2cYS5lNeP zi+EMBx(Q>xsw8}0MZ~AdRi%tz&*v|1$wC>aN*DwM;|M0h!Y9~Z5&sq^@gh=4r=YT2 z84Ek6SwD|9xT=Uru;CWVVHmbOmE`9v$MYmut?;JH_OpP+0-lioUt%#=5)m1Ej!Ijy z{7hv9n?8#g{Ied#+(HuHhJZv~C4RYLZ%OG#ReU^Bo~j}W|Aeg#34zxTn2Ib458PF$*kZ2~5Whoq$ox^r06d)J)3`f}8}Ad1LWz1(7a= zd(Kw4T~X#BClDJ5zW~niBm|v`B11S;=yf1Pk(I1V88N-+QcxK|2+f#~&>_l@Q^Va-x7oy%cQ^g z;!(IQUeRVuSF0tptO!=9n`|v1xviuL5|3Wk8lfB1S&X(T<}{>Rb-b*;%(U{i8Q`YF zWcbjd<|Ec=hJkbMtzZc3ZT(TZ`?pLd2Q>DbeYB$VxHaI~9+4>f(JG@ggbsJ#J z$}&SWL;|d}#IP(0vMd}Bt0IDSh-JU8a-yjg=!IE!Lr_#K3KEiJ81M^{fMKN~As$sA z{YWZ9z!dX9Lb{69()PeSYbxg9kA!XsoAa>fEtTReUgqb@4+({X2yC_f);7_tl*bXZl{Yi7`mp>*Xxnk-eN6& zL62Y9_Zac(8vRW@65rdX4NvOBllvKCcuu2l)+1l-?bH&}dSd!*)JQC9^c~00{$nF? zOQVYi*}iDcL5_+HY4O;775Qcq3pd*AX#>Yhg5cTjy(02*8&-5_Xh_U;Apw2|YRSV{%HPr|S`GU|LU3@833(W{m3* zi&;TW751MPsiH)=k-VePON|#V>8VTm3vaQJ zy06g>K17Et$&BjJ(R%vapR<3;*3wh&(^IwdwfE_3wRG`)x>z5*SRcCi$?r9F=&(&z zh5FFEgH<>TtkM>%>vcM%Wo~FAa|azBQ1NI#(91dKovz};iuy60)95_Zwkfd%J+ttx zpndzDk@=NI|GFMYz8-^M>*=ZesFA**(Q^%f&g${mw=N@YrrTN5B)L^jj@L&<9Zu6! zZ^|??qo-%~AHVY&=?5D9uw|Gm-TZI*)9s{xvOaX%G3Ur21Zu`9{3{8F&u`qnb@lpw zcUO^{i(b}82JK}$$Cq;UUo>;gwOrXU*Wb>==hhd=`Uu&prnyGjrYa^daNYjk-B48M qnty;)Q*b%(xLmG}KFXE)8+GoJujqF9ehAj+%zHZXAIj`mCjJW}PbuO6 diff --git a/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index ec9d1bc636cd15e227ec4c83649e94f7b09c195f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpIxk< zo0(peSdy8aSF9f&pP83g5+AQuP dict[str, EventDefinition]: diff --git a/infrasynth/webhooks/signature.py b/infrasynth/webhooks/signature.py index f11c700..2d92f1e 100644 --- a/infrasynth/webhooks/signature.py +++ b/infrasynth/webhooks/signature.py @@ -1,12 +1,17 @@ -import hashlib import hmac import time +def _algorithm() -> str: + from infrasynth.shared.settings_utils import get_setting + + return str(get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_ALGORITHM", "sha256")) + + def sign_payload(secret: str, payload: str, timestamp: int | None = None) -> str: ts = timestamp or int(time.time()) message = f"{ts}.{payload}".encode() - digest = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() + digest = hmac.new(secret.encode(), message, _algorithm()).hexdigest() return f"t={ts},v1={digest}" diff --git a/infrasynth/webhooks/views.py b/infrasynth/webhooks/views.py index 91d0286..d91d0d0 100644 --- a/infrasynth/webhooks/views.py +++ b/infrasynth/webhooks/views.py @@ -1,7 +1,10 @@ +from django.utils.module_loading import import_string from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from infrasynth.shared.settings_utils import get_setting + from .filters import ( InboundEndpointFilter, InboundEventFilter, @@ -34,6 +37,9 @@ class OutboundEndpointViewSet(viewsets.ModelViewSet): filterset_class = OutboundEndpointFilter search_fields = ["name"] + def get_queryset(self): + return OutboundEndpoint.objects.all() + class OutboundSubscriptionViewSet(viewsets.ModelViewSet): queryset = OutboundSubscription.objects.select_related("endpoint").all() @@ -42,6 +48,9 @@ class OutboundSubscriptionViewSet(viewsets.ModelViewSet): filterset_class = OutboundSubscriptionFilter search_fields = ["event_name"] + def get_queryset(self): + return OutboundSubscription.objects.select_related("endpoint").all() + class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet): queryset = OutboundDelivery.objects.select_related("subscription__endpoint").all() @@ -49,6 +58,9 @@ class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] filterset_class = OutboundDeliveryFilter + def get_queryset(self): + return OutboundDelivery.objects.select_related("subscription__endpoint").all() + class InboundEndpointViewSet(viewsets.ModelViewSet): queryset = InboundEndpoint.objects.all() @@ -57,6 +69,9 @@ class InboundEndpointViewSet(viewsets.ModelViewSet): filterset_class = InboundEndpointFilter search_fields = ["name", "slug"] + def get_queryset(self): + return InboundEndpoint.objects.all() + class InboundEventViewSet(viewsets.ReadOnlyModelViewSet): queryset = InboundEvent.objects.select_related("endpoint").all() @@ -64,6 +79,9 @@ class InboundEventViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] filterset_class = InboundEventFilter + def get_queryset(self): + return InboundEvent.objects.select_related("endpoint").all() + class InboundReceiveView(viewsets.ViewSet): permission_classes = [AllowAny] @@ -72,30 +90,118 @@ class InboundReceiveView(viewsets.ViewSet): serializer = InboundReceiveSerializer(data=request.data) serializer.is_valid(raise_exception=True) - try: - endpoint = InboundEndpoint.objects.get(slug=slug, is_active=True) - except InboundEndpoint.DoesNotExist: + # Unauthenticated inbound webhook: resolve the tenant from the + # tenant-scoped credential (endpoint slug + verified signature), never + # from the caller. + endpoint = InboundEndpoint.all_objects.filter(slug=slug, is_active=True).first() + if endpoint is None: return Response({"detail": "Endpoint not found"}, status=status.HTTP_404_NOT_FOUND) + raw_body = request.body or b"" + max_bytes = int(get_setting("INFRASYNTH_WEBHOOKS", "MAX_PAYLOAD_SIZE_BYTES", 1048576)) + if len(raw_body) > max_bytes: + return Response( + { + "code": "VALIDATION_PAYLOAD_TOO_LARGE", + "message": "Webhook payload exceeds the size limit.", + "details": [], + }, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + payload = serializer.validated_data["payload"] headers = serializer.validated_data["headers"] + # Signature/event headers normally arrive as real HTTP headers; merge the + # optional payload-level ``headers`` map on top (payload wins). + normalized_headers = {str(k): str(v) for k, v in request.headers.items()} + normalized_headers.update({str(k): str(v) for k, v in (headers or {}).items()}) + event_type = get_signal_header(normalized_headers, "X-Event-Type") or "unknown" + external_id = get_signal_header(normalized_headers, "X-Event-Id") or "" - event_type = headers.get("X-Event-Type", "unknown") + try: + handler = import_string(endpoint.handler)() if endpoint.handler else None + except (ImportError, TypeError): + handler = None - event = InboundEvent.objects.create( - endpoint=endpoint, - event_type=event_type, - raw_payload=payload, - ) + verified = self._verify(endpoint, handler, payload, normalized_headers, raw_body) + if not verified: + return Response( + { + "code": "AUTH_INVALID_SIGNATURE", + "message": "The webhook signature is missing or invalid.", + "details": [], + }, + status=status.HTTP_401_UNAUTHORIZED, + ) + + from infrasynth.tenancy.context import tenant_context + + with tenant_context(endpoint.tenant): + if external_id: + existing = InboundEvent.all_objects.filter(endpoint=endpoint, external_id=external_id).first() + if existing is not None: + data = InboundEventSerializer(existing).data + data = dict(data) if isinstance(data, dict) else data + return Response({**data, "duplicate": True}, status=status.HTTP_200_OK) + + event = InboundEvent.all_objects.create( + tenant=endpoint.tenant, + endpoint=endpoint, + event_type=event_type, + external_id=external_id, + raw_payload=payload, + is_verified=True, + ) + + backend = get_setting("INFRASYNTH_WEBHOOKS", "INBOUND_PROCESSING_BACKEND", "sync") + from .dispatch import process_inbound_event + + if backend == "celery": + process_inbound_event.delay(event.id, str(endpoint.tenant_id)) + else: + process_inbound_event(event.id, str(endpoint.tenant_id)) + + event.refresh_from_db() inbound_event_received.send( sender=InboundReceiveView, event=event, payload=payload, - headers=headers, + headers=normalized_headers, ) return Response( InboundEventSerializer(event).data, status=status.HTTP_201_CREATED, ) + + @staticmethod + def _verify(endpoint, handler, payload, headers, raw_body) -> bool: + if handler is not None: + try: + return bool(handler.verify(payload, headers, endpoint.secret, raw_body=raw_body)) + except TypeError: + # Older handlers without the ``raw_body`` keyword. + pass + from .signature import verify_signature + + header_name = get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature") + signature = headers.get(header_name) or headers.get("X-Signature") or "" + if not signature: + return False + tolerance = int(get_setting("INFRASYNTH_WEBHOOKS", "INBOUND_SIGNATURE_TOLERANCE_SECONDS", 300)) + return verify_signature( + endpoint.secret, + raw_body.decode("utf-8", errors="replace"), + signature, + tolerance_seconds=tolerance, + ) + + +def get_signal_header(headers: dict, name: str) -> str: + """Case-insensitive header lookup that preserves the original casing.""" + lowered = name.lower() + for key, value in headers.items(): + if key.lower() == lowered: + return value + return "" diff --git a/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc b/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index cc623b92859b27e657151ecb17748bbf7b4039f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcUzDAelV4t} sA0MBYmst`YuUAlci^C>2KczG$)vkyYXgRGX$krAY-fs0oOm2Pv%|MG;CFw4T~wvOAk@yV+f5CN1eH zH?RI1{x^Cu3={-U-U^ZGP2XfUX$SK1-kbOJ`_0??*XBwH&cFBJp_xPIr#7a?%9Qav zP=;t9VdD*YZAfDkRZ#=sTn*v;p7A)J#>pHuZ=!tyzG%{oS z2owr+_mDI&lDU0T=9f?mU8IR~(!zOJz-A4}BDSQxi==}KP`{|=Kg9gV591S&mOs7a z9nz>9x4cfP7kexet>-~^voGVVfQ8=aVi5V9ia{g~S|`&~b;V0&UUxd39@2&%V6(^T zpj_fl0ROJBGi66dW(VhR9-G+01zdcR-LE2S!_*E3b~P*WTa3y+XP9*ZA^AW#v*yZ~ z0Xuy#RqHJ31`(_KF|X4k;qfsIA9koRnpDEfl7n4#dc5c084!|Z0B5I^8ThQM_c!}axyFhXIE;0_jvSD>V_LfT;=U(|Y)5Fvqf zmryBr(CJGiAa{WfH>5&%fh%1WhJ?Vw&s}jsnqIUGegh=en=%n5C}I`F-%kK@bo1#~ zb7@p=3}22aYr|K6OvfsnBM^LPnxP-DoNd3Rk?U_UEm+gqBv3a|_OlfEI$r}*cNZGM XdEPLLGt~H9xMq|-2NwwbCRzUhvh64S diff --git a/infrasynth/workflows/__pycache__/engine.cpython-314.pyc b/infrasynth/workflows/__pycache__/engine.cpython-314.pyc deleted file mode 100644 index 7e6dd8a2b669928eb4d0de60849eefe29646faa2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15609 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 ULz)cbcxp`QG-YcP`lRCj0s4A*a{vGU diff --git a/infrasynth/workflows/__pycache__/filters.cpython-314.pyc b/infrasynth/workflows/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 9e6b32c6100a17778f5246a0d1f62a49df819271..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3347 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 OG;VVJo4|z_<$nR#7kUc- diff --git a/infrasynth/workflows/__pycache__/signals.cpython-314.pyc b/infrasynth/workflows/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index b8abd84e307959ba122306323f64aeafaf7ced58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 427 zcmZ9HJx>BL7{}W?MWZ0(41;kn>U1&E#lgWi>p~1JEPCm+h3mbvq%9&FyCa{%)lWj) zxWvIYv6=7%>=`tkPtyLM-}CZMd$Lp80ygj>iC?UK6z~(yDp_`IG6DCX1FyjqH1Gi2 z!9#GiY8$6SZi&`Pv|gf0$2kVRyC{5om{)FjU*NRuZ{&3vW6_r_%Jl#n5-Hb?nlkzfmc=XbHi~4% zP$m_AzzMohGG@eR#6?eGJr*W94Y6h@#3W&Y{;8vpR7o$DBaQU;KXhv_&NnYINrx%B uR5fc4?a|0ypDu*(4II7i?oH2UpfRsEpB^XGd2@f-odK_;ufS8=cIOLX>vsPD diff --git a/infrasynth/workflows/__pycache__/urls.cpython-314.pyc b/infrasynth/workflows/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index a71772bb039a018c3632fc236577ff3eac5d7b39..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1210 zcma)*zi$&U6vus+%l*hDxi$qV0cr_|Awo?+DgvPjfy8hTQYmg}hw4Dr#A&Zhe3k9A zYNx^ih^0(0vNH0YoKyy62{ACSwd&9nyN$1h#1Ie9_Qzkp_xx<%DwhgCw9i_1eKrTc zZ#hxT91M<`2EZP8382;lzQ#0PXZoa^>&={RFhh(rm}};JlbOE7EZ=6f?=Z(Nu!8S0 zcM=3f6-*D@v0_l1yP|@e_s|Gjn1|*)k%w$z*Z*}^;6Qr_ofBBFFoezxY*-vZR|-s6 z9zy2@1vv7LPOkzu8faB{qN``8%i3vCU)iShv%an|4_0n~+SnfnYC5+f-0Jq*h#Q+B zTdQfj^aORnewV?x&k(7Z{NilfMvp0tR&ftuwiu!9dBphW0tqpVSQKN0#+PEU(dow9 zO-xycTSzU8sRiju!BqWhg(9+rh+6QJS^>9>IuSk%MRzZ34}wf(sL0!12`R#15Ai$} zQRr?t6`szy%hH6iUNV$B(I9cUTS<4PjJsi4g{+4RP{1QQwz3LEg1R1+dPA`Cxyj;w0iLz57KMt{^v88dOk>8BC!f^9!3%3bc zji|UiZpawi6;|A?0GNIS$3hDD)5codLycZc!mV(lF-PKc)MB&|VUYpd!E9|x7PPSv zwl)xMXLv(ZhBl=4`sNO|XL@nF-$f6}gzzoj9Q`KDVNTPuU*P6XaQnotH2nk!b5sF^ z@eh-G<_}=L^@3F s#e`0#xd{C+$7}pPTx~IQ!iTpx;0V9<`LI3~& diff --git a/infrasynth/workflows/__pycache__/validators.cpython-314.pyc b/infrasynth/workflows/__pycache__/validators.cpython-314.pyc deleted file mode 100644 index 61ff781d8acc4d35668e96e50342f040637fe205..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1880 zcmZuxL2nyH6n?Y2cATWH<3?$KmNvK$VU@;BViiXO#DPPgQj~6mIiO^;S+5f}>)kRl zZpo<@AfcWrZas3%k$=*tDuuaFi392_V5IiezBjve+{h#O`R$uG^S(Fld++k;`~={7 z^>ujb2?O|>KKd)0D!s2z>B1Jc>`Pc-fH4Co2$*OkZT}=-u+w3~H zo2gofQ8{k*5|qcD(`VVzxeXDk*pEY96rD)5+5z`>1Aouk4g){E$NZ(|p|^ye9%9)9 z#avLUuQ+2IwC4F?KE1x!lbgzShPK* z^PKykCncFVm*idQ>Km2axE)m5vGDGAdldw7EAW-9w4$c)2+Dv)@zI8+-y2t&>fm^g(8B&GbhK`sc85d=z3qImf; zg8Rp*{qEEffaEfos2jNR22sZoqEpqyNFHjgGMs5pZP(sz;{v#M-s+YhDA_bJC^ z*xAwJ#~u{qxWvNX2*imJ_u(z{_st6v@w)J=y!4=Ozwj{sI5%(J(x(RLuR#tqv`{0C z%3*`NPOLhsrtRF%%o6XTkG6LLY)

Vi@?{g`r8TqcjV-=OCJJDwvh%2^Z$yKv^g zz5Dl`7D|VO(yyOBE-XIEEhhLWZW)L_ZjT_=aGMh)mMjOngW+(Eo zFE7yMMG|u;h8vCuD@YhzAERO#?vLuV?)7Jxsedh&S?<}8Egq3}ltqnfO9R`|iy|Wa z5faRF(>jj+tlGcbigpAQL{Mdt&Nk6#!X)0wuv$%~%V%O4H>jHO3W_6}G4>ZMU`T8F ee~Yn|hig5Iw1SbXX+*s|v3w5E*! diff --git a/infrasynth/workflows/__pycache__/views.cpython-314.pyc b/infrasynth/workflows/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 9fb6464a0c195cc904d1c79ad7064a61cb400536..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13292 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 Iq-pE@Kk_&30{{R3 diff --git a/infrasynth/workflows/engine.py b/infrasynth/workflows/engine.py index 75be527..f4d3047 100644 --- a/infrasynth/workflows/engine.py +++ b/infrasynth/workflows/engine.py @@ -27,12 +27,24 @@ class WorkflowEngine: @transaction.atomic def start(self, workflow_slug: str, owner, initial_data: dict | None = None) -> WorkflowInstance: """Starts a new workflow instance at its START node.""" + from infrasynth.shared.settings_utils import get_setting + workflow = Workflow.objects.filter(slug=workflow_slug).first() if workflow is None: raise ValueError(f"Workflow '{workflow_slug}' not found.") if not workflow.is_active: raise ValueError(f"Workflow '{workflow_slug}' is inactive.") + max_instances = int(get_setting("INFRASYNTH_WORKFLOWS", "MAX_INSTANCES_PER_WORKFLOW", 10000)) + if max_instances: + live = WorkflowInstance.objects.filter( + workflow=workflow, status=WorkflowInstance.Status.IN_PROGRESS + ).count() + if live >= max_instances: + raise ValueError( + f"Workflow '{workflow_slug}' has reached its in-progress instance limit ({max_instances})." + ) + start_node = workflow.nodes.filter(node_type=WorkflowNode.NodeType.START).first() if start_node is None: start_node = workflow.nodes.order_by("id").first() @@ -157,11 +169,17 @@ class WorkflowEngine: @transaction.atomic def assign_users( - self, instance_id: int, node_id: int, users: list, is_required: bool = True + self, instance_id: int, node_id: int, users: list, is_required: bool = True, *, actor=None ) -> list[NodeAssignment]: """Assigns users as responsible for a node. Returns created assignments.""" + from infrasynth.shared.settings_utils import get_setting + instance = WorkflowInstance.objects.get(pk=instance_id) node = WorkflowNode.objects.get(pk=node_id) + allow_self = get_setting("INFRASYNTH_WORKFLOWS", "ALLOW_SELF_ASSIGNMENT", False) + for user in users: + if actor is not None and not allow_self and getattr(actor, "pk", None) == getattr(user, "pk", None): + raise ValueError("Self-assignment is disabled for this workflow.") last_visit = ( NodeAssignment.objects.filter(instance=instance, node=node) .order_by("-visit_number") @@ -199,7 +217,11 @@ class WorkflowEngine: return False min_needed = min(node.min_approvals or 1, len(required)) - strategy = node.approval_strategy + from infrasynth.shared.settings_utils import get_setting + + strategy = node.approval_strategy or get_setting( + "INFRASYNTH_WORKFLOWS", "DEFAULT_APPROVAL_STRATEGY", ApprovalStrategy.ALL + ) if strategy == ApprovalStrategy.ANY: return len(processed) >= max(1, min_needed) @@ -222,6 +244,17 @@ class WorkflowEngine: next_node = transition.to_node instance.current_node = next_node + # Path-length guard so a misconfigured graph cannot loop forever. + from infrasynth.shared.settings_utils import get_setting + + max_depth = int(get_setting("INFRASYNTH_WORKFLOWS", "ROUTE_MAX_DEPTH", 50)) + metadata = dict(instance.metadata or {}) + depth = int(metadata.get("_route_depth", 0)) + 1 + if max_depth and depth > max_depth: + raise ValueError(f"Workflow route exceeded ROUTE_MAX_DEPTH ({max_depth}).") + metadata["_route_depth"] = depth + instance.metadata = metadata + if next_node.node_type == WorkflowNode.NodeType.END: instance.status = WorkflowInstance.Status.COMPLETED instance.completed_at = timezone.now() @@ -242,6 +275,10 @@ class WorkflowEngine: node_reached.send(sender=WorkflowInstance, instance=instance, node=next_node, visit_number=1) def _clone_assignees(self, instance: WorkflowInstance, from_node: WorkflowNode, to_node: WorkflowNode) -> None: + from infrasynth.shared.settings_utils import get_setting + + if not get_setting("INFRASYNTH_WORKFLOWS", "AUTO_CLONE_ASSIGNEES_ON_REENTRY", True): + return assignees = NodeAssignment.objects.filter(instance=instance, node=from_node, is_required=True).select_related( "user" ) diff --git a/infrasynth/workflows/migrations/0001_initial.py b/infrasynth/workflows/migrations/0001_initial.py index e9bd2ed..3901e92 100644 --- a/infrasynth/workflows/migrations/0001_initial.py +++ b/infrasynth/workflows/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.16 on 2026-07-31 01:19 +# Generated by Django 5.2.17 on 2026-09-24 14:10 import django.db.models.deletion from django.conf import settings @@ -11,6 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ("tenancy", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -19,7 +20,7 @@ class Migration(migrations.Migration): name="Workflow", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("slug", models.SlugField(max_length=100, unique=True)), + ("slug", models.SlugField(max_length=100)), ("name", models.CharField(max_length=200)), ("description", models.TextField(blank=True)), ("is_active", models.BooleanField(default=True)), @@ -33,6 +34,15 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "workflows_definition", @@ -62,6 +72,15 @@ class Migration(migrations.Migration): ), ("position_x", models.IntegerField(default=0)), ("position_y", models.IntegerField(default=0)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "workflow", models.ForeignKey( @@ -73,7 +92,6 @@ class Migration(migrations.Migration): ], options={ "db_table": "workflows_node", - "unique_together": {("workflow", "name")}, }, ), migrations.CreateModel( @@ -105,6 +123,15 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "workflow", models.ForeignKey( @@ -128,6 +155,42 @@ class Migration(migrations.Migration): "db_table": "workflows_instance", }, ), + migrations.CreateModel( + name="Transition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("condition_slug", models.CharField(blank=True, max_length=100)), + ("is_default", models.BooleanField(default=False)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), + ( + "from_node", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="outgoing_transitions", + to="infrasynth_workflows.workflownode", + ), + ), + ( + "to_node", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="incoming_transitions", + to="infrasynth_workflows.workflownode", + ), + ), + ], + options={ + "db_table": "workflows_transition", + }, + ), migrations.CreateModel( name="NodeAssignment", fields=[ @@ -139,6 +202,15 @@ class Migration(migrations.Migration): ("comments", models.TextField(blank=True)), ("submitted_data", models.JSONField(blank=True, default=dict, null=True)), ("processed_at", models.DateTimeField(blank=True, null=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "user", models.ForeignKey( @@ -166,34 +238,6 @@ class Migration(migrations.Migration): "db_table": "workflows_node_assignment", }, ), - migrations.CreateModel( - name="Transition", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("condition_slug", models.CharField(blank=True, max_length=100)), - ("is_default", models.BooleanField(default=False)), - ( - "from_node", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="outgoing_transitions", - to="infrasynth_workflows.workflownode", - ), - ), - ( - "to_node", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="incoming_transitions", - to="infrasynth_workflows.workflownode", - ), - ), - ], - options={ - "db_table": "workflows_transition", - "unique_together": {("from_node", "condition_slug")}, - }, - ), migrations.CreateModel( name="WorkflowObserver", fields=[ @@ -206,6 +250,15 @@ class Migration(migrations.Migration): to="infrasynth_workflows.workflowinstance", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "user", models.ForeignKey( @@ -215,7 +268,32 @@ class Migration(migrations.Migration): ], options={ "db_table": "workflows_observer", - "unique_together": {("instance", "user")}, }, ), + migrations.AddConstraint( + model_name="workflow", + constraint=models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_workflow_slug_per_tenant"), + ), + migrations.AddConstraint( + model_name="workflownode", + constraint=models.UniqueConstraint(fields=("workflow", "name"), name="uniq_node_name_per_workflow"), + ), + migrations.AddIndex( + model_name="workflowinstance", + index=models.Index(fields=["tenant_id", "status"], name="workflows_i_tenant__b74e9f_idx"), + ), + migrations.AddConstraint( + model_name="transition", + constraint=models.UniqueConstraint( + fields=("from_node", "condition_slug"), name="uniq_transition_condition" + ), + ), + migrations.AddIndex( + model_name="nodeassignment", + index=models.Index(fields=["tenant_id", "has_processed"], name="workflows_n_tenant__cd39b7_idx"), + ), + migrations.AddConstraint( + model_name="workflowobserver", + constraint=models.UniqueConstraint(fields=("instance", "user"), name="uniq_observer_per_instance"), + ), ] diff --git a/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index f0da8715231b23c7ee948ac203aa3801339f92dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7270 zcmd5>O;Fp~6_)&me}EXCfe?a`Z3s948!&|YJzfBlKpsDiOqw=R(8#s`1zRGLOak3x z(dqgvvrJd7-DSx#OP03ljy-p#Q)b?(JEqBW+ny`gSO%M#zA3F|u&?etIzQj}&bhkc zPHT&ufX_c)Bwz1%3F0SwQT~`qgD<~^!FytZpvgr-A>r4sXjm{R#y&h}Tr@4174w2c zv8dPNVv}O+AUp(Z>LO_Kx;fHbR#^F+FluAPMw33mZ}}U(T=A30Dszg$3tO_^fb1!L zOJWsX6l7#ci3u(#$15rbqN{ZA#oB`XTL<50LNRRE6(db5Ca~PB7uUufV!bKiJU>4{ zYI6ZnvB90L^AZq|fi|u?BG+rKku*u0Xfth@GHL4}1KkADH)=_5BtY6aWjK|zf#l7a zl1H?Cs^lAJbby?@mK^A7*2%Si992iIRVQ~19c|y(=<${{F~#jk$>$b9c|r{XC6y&#;cSXQI6cQQSLk5uzok zqU~)^&JA)kIruaA_u#ybbRSL8o=WBdR^XbK_R*11GH7Ukas8KM+(5mI1CyQulWv{C zb`azro%dU<$01$a`Y(_p>D#)_at9=*YiV4ksbF`r@ICe=+jzeU%>$kdFg+Pv~#T;OSzN<~tl`~bzO@ds_J&q3jgP^=hlSW*&lM&06>`bfkJaqOMEPkFL<4Z|P}$horx| zQrkhj?SKPn;BiAWFmuTon1!h7aWO&9>H23FBx_eeHM{4p5T_^5EA;$$dWE(Zs%;0% zkfTM|LFoo}&}Wy-)Kzl-`!n4CKkEYlBVfG*SVya|4pa_mZcik0@69c~}lWKQDQB!9r6<~>e| zi899sY>GoBIho#44`u8-Cdmm~$~Lm31^!JM*06-1l<=_%ITD;4m-t}4l1h>hilM1qzd|+bxh`i%BOZeCY>e9(1vWWr%M?OvgPb4mhUDZ4> zC?c{ZVvNGZk{oiqEd?lp^d>Lxs$HXm51q)e$#cnsjG9E%9T_#3;aGv=B(e$c%_#f3 zV1gGEPD*hJ9t`=7s42cJ@^Ma<@L@*_tR=+@47bab{@= z*>^=*b%J@P8}B1)*->Q1X61ukUf7gad0$Yrnev((D3AO`2|s~LJ@^ZED%4tDk{PT3 zUDMEK6mg4FwmAtoUzVU)6l8@J;vBNTuacIL13U-6w*Gxqbvad<3ppgZ&#zi5a@kXn(N+`$&o1lgrE*t=I;$~=8wwxFuQZa8E?ecvKf=ozBXk3^$|YouVoLc9k`Gg* zc~M_W24v)f1TxMG2@t^V1k~EZw@diUd*E0Us7Q&Npwf0qL1ddNvr(PLlujlg;kLQ* z5OgeJjZfhV#ewn5Ctd1_a*#N0$rPVv9`dxh|bf@@@jZ~2{oTe@EjD} zoZ4KMA>m00zu7>|v;5W!wB0ATBOzN9x@l=Rx6MkW;Trc&(T2^lqL}1ZLA$a)5hV@^ z<9q1Pmc(mT*b0B-(KM@v<5H> zSeiMgFc~>#N{uaAYGzWu>UGuqO>;BRxtZ`hY6(NbTjNt)$?s>;)fH`MO7L;$>*6j~ zeskBsK!b$0cyTBzMdD*=g_9XZdJgKpz#oA&g4<$>3#LSg-D7uxK!w*_T#wap7}(vHn!t$qh2M0_KUxUlcSNzZ{r%Gu3t{`F zU>(d^2mfF#3_Z>cJ^qt*=EP`lS>7+5*wsaM&gw3<^&Gc#{IFN(x}EE~eP}xB&%9zX zf|T!)Gft)0*7c#Y(CyE4`#(iLyYk&nGR~(}llkuZ8RvsyoBN}sK#k_8(N9XAdX#Z~ zTWq`eVYA>K%elu6`}6MaGS0`xE~@CdQE(09Tmwb7x7h0`dWU}W|K4Blj%U5&1@FVG z_hG>s&U(YefuUlLuh0|9_JqDVEFGOEW?NhPNsH6fdeTp{^<-|2Lo6MwUk5vF_LG1v zoW4fG(L6tP^{GDf=Un}W+ALCmqHo}9lL=dF0*g+f-ShEQ!8@Dt&K`~By~`Qr%CT$k z^qwMhyFiU)sj&hzm8GT%)Iye8C{U3s6{)#&)bVF(^_&U!hrL4Ic&=~!a4g^VZN@oW zZ0kKcb?kCi(5r>pT-G~R92hS4Jka?+;s(SXlPYAxMW-jze=p;^e_}QQc5~zafVV{I z(DPk@-m&ZEY1n@^m^?RtMjjQeYgP=G1K_$&w5zd%a(yA-OP|J!nA)J}BxE@gRI zN6Dw;2q3(nAv~&)k|jd`?6w|Q&jr91+f;xia-NC9=Xnnuc zg^4(s^G+Ued2cx5oY#XG{!AYMkkO2Dt!g^os}-o{%4i%*lzjlGwGnb zbNTJ4Tif;niC94US6HCD_e-zwF6t<)!(-&8_K0FTy+eJP(H=v(D!Xbxdjbfk4*-%_ zwx#aOn(5zQ#nwmK!_&0%9xm~JqU1sNIx&(Y`Ncwzp1%;(Ux}fg2=`Y@)JR%>uos;D NS!e%02z;d~`WJ6%EIa@J diff --git a/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 79e9dd90ef91ce27af3fb2fce0373f5b95517770..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 189 zcmdPq_I|p@<2{{|u766{Vk%pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcUzDAelV4t} zpPQLplvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A705BgAjT(VMn=XWW*`dy DrL{66 diff --git a/infrasynth/workflows/models.py b/infrasynth/workflows/models.py index 0ccc1fc..f4eebe5 100644 --- a/infrasynth/workflows/models.py +++ b/infrasynth/workflows/models.py @@ -2,10 +2,11 @@ from django.conf import settings from django.db import models from infrasynth.shared.enums import ApprovalStrategy +from infrasynth.tenancy.mixins import TenantOwnedModel -class Workflow(models.Model): - slug = models.SlugField(max_length=100, unique=True) +class Workflow(TenantOwnedModel): + slug = models.SlugField(max_length=100) name = models.CharField(max_length=200) description = models.TextField(blank=True) is_active = models.BooleanField(default=True) @@ -19,12 +20,15 @@ class Workflow(models.Model): class Meta: db_table = "workflows_definition" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_workflow_slug_per_tenant"), + ] def __str__(self): return self.name -class WorkflowNode(models.Model): +class WorkflowNode(TenantOwnedModel): class NodeType(models.TextChoices): START = "start", "Start" INTERMEDIATE = "intermediate", "Intermediate" @@ -44,13 +48,13 @@ class WorkflowNode(models.Model): class Meta: db_table = "workflows_node" - unique_together = [("workflow", "name")] + constraints = [models.UniqueConstraint(fields=["workflow", "name"], name="uniq_node_name_per_workflow")] def __str__(self): return f"{self.workflow.slug}:{self.name}" -class Transition(models.Model): +class Transition(TenantOwnedModel): from_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="outgoing_transitions") to_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="incoming_transitions") condition_slug = models.CharField(max_length=100, blank=True) @@ -58,13 +62,15 @@ class Transition(models.Model): class Meta: db_table = "workflows_transition" - unique_together = [("from_node", "condition_slug")] + constraints = [ + models.UniqueConstraint(fields=["from_node", "condition_slug"], name="uniq_transition_condition"), + ] def __str__(self): return f"{self.from_node} \u2192 {self.to_node}" -class WorkflowInstance(models.Model): +class WorkflowInstance(TenantOwnedModel): class Status(models.TextChoices): IN_PROGRESS = "in_progress", "In Progress" COMPLETED = "completed", "Completed" @@ -92,12 +98,13 @@ class WorkflowInstance(models.Model): class Meta: db_table = "workflows_instance" + indexes = [models.Index(fields=["tenant_id", "status"])] def __str__(self): return f"{self.workflow.slug}#{self.pk}" -class NodeAssignment(models.Model): +class NodeAssignment(TenantOwnedModel): instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="assignments") node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="assignments") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+") @@ -111,24 +118,27 @@ class NodeAssignment(models.Model): class Meta: db_table = "workflows_node_assignment" + indexes = [models.Index(fields=["tenant_id", "has_processed"])] def __str__(self): return f"{self.instance}#{self.node.name}@{self.user}" -class WorkflowObserver(models.Model): +class WorkflowObserver(TenantOwnedModel): instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="observers") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+") class Meta: db_table = "workflows_observer" - unique_together = [("instance", "user")] + constraints = [models.UniqueConstraint(fields=["instance", "user"], name="uniq_observer_per_instance")] def __str__(self): return f"{self.instance}\u2192{self.user}" -class WorkflowAwareModel(models.Model): +class WorkflowAwareModel(TenantOwnedModel): + """Abstract mixin for domain models that participate in a workflow.""" + workflow_instance = models.ForeignKey( WorkflowInstance, on_delete=models.SET_NULL, diff --git a/infrasynth/workflows/views.py b/infrasynth/workflows/views.py index c7b4c2b..143b558 100644 --- a/infrasynth/workflows/views.py +++ b/infrasynth/workflows/views.py @@ -174,12 +174,16 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet): user_model = get_user_model() users = user_model.objects.filter(pk__in=user_ids) - assignments = WorkflowEngine().assign_users( - instance.pk, - node_id, - list(users), - is_required=request.data.get("is_required", True), - ) + try: + assignments = WorkflowEngine().assign_users( + instance.pk, + node_id, + list(users), + is_required=request.data.get("is_required", True), + actor=request.user, + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) from .serializers import NodeAssignmentSerializer return Response( diff --git a/infrasynth_base.egg-info/PKG-INFO b/infrasynth_base.egg-info/PKG-INFO deleted file mode 100644 index 3fea035..0000000 --- a/infrasynth_base.egg-info/PKG-INFO +++ /dev/null @@ -1,37 +0,0 @@ -Metadata-Version: 2.4 -Name: infrasynth-base -Version: 1.0.0 -Summary: InfraSynth Base — reusable Django infrastructure kit -License: MIT -Requires-Python: >=3.12 -Requires-Dist: django<6.0,>=5.2 -Requires-Dist: djangorestframework<4.0,>=3.16 -Requires-Dist: django-cors-headers>=4.7 -Requires-Dist: djangorestframework-simplejwt>=5.5 -Requires-Dist: django-filter>=25.1 -Requires-Dist: psycopg2-binary>=2.9 -Requires-Dist: python-dotenv>=1.0 -Requires-Dist: cryptography>=44.0 -Requires-Dist: pydantic>=2.0 -Requires-Dist: pyotp>=2.10 -Requires-Dist: qrcode[pil]>=8.1 -Requires-Dist: celery[redis]>=5.4 -Requires-Dist: django-celery-results>=2.5 -Requires-Dist: django-celery-beat>=2.7 -Requires-Dist: boto3>=1.35 -Requires-Dist: django-storages>=1.14 -Requires-Dist: Pillow>=11.0 -Requires-Dist: twilio>=9.0 -Requires-Dist: stripe>=10.0 -Requires-Dist: mercadopago>=3.0 -Requires-Dist: requests>=2.32 -Requires-Dist: flower>=2.0 -Provides-Extra: dev -Requires-Dist: pytest>=8.0; extra == "dev" -Requires-Dist: pytest-django>=4.8; extra == "dev" -Requires-Dist: pytest-cov>=5.0; extra == "dev" -Requires-Dist: factory-boy>=3.3; extra == "dev" -Requires-Dist: faker>=28.0; extra == "dev" -Requires-Dist: ruff>=0.6; extra == "dev" -Requires-Dist: mypy>=1.11; extra == "dev" -Requires-Dist: pre-commit>=3.8; extra == "dev" diff --git a/infrasynth_base.egg-info/SOURCES.txt b/infrasynth_base.egg-info/SOURCES.txt deleted file mode 100644 index d89d6f9..0000000 --- a/infrasynth_base.egg-info/SOURCES.txt +++ /dev/null @@ -1,150 +0,0 @@ -pyproject.toml -infrasynth/__init__.py -infrasynth/audit/__init__.py -infrasynth/audit/apps.py -infrasynth/audit/filters.py -infrasynth/audit/middleware.py -infrasynth/audit/mixins.py -infrasynth/audit/models.py -infrasynth/audit/receivers.py -infrasynth/audit/serializers.py -infrasynth/audit/signals.py -infrasynth/audit/urls.py -infrasynth/audit/views.py -infrasynth/audit/migrations/0001_initial.py -infrasynth/audit/migrations/__init__.py -infrasynth/billing/__init__.py -infrasynth/billing/apps.py -infrasynth/billing/filters.py -infrasynth/billing/invoice_generator.py -infrasynth/billing/models.py -infrasynth/billing/serializers.py -infrasynth/billing/services.py -infrasynth/billing/signals.py -infrasynth/billing/urls.py -infrasynth/billing/views.py -infrasynth/billing/gateways/__init__.py -infrasynth/billing/gateways/base.py -infrasynth/billing/gateways/mercadopago.py -infrasynth/billing/gateways/stripe.py -infrasynth/billing/gateways/wompi.py -infrasynth/billing/migrations/0001_initial.py -infrasynth/billing/migrations/__init__.py -infrasynth/features/__init__.py -infrasynth/features/apps.py -infrasynth/features/decorators.py -infrasynth/features/filters.py -infrasynth/features/models.py -infrasynth/features/registry.py -infrasynth/features/serializers.py -infrasynth/features/services.py -infrasynth/features/signals.py -infrasynth/features/urls.py -infrasynth/features/views.py -infrasynth/features/migrations/0001_initial.py -infrasynth/features/migrations/__init__.py -infrasynth/files/__init__.py -infrasynth/files/apps.py -infrasynth/files/filters.py -infrasynth/files/models.py -infrasynth/files/processing.py -infrasynth/files/serializers.py -infrasynth/files/services.py -infrasynth/files/signals.py -infrasynth/files/storage.py -infrasynth/files/urls.py -infrasynth/files/views.py -infrasynth/files/migrations/0001_initial.py -infrasynth/files/migrations/__init__.py -infrasynth/notifications/__init__.py -infrasynth/notifications/apps.py -infrasynth/notifications/filters.py -infrasynth/notifications/models.py -infrasynth/notifications/resolvers.py -infrasynth/notifications/serializers.py -infrasynth/notifications/services.py -infrasynth/notifications/signals.py -infrasynth/notifications/tasks.py -infrasynth/notifications/urls.py -infrasynth/notifications/views.py -infrasynth/notifications/channels/__init__.py -infrasynth/notifications/channels/base.py -infrasynth/notifications/channels/email_sendgrid.py -infrasynth/notifications/channels/email_smtp.py -infrasynth/notifications/channels/sms_twilio.py -infrasynth/notifications/channels/telegram.py -infrasynth/notifications/migrations/0001_initial.py -infrasynth/notifications/migrations/__init__.py -infrasynth/scheduler/__init__.py -infrasynth/scheduler/apps.py -infrasynth/scheduler/filters.py -infrasynth/scheduler/models.py -infrasynth/scheduler/serializers.py -infrasynth/scheduler/services.py -infrasynth/scheduler/signals.py -infrasynth/scheduler/urls.py -infrasynth/scheduler/views.py -infrasynth/scheduler/migrations/0001_initial.py -infrasynth/scheduler/migrations/__init__.py -infrasynth/security/__init__.py -infrasynth/security/apps.py -infrasynth/security/filters.py -infrasynth/security/models.py -infrasynth/security/permissions.py -infrasynth/security/serializers.py -infrasynth/security/services.py -infrasynth/security/signals.py -infrasynth/security/urls.py -infrasynth/security/views.py -infrasynth/security/altcha/__init__.py -infrasynth/security/altcha/services.py -infrasynth/security/auth/__init__.py -infrasynth/security/auth/api_keys.py -infrasynth/security/auth/backends.py -infrasynth/security/auth/cookies.py -infrasynth/security/auth/middleware.py -infrasynth/security/migrations/0001_initial.py -infrasynth/security/migrations/0002_add_role_users_m2m.py -infrasynth/security/migrations/__init__.py -infrasynth/security/two_factor/__init__.py -infrasynth/security/two_factor/middleware.py -infrasynth/security/two_factor/services.py -infrasynth/security/two_factor/utils.py -infrasynth/shared/__init__.py -infrasynth/shared/crypto.py -infrasynth/shared/enums.py -infrasynth/shared/protocols.py -infrasynth/shared/results.py -infrasynth/shared/settings_utils.py -infrasynth/webhooks/__init__.py -infrasynth/webhooks/apps.py -infrasynth/webhooks/dispatch.py -infrasynth/webhooks/filters.py -infrasynth/webhooks/models.py -infrasynth/webhooks/registry.py -infrasynth/webhooks/serializers.py -infrasynth/webhooks/signals.py -infrasynth/webhooks/signature.py -infrasynth/webhooks/urls.py -infrasynth/webhooks/views.py -infrasynth/webhooks/inbound/__init__.py -infrasynth/webhooks/inbound/handlers.py -infrasynth/webhooks/migrations/0001_initial.py -infrasynth/webhooks/migrations/__init__.py -infrasynth/workflows/__init__.py -infrasynth/workflows/apps.py -infrasynth/workflows/engine.py -infrasynth/workflows/filters.py -infrasynth/workflows/models.py -infrasynth/workflows/serializers.py -infrasynth/workflows/signals.py -infrasynth/workflows/urls.py -infrasynth/workflows/validators.py -infrasynth/workflows/views.py -infrasynth/workflows/migrations/0001_initial.py -infrasynth/workflows/migrations/__init__.py -infrasynth_base.egg-info/PKG-INFO -infrasynth_base.egg-info/SOURCES.txt -infrasynth_base.egg-info/dependency_links.txt -infrasynth_base.egg-info/requires.txt -infrasynth_base.egg-info/top_level.txt diff --git a/infrasynth_base.egg-info/requires.txt b/infrasynth_base.egg-info/requires.txt deleted file mode 100644 index 1e83d51..0000000 --- a/infrasynth_base.egg-info/requires.txt +++ /dev/null @@ -1,32 +0,0 @@ -django<6.0,>=5.2 -djangorestframework<4.0,>=3.16 -django-cors-headers>=4.7 -djangorestframework-simplejwt>=5.5 -django-filter>=25.1 -psycopg2-binary>=2.9 -python-dotenv>=1.0 -cryptography>=44.0 -pydantic>=2.0 -pyotp>=2.10 -qrcode[pil]>=8.1 -celery[redis]>=5.4 -django-celery-results>=2.5 -django-celery-beat>=2.7 -boto3>=1.35 -django-storages>=1.14 -Pillow>=11.0 -twilio>=9.0 -stripe>=10.0 -mercadopago>=3.0 -requests>=2.32 -flower>=2.0 - -[dev] -pytest>=8.0 -pytest-django>=4.8 -pytest-cov>=5.0 -factory-boy>=3.3 -faker>=28.0 -ruff>=0.6 -mypy>=1.11 -pre-commit>=3.8 diff --git a/infrasynth_base.egg-info/top_level.txt b/infrasynth_base.egg-info/top_level.txt deleted file mode 100644 index 45f452b..0000000 --- a/infrasynth_base.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -infrasynth diff --git a/pyproject.toml b/pyproject.toml index 056d212..28e90f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,29 +6,32 @@ requires-python = ">=3.12" license = { text = "MIT" } dependencies = [ - "django>=5.2,<6.0", - "djangorestframework>=3.16,<4.0", - "django-cors-headers>=4.7", - "djangorestframework-simplejwt>=5.5", - "django-filter>=25.1", - "psycopg2-binary>=2.9", - "python-dotenv>=1.0", - "cryptography>=44.0", - "pydantic>=2.0", - "pyotp>=2.10", - "qrcode[pil]>=8.1", - "celery[redis]>=5.4", - "django-celery-results>=2.5", - "django-celery-beat>=2.7", - "boto3>=1.35", - "django-storages>=1.14", - "Pillow>=11.0", - "twilio>=9.0", - "stripe>=10.0", - "mercadopago>=3.0", - "requests>=2.32", - "flower>=2.0", - "gunicorn>=23.0", + "django>=5.2,<6.0", # the framework the whole kit targets + "djangorestframework>=3.16,<4.0", # API layer, viewsets, serializers + "django-cors-headers>=4.7", # CORS for the SPA frontends + "djangorestframework-simplejwt>=5.5", # JWT minting/validation behind the cookie auth + "djangorestframework-camel-case>=1.4", # wire-format camelCase (API-STANDARD §3) + "drf-spectacular>=0.28", # live OpenAPI schema at /api/v1/schema/ (API-STANDARD §12) + "django-filter>=25.1", # per-view FilterSets + "psycopg2-binary>=2.9", # PostgreSQL driver + "python-dotenv>=1.0", # local .env loading + "cryptography>=44.0", # Fernet encryption for secrets at rest + "pydantic>=2.0", # settings/validation helpers + "pyotp>=2.10", # TOTP 2FA + "qrcode[pil]>=8.1", # 2FA provisioning QR codes + "celery[redis]>=5.4", # background tasks + "django-celery-results>=2.5", # Celery result backend models + "django-celery-beat>=2.7", # periodic task scheduling + "boto3>=1.35", # S3 storage backend + "django-storages>=1.14", # cloud storage backends (S3/GCS) + "Pillow>=11.0", # image processing pipelines + "reportlab>=4.2", # invoice PDF generation + "twilio>=9.0", # SMS notification channel + "stripe>=10.0", # Stripe payment gateway + "mercadopago>=3.0", # MercadoPago payment gateway + "requests>=2.32", # outbound webhook delivery + "flower>=2.0", # Celery monitoring dashboard + "gunicorn>=23.0", # production WSGI server ] [project.optional-dependencies] @@ -44,7 +47,11 @@ dev = [ "django-stubs[compatible-mypy]>=5.2", "djangorestframework-stubs[compatible-mypy]>=3.16", "types-requests", + "hypothesis>=6.0", # property tests for time/token/billing math (AGENTS §11) + "schemathesis>=3.0", # contract tests against the live OpenAPI schema (API-STANDARD §15) ] +# Enable with INFRASYNTH_FILES["VIRUS_SCANNER"]="clamav". +clamav = ["clamd>=0.5"] # ClamAV client for the files virus-scan step [tool.setuptools.packages.find] include = ["infrasynth*"] @@ -110,3 +117,15 @@ django_settings_module = "config.settings.test" DJANGO_SETTINGS_MODULE = "config.settings.test" testpaths = ["tests"] python_files = ["test_*.py"] + +[tool.coverage.run] +source = ["infrasynth"] +omit = ["*/migrations/*", "*/__init__.py"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +# Gate for the whole package. The binding bar is ≥95% on the *public API +# surface* plus 100% branch coverage on security-relevant code (AGENTS §11); +# this floor keeps total coverage from regressing while that surface grows. +fail_under = 85 diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 3725e761378097a20ff02536797cd83945730612..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 163 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<)ELDpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3l#P$i%*!l^ lkJl@xyv1RYo1apelWJGQ3N#R8Wig2HiJ6g+v4|PS0suN0CvN}% diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 869868d8706c02730d72ba353ddffb32f7c9ace8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4234 zcmcgv-)|eo5#BowyrD1iA%wiO|TBzB2I}Dd6H<6 zJYx4)TS6WfH%JW|uFGjzkEw|GZg!@JJz~gfIOj^@O8+fshvYJ;@8J#1{Ckm<=?=1j$NvFyW5K zwhSZUYACDN3fBcz!`X-(;j*+E&8oJVAa5&kqr%X!(iYgjaH_H>(x1 zZkKvr#mPj3CjigUBy=ru-y7>kc!4|-?ocEWEFzZI86p;)<;<2{OICdYCB3oZM2tqISmGAZD6AQVhar_) zE zH+p*e5x#)y8Z08{i07S&Pyh8Lqy#m~>-2 z;fI&|jGqTc5+0U-4q`qs`O#agTOBEKD9yB`nNP3mOEU-3QYRW~t+|o@A8nG(f&mn* zfURH<_*Uo~NGJE@KB2?={>T zr$iD2L+D>tvVtW*HVT)aYEO}>(xXX$V$k87lk!&8Ji!@^D(6fcgzK=-F>CZbM|z9_ zfY%D-m?OUy2OcLLbG%6exDoVmLv|!Q8+OC@;Lu2-X^zCI&CT5~o+4?AiYQdWT7|KS zRcEdv1N22$krz$+5=fAUmr#+$?q%qBB3??i+=9|SQTS?e*bij@4ld&Yzku%FNP+QB zZg<@7+}44R0LoT`T%Zg!IDwhGVj2}BvyW4)Hf}- z&``M5pqAbQ3?QneLFsEC`|~)?)x{0CxHPF*bSK;bP#%FZ(0vV?_)5C?g%tka_Pe*= zTl>2d?zr;%Yu__Wl*j4J$y$z~12;%Ve+J=sH{8b)mN(ewfXdv@PN?w%$8&fBpK8xL z@i$|2w1c-i9PzHhc2{nqA6OF3_2g#NS~sf6Tkhub=4J)Rvy+6QpATaqUqY##q#%Mr zS+Zj55Pa>_lsl=QGwn;3o;#UNdu!4x*x^X!O38M@eQz`R252FP_@Kh-MuTlxHn083 zUY(u9_|*F|ir#u_F!L>V*j4B-MBm9I`13Ed_$PM{C(`YS^ixf5Wjj*by<{9p3vFrP zK>86zFS+!&H1j<}&p%!fS0o{I&QaEzl`5nLbJXR=PSJZiS8VK{A9T1Do79F%277u4 zg}`*ep2`XJbrG11@S;H-*%h6*$H=-(E9*L5k|<`1b78=UzGX1rEOQUdI6A%v|1)YP z{JGiIaczD<6Na zm^um&ajc6{SMl^gLGI~6>W=-OJ|K+u@E~N!Jv~U>Sx+wt6TS68 kf{Os!|9Ey!IuK^Q5rgi>XS*o*j137XKIvN#B<{2S0$9>h4FCWD diff --git a/tests/__pycache__/helpers.cpython-314.pyc b/tests/__pycache__/helpers.cpython-314.pyc deleted file mode 100644 index de99d21dee82b3740fd828e3c4eb4557ce72b08d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3944 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 diff --git a/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 1fe052063626d740af96f3948a7ca6fc622498f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33747 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 diff --git a/tests/conftest.py b/tests/conftest.py index 013e43e..c4aa172 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,87 @@ +import uuid + import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from rest_framework.test import APIClient +from infrasynth.tenancy.models import Tenant, TenantMembership + UserModel = get_user_model() +@pytest.fixture +def tenant(db): + return Tenant.objects.create(slug=f"t-{uuid.uuid4().hex[:8]}", name="Test Workspace") + + +@pytest.fixture +def membership_factory(db): + def create_membership(user, tenant, role="member", is_owner=False, is_active=True): + return TenantMembership.objects.create( + tenant=tenant, + user=user, + role=role, + is_owner=is_owner, + is_active=is_active, + ) + + return create_membership + + +@pytest.fixture(autouse=True) +def bind_tenant_context(tenant): + """Binds ``current_tenant`` for the whole test so scoped managers work.""" + from infrasynth.tenancy.context import tenant_context + + with tenant_context(tenant): + yield tenant + + @pytest.fixture def api_client(db): return APIClient() @pytest.fixture -def user(db): - return UserModel.objects.create_user( +def user(db, tenant): + user = UserModel.objects.create_user( username="testuser", email="test@example.com", password="testpass123", ) + TenantMembership.objects.create(tenant=tenant, user=user, role="owner", is_owner=True) + return user @pytest.fixture -def admin_user(db): - return UserModel.objects.create_superuser( +def admin_user(db, tenant): + user = UserModel.objects.create_superuser( username="admin", email="admin@example.com", password="adminpass123", ) + TenantMembership.objects.create(tenant=tenant, user=user, role="owner", is_owner=True) + return user + + +@pytest.fixture +def member_user(db, tenant): + """A non-owner member of the current tenant (no implicit permissions).""" + user = UserModel.objects.create_user( + username="member", + email="member@example.com", + password="memberpass123", + ) + TenantMembership.objects.create(tenant=tenant, user=user, role="member", is_owner=False) + return user + + +@pytest.fixture +def member_client(member_user, db): + client = APIClient() + client.force_authenticate(user=member_user) + return client @pytest.fixture diff --git a/tests/helpers.py b/tests/helpers.py index 75dd746..bd1991e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -52,3 +52,8 @@ class FakeGateway(BasePaymentGateway): def health_check(self) -> bool: return True + + +def noop_task(*args, **kwargs): + """Plain callable used by scheduler tests (accepts the tenant_id kwarg).""" + return "ok" diff --git a/tests/test_api/test_conventions.py b/tests/test_api/test_conventions.py new file mode 100644 index 0000000..dfecf8f --- /dev/null +++ b/tests/test_api/test_conventions.py @@ -0,0 +1,94 @@ +"""Cursor pagination + request id + exception handler (API-STANDARD.md §4-§7).""" + +import pytest +from django.http import HttpResponse +from django.test import RequestFactory +from rest_framework.exceptions import ValidationError + +from infrasynth.api.exceptions import envelope_exception_handler +from infrasynth.api.middleware import RequestIdMiddleware +from infrasynth.api.pagination import CursorPagination +from infrasynth.shared.exceptions import EntitlementError + + +class TestCursorPagination: + def test_configuration(self): + pagination = CursorPagination() + assert pagination.page_size == 25 + assert pagination.max_page_size == 100 + assert pagination.page_size_query_param == "pageSize" + assert pagination.ordering == "-pk" + + def test_response_shape(self): + # get_paginated_response depends on cursor state set during pagination; + # the envelope renderer test covers the wire shape end-to-end. + pagination = CursorPagination() + assert callable(pagination.get_paginated_response) + + +class TestRequestIdMiddleware: + def test_generates_request_id(self): + request = RequestFactory().get("/") + response = RequestIdMiddleware(lambda r: HttpResponse("ok"))(request) + assert response["X-Request-Id"] == request.request_id + assert request.request_id.startswith("req_") + + def test_echoes_supplied_request_id(self): + request = RequestFactory().get("/", HTTP_X_REQUEST_ID="req_abc") + response = RequestIdMiddleware(lambda r: HttpResponse("ok"))(request) + assert response["X-Request-Id"] == "req_abc" + assert request.request_id == "req_abc" + + +class TestEnvelopeExceptionHandler: + def test_app_error_maps_to_code_and_status(self): + response = envelope_exception_handler( + EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets"), + {}, + ) + assert response.status_code == 402 + assert response.data["code"] == "ENTITLEMENT_PLAN_UPGRADE_REQUIRED" + assert response.data["details"][0]["app"] == "helpdesk" + assert response.data["details"][0]["feature"] == "tickets" + + def test_drf_validation_error_flattens_details(self): + response = envelope_exception_handler(ValidationError({"name": ["required"]}), {}) + assert response.status_code == 400 + assert response.data["code"] == "VALIDATION_ERROR" + assert response.data["details"] == [{"field": "name", "issue": "required"}] + + def test_unexpected_exception_is_not_masked(self): + assert envelope_exception_handler(RuntimeError("boom"), {}) is None + + +@pytest.mark.django_db +class TestIdempotency: + def test_replays_first_successful_response(self): + from django.core.cache import cache + from rest_framework.response import Response + + from infrasynth.api.idempotency import idempotent + + cache.clear() + + class _View: + def __init__(self): + self.calls = 0 + + @idempotent + def post(self, request): + self.calls += 1 + return Response({"n": self.calls}) + + class _Request: + method = "POST" + path = "/api/v1/billing/checkout/" + headers = {"Idempotency-Key": "key-1"} + + view = _View() + first = view.post(_Request()) + second = view.post(_Request()) + assert first.data == {"n": 1} + assert second.data == {"n": 1} + assert second["Idempotent-Replay"] == "true" + assert view.calls == 1 diff --git a/tests/test_api/test_renderers.py b/tests/test_api/test_renderers.py new file mode 100644 index 0000000..7f0f0a2 --- /dev/null +++ b/tests/test_api/test_renderers.py @@ -0,0 +1,64 @@ +"""Envelope + camelCase renderer (API-STANDARD.md §3, §4).""" + +import json + +from infrasynth.api.renderers import EnvelopeJSONRenderer + + +class _FakeRequest: + def __init__(self, request_id="req_1", tenant=None): + self.request_id = request_id + self.tenant = tenant + + +class _FakeResponse: + def __init__(self, status_code=200): + self.status_code = status_code + + +def _render(data, *, status=200, request=None, response=None): + renderer = EnvelopeJSONRenderer() + raw = renderer.render( + data, + renderer_context={"request": request or _FakeRequest(), "response": response or _FakeResponse(status)}, + ) + return json.loads(raw) + + +def test_success_envelope_camelcases_data(): + body = _render({"first_name": "Ada", "nested": {"last_name": "L"}}) + assert body["success"] is True + assert body["error"] is None + assert body["data"] == {"firstName": "Ada", "nested": {"lastName": "L"}} + assert body["meta"]["requestId"] == "req_1" + assert body["meta"]["timestamp"].endswith("Z") + + +def test_error_envelope_uses_error_member(): + body = _render( + {"code": "ENTITLEMENT_EXPIRED", "message": "expired", "details": []}, + status=402, + ) + assert body["success"] is False + assert body["data"] is None + assert body["error"]["code"] == "ENTITLEMENT_EXPIRED" + + +def test_paginated_response_moves_results_to_data(): + payload = { + "next": "cursor_next", + "previous": None, + "page_size": 25, + "results": [{"id": 1}, {"id": 2}], + } + body = _render(payload) + assert body["data"] == [{"id": 1}, {"id": 2}] + assert body["meta"]["pagination"] == {"nextCursor": "cursor_next", "prevCursor": None, "pageSize": 25} + + +def test_meta_includes_tenant_id_when_resolved(): + class _Tenant: + pk = "11111111-1111-1111-1111-111111111111" + + body = _render({"ok": True}, request=_FakeRequest(tenant=_Tenant())) + assert body["meta"]["tenantId"] == "11111111-1111-1111-1111-111111111111" diff --git a/tests/test_audit/__pycache__/__init__.cpython-314.pyc b/tests/test_audit/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index abaa1192c8d055502e24613c685c610f9f18b705..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITiKQu- tCHnF4nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=7PY%c~eJ~1;gG8QodSpZ&GE8hSB diff --git a/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 8e01fa791b102711f5ebf9bfb8d5af6ebcb718c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20085 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 sC%n^f>Up5Wap)NbVLT=mt0u9u~2LJ#7 diff --git a/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 212e6afe29c092101c4fe802f13738cdf04e58a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25846 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 diff --git a/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 1467bd353d5ab4a903c187de6589111d44e97871..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22060 zcmeG^S#TRib~88!=kSuK%amkFG@yg`A+HWfHYt;)L~%zn+XZ$ke`9)e_zYI6>Mdg1bXzxr8(AIEW_MnE-?Z-$;d2y zJ0_fqj@`_JOLp&8V?444VjPp+2~Or{m~+xM;g|gr0XZ-cl!LpOH$5{u^vun-;A&?E zHLo15gX6Z~@O5xJ7M!Ln%w9(Dwle}Z=uk74n`xL2!u&>93k?fESkMS-rC}im3mai= z0^7+%`7bf6=_spsBsnfGN>QiMd}?NLGApM@JRzrZ*)zF0C44rQOl4la8qdz9&>uRV zN-UDJyfpq!Dk~?n^3YQG06O7+Z5F^y<{}M5Fk{8iLTB7YwBZn-R|JRP6kLM4+tI_u zSivKBM?5M5l$m?9EuFnW;?h!9zA8#uNxFqxCZ(`_iYGx*aXF>9vhn%UwBnjgN%A8% zNi7`ii+Ys6Jk~_a#4o2ZiZ6HhtyDr5(@DjnR#x#QXeA}Z4FNf#_#xpUQc`3T+Pj=dggN^{!aYuo*9zEuSh*+sSjU{OR1jA z@xKO2b+5E+S>fAPn%h6!cgyo>N1-XQ5^h;(-LV>Cg3&d`8}zI)Ubklrk~8-G zc82EsWc_yOUW~mI#NM;?mrHTJGCun#+!N2Id#*s=ERvMebAxtCKXi#_J|$nxB^6gb zE?-pw>Npe=&`+cp%Ay34izJRCQJj}Z3xq&N;0Plyy&2jGqzznJ>yvhHD_-~(ZLq!I zLp-S;05;gJa$sLEu&*3ASPUFo;oDY1&#knzVfwM9wi8!oX(0@;^|B(dBfM&KhoU`e=w?HP)oV261*Hwiut%C zUCWWA+J*yBx8hIXL=@$vyw+&`bY6@nlhD$V!a)PalW{q&w;q0Rz19oslS-u7k|^Ns zhJOjE8aCp#a$rv}u%{e&p%{2!rFAbIV;w6kJ3gH*G#|uf3^2jS8sov?=5c$}W`xAX z0|b~pYe(P}hfLoDLM$+{C7MuV*NLFR9;Fh@oKrdqwrcOl(ak5oC)^um-__fr? z9^a*Z2kH1@)dMDPMR{!NVfKXn9iu)wtJ2$+uQol@%PVjV>*dzJ*W)^583TE*;1m2Y zZx34$%3a)|H>D-#t$LXGh8E`Zob~rrRtOwr^RA;TNx_IjqEz+h@`eyRWRXbnzDw*v zI_9Bc@$dC^nOmxAmwF@747)h9GreR-eIyxF=q5Us5%1b{GadDKeTy(-UW zGF1=dKLjb6;><7gDlTA8d35$87$hh^p=^$?CsyFNrwlL5}{km8(6$ppEjTAhF;mEu2{UqWe5 zj1#3Qe1C=2yr{8xTI9(iqaNe zl&^x+kWPTOoD>t8GQ6;!$+D@4?G?p%sA6|#och{gFglwgEfW_TpR`|{`|3Z;}p%6L#dGOv`VKi3a zFMQ-$;aki6?jpatzzzLkxZF8Z>>L7T0UZ2LnbUsvA%x#;cn!x#vf=!K8`6Cb?64sa z^4Bv2Y~6?0o#v+u6mS3*l<%iYA1{6K?x!Qmd}o1++*+WZ$VbYY_A7Cb0#uKBmo4u5 zh;3k59ba$}GvgPR`Te)z%Y5XsZOeRrfje>MDg{OUM48imCGJFl?^nIc7WaL`Hn6M? zh*3p8TM29r$TjsPu$aS6;tGO5*l$QIR4&t`Bj{%10lCcJ#50DZWkjzhml-^8#*nnk zbRd^CQMs(SLN0@K&P78So3WKnbiQnn%gis?b5Ns}I=|NBGBX`6cdDK$xeO)7M);;& z<_2E4rDuNTe5CeF#ueApHmi5MV1$6P$nq zTaDmij|?LiL4cEwpvHt818_S`PQXiX
  • =G#SO{lL$^BcnQJF2*wbMBRGxV6$G%f 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 diff --git a/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 1d44ed51b9c01835e5b3e95f8fa4ead4aaeb8cb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52079 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 diff --git a/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 61423a620fcf123984e4a3ef34f689f22d9c3bcd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28357 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ð8S+d{uTR4Nao0*P^dTi(*7MIM|0Z z>_u_{$pDhiBY6u6&Sqp-`hWxSsKaeI`4G63*We!k#LfU@ diff --git a/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index dea3bd5a6c3668aeaee00578166535ed561cf5a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10709 zcmeG?TWlNGl{0(}pCeH(%l2CG+e{@=v=vKU+lrkyvE)r$cI?vIMDbumj^v3clDadD zEfwy%Xn?qAfwt&Jx7Z*5)&0w-``5osfqsydD-whB0qpjpKZUwlAd7tMId|sHj3_#a z6Koq4dqJK#_ug~QeVw`Iapx<^M3jScFIj%)dw>-&!&1OTwl)E|&)wuCevUJE`t;5E z>%I|gHedutteTJ!f*Rjkcvdh3TIQdN%tnpqY|Mzw#*H{F3(O^ElSXocdpndrz&diD z`B4{Vn9&QgM_A5%O2Hv+(#{%v&!7o;X!-}ZgPar|;v`|xXEihiXjufxqE6YM#2@C; zu|FZn)iiIW>da(bSzT5u#%0A& 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 diff --git a/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 83a84b9ba1b7efc1f48bda5bc7b7c320b23e7075..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43285 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

    6@0!0%K9^1pVVw@09;V#GahAu~ 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 Y=U#Wj_dE#5y|;0%ujj4;hgJCh18n$lvH$=8 diff --git a/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index f9bd1f370d001f884cf646784513f47a6861aeef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37675 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 YK_0GCe8bpJ^x;=F`8GX}Kv?Jee~WBWivR!s diff --git a/tests/test_billing/test_entitlements.py b/tests/test_billing/test_entitlements.py new file mode 100644 index 0000000..3696996 --- /dev/null +++ b/tests/test_billing/test_entitlements.py @@ -0,0 +1,108 @@ +"""EntitlementService enforcement (ENTITLEMENTS.md §4, §5).""" + +import pytest + +from infrasynth.billing.entitlements import EntitlementService +from infrasynth.billing.models import App, Entitlement, Plan +from infrasynth.billing.services import BillingService +from infrasynth.tenancy.models import Tenant + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization="subscription") + + +@pytest.fixture +def plan(app): + return Plan.objects.create( + app=app, + slug="pro", + name="Pro", + price_amount=1000, + features={"broadcast": True, "analytics": False}, + limits={"max_agents": 10}, + ) + + +def _entitlement(tenant, app, plan=None, status="active"): + return Entitlement.all_objects.create(tenant=tenant, app=app, plan=plan, status=status) + + +class TestIsEntitled: + def test_false_without_entitlement(self, tenant, app): + assert EntitlementService().is_entitled(tenant, "messenger") is False + + def test_active_is_entitled(self, tenant, app, plan): + _entitlement(tenant, app, plan) + assert EntitlementService().is_entitled(tenant, "messenger") is True + + @pytest.mark.parametrize("status", ["trialing", "grace", "past_due"]) + def test_grace_and_trial_are_entitled(self, tenant, app, plan, status): + _entitlement(tenant, app, plan, status=status) + assert EntitlementService().is_entitled(tenant, "messenger") is True + + @pytest.mark.parametrize("status", ["suspended", "expired", "cancelled", "revoked"]) + def test_inactive_statuses_are_not_entitled(self, tenant, app, plan, status): + _entitlement(tenant, app, plan, status=status) + assert EntitlementService().is_entitled(tenant, "messenger") is False + + def test_suspended_tenant_denied_regardless_of_entitlement(self, tenant, app, plan): + _entitlement(tenant, app, plan, status="active") + tenant.status = Tenant.Status.SUSPENDED + tenant.save(update_fields=["status"]) + assert EntitlementService().is_entitled(tenant, "messenger") is False + + def test_feature_gate_reads_plan_features(self, tenant, app, plan): + _entitlement(tenant, app, plan) + svc = EntitlementService() + assert svc.is_entitled(tenant, "messenger", feature="broadcast") is True + assert svc.is_entitled(tenant, "messenger", feature="analytics") is False + assert svc.is_entitled(tenant, "messenger", feature="unknown") is False + + def test_none_tenant_is_never_entitled(self, app): + assert EntitlementService().is_entitled(None, "messenger") is False + + +class TestCheckLimit: + def test_within_and_at_limit(self, tenant, app, plan): + _entitlement(tenant, app, plan) + svc = EntitlementService() + assert svc.check_limit(tenant, "messenger", "max_agents", 9) is True + assert svc.check_limit(tenant, "messenger", "max_agents", 10) is False + + def test_missing_limit_is_unlimited(self, tenant, app, plan): + _entitlement(tenant, app, plan) + assert EntitlementService().check_limit(tenant, "messenger", "nonexistent", 10**9) is True + + def test_no_entitlement_denies(self, tenant, app): + assert EntitlementService().check_limit(tenant, "messenger", "max_agents", 0) is False + + +class TestCacheInvalidation: + def test_mutation_invalidates_cache(self, tenant, app, plan): + entitlement = _entitlement(tenant, app, plan) + svc = EntitlementService() + assert svc.is_entitled(tenant, "messenger") is True + entitlement.status = "revoked" + entitlement.save(update_fields=["status"]) + # still cached + assert svc.is_entitled(tenant, "messenger") is True + svc.invalidate(tenant, "messenger") + assert svc.is_entitled(tenant, "messenger") is False + + +class TestLifecycleTransitions: + def test_past_due_grace_suspend_reinstate(self, tenant, app, plan): + entitlement = _entitlement(tenant, app, plan) + svc = BillingService() + svc.mark_past_due(entitlement) + assert EntitlementService().get(tenant, "messenger").status == "past_due" + svc.enter_grace(entitlement) + assert EntitlementService().is_entitled(tenant, "messenger") is True + svc.suspend_entitlement(entitlement) + assert EntitlementService().is_entitled(tenant, "messenger") is False + svc.reinstate_entitlement(entitlement) + assert EntitlementService().is_entitled(tenant, "messenger") is True diff --git a/tests/test_billing/test_gateways.py b/tests/test_billing/test_gateways.py index 737059c..fa89319 100644 --- a/tests/test_billing/test_gateways.py +++ b/tests/test_billing/test_gateways.py @@ -223,7 +223,7 @@ class TestWompiGateway: assert result.session_id == "plink_1" assert result.checkout_url == "https://checkout.wompi.co/link" payload = post.call_args.kwargs["json"] - assert payload["amount_in_cents"] == 10000 + assert payload["amount_in_cents"] == 100 assert payload["currency"] == "usd" def test_create_checkout_http_error_raises(self): diff --git a/tests/test_billing/test_integration.py b/tests/test_billing/test_integration.py index 14c46f7..96496c3 100644 --- a/tests/test_billing/test_integration.py +++ b/tests/test_billing/test_integration.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from django.test import override_settings from infrasynth.audit.models import SecurityEvent -from infrasynth.billing.models import BillingPlan, PaymentGateway +from infrasynth.billing.models import App, PaymentGateway, Plan from infrasynth.billing.services import BillingService from infrasynth.billing.signals import ( invoice_generated, @@ -14,7 +14,7 @@ from infrasynth.billing.signals import ( ) from infrasynth.notifications.models import ChannelConfig, NotificationDispatch, NotificationTemplate from infrasynth.notifications.services import NotificationService -from infrasynth.shared.enums import ChannelType +from infrasynth.shared.enums import ChannelType, MonetizationModel from infrasynth.webhooks.signals import outbound_delivery_succeeded pytestmark = pytest.mark.django_db @@ -31,11 +31,17 @@ def gateway(): @pytest.fixture -def plan(gateway): - return BillingPlan.objects.create( +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION) + + +@pytest.fixture +def plan(app, gateway): + return Plan.objects.create( + app=app, slug="pro", name="Pro", - price_amount="49.00", + price_amount=4900, price_currency="USD", interval="monthly", gateway=gateway, @@ -45,7 +51,7 @@ def plan(gateway): @pytest.fixture def notification_template(): - return NotificationTemplate.objects.create( + return NotificationTemplate.all_objects.create( slug="sub-created", name="Subscription Created", channel=ChannelType.EMAIL, @@ -56,8 +62,9 @@ def notification_template(): @pytest.fixture -def channel_config(): +def channel_config(tenant): return ChannelConfig.objects.create( + tenant=tenant, slug="email", channel_type=ChannelType.EMAIL, display_name="Email Channel", @@ -67,17 +74,18 @@ def channel_config(): class TestSubscriptionCreatedSignalIntegration: - def test_subscription_created_signal_integration(self, user, plan, gateway): + def test_subscription_created_signal_integration(self, tenant, plan, gateway): sent = [] receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 subscription_created.connect(receiver_fn, weak=False) try: - BillingService().create_subscription(plan, user, gateway, external_id="sub_int_1") + BillingService().create_subscription(plan, tenant, gateway, external_id="sub_int_1") assert len(sent) == 1 - assert sent[0]["user"] == user + assert sent[0]["tenant_id"] == str(tenant.pk) assert sent[0]["plan_slug"] == "pro" + assert sent[0]["app_slug"] == "messenger" assert sent[0]["gateway"] == "fake" assert sent[0]["external_id"] == "sub_int_1" assert sent[0]["signal"] is subscription_created @@ -97,7 +105,7 @@ class TestBillingSignalTriggersNotificationDispatch: } ) def test_billing_signal_triggers_notification_dispatch( - self, user, plan, gateway, notification_template, channel_config + self, tenant, plan, gateway, notification_template, channel_config ): dispatch_created = [] @@ -105,7 +113,7 @@ class TestBillingSignalTriggersNotificationDispatch: def handle_sub_created(**kwargs): svc = NotificationService() d = svc.send( - recipient=kwargs["user"].email, + recipient="owner@example.com", subject="Subscription Active", body="Your subscription is active.", channel=ChannelType.EMAIL, @@ -115,12 +123,12 @@ class TestBillingSignalTriggersNotificationDispatch: dispatch_created.append(d) try: - BillingService().create_subscription(plan, user, gateway, external_id="sub_notif_1") + BillingService().create_subscription(plan, tenant, gateway, external_id="sub_notif_1") assert len(dispatch_created) == 1 dispatch = dispatch_created[0] assert isinstance(dispatch, NotificationDispatch) - assert dispatch.recipient == user.email + assert dispatch.recipient == "owner@example.com" assert dispatch.channel == ChannelType.EMAIL assert dispatch.template == notification_template assert dispatch.status == NotificationDispatch.Status.PENDING @@ -129,12 +137,13 @@ class TestBillingSignalTriggersNotificationDispatch: class TestWebhookSignalTriggersAuditLog: - def test_webhook_signal_triggers_audit_log(self, user): + def test_webhook_signal_triggers_audit_log(self, tenant, user): events_created = [] @receiver(outbound_delivery_succeeded, weak=False) def create_security_event(**kwargs): event = SecurityEvent.objects.create( + tenant=tenant, event_type="webhook_delivery_succeeded", actor=user, metadata={ @@ -166,8 +175,8 @@ class TestWebhookSignalTriggersAuditLog: class TestSubscriptionCancelledSignal: - def test_subscription_cancelled_signal(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_to_cancel") + def test_subscription_cancelled_signal(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_to_cancel") sent = [] receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 @@ -177,7 +186,7 @@ class TestSubscriptionCancelledSignal: BillingService().cancel_subscription(subscription) assert len(sent) == 1 - assert sent[0]["user"] == user + assert sent[0]["tenant_id"] == str(tenant.pk) assert sent[0]["plan_slug"] == "pro" assert sent[0]["reason"] == "user_requested" assert sent[0]["signal"] is subscription_cancelled @@ -186,8 +195,8 @@ class TestSubscriptionCancelledSignal: class TestInvoiceGeneratedSignal: - def test_invoice_generated_signal(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_inv_1") + def test_invoice_generated_signal(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_inv_1") sent = [] receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 @@ -198,7 +207,7 @@ class TestInvoiceGeneratedSignal: assert len(sent) == 1 assert sent[0]["invoice_id"] == invoice.id - assert sent[0]["amount"] == 49.0 + assert sent[0]["amount"] == 4900 assert sent[0]["signal"] is invoice_generated finally: invoice_generated.disconnect(receiver_fn) diff --git a/tests/test_billing/test_invoice_generator.py b/tests/test_billing/test_invoice_generator.py index abb0d03..9f25d3e 100644 --- a/tests/test_billing/test_invoice_generator.py +++ b/tests/test_billing/test_invoice_generator.py @@ -1,8 +1,9 @@ import pytest from infrasynth.billing.invoice_generator import _build_invoice_pdf -from infrasynth.billing.models import BillingPlan, Invoice, PaymentGateway +from infrasynth.billing.models import App, Invoice, PaymentGateway, Plan from infrasynth.billing.services import BillingService +from infrasynth.shared.enums import MonetizationModel pytestmark = pytest.mark.django_db @@ -18,11 +19,17 @@ def gateway(): @pytest.fixture -def plan(gateway): - return BillingPlan.objects.create( +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION) + + +@pytest.fixture +def plan(app, gateway): + return Plan.objects.create( + app=app, slug="pro", name="Pro", - price_amount="49.00", + price_amount=4900, price_currency="USD", interval="monthly", gateway=gateway, @@ -30,43 +37,43 @@ def plan(gateway): class TestBuildInvoicePdf: - def test_generates_pdf_bytes(self, user, gateway, plan): - subscription = BillingService().create_subscription(plan, user, gateway) - invoice = Invoice.objects.create( + def test_generates_pdf_bytes(self, tenant, gateway, plan): + subscription = BillingService().create_subscription(plan, tenant, gateway) + invoice = Invoice.all_objects.create( + tenant=tenant, subscription=subscription, - user=user, gateway=gateway, invoice_number="INV-2026-000001", - amount=49, + amount=4900, currency="USD", status="open", - line_items=[{"description": "Pro", "amount": 49.0, "quantity": 1}], + line_items=[{"description": "Pro", "amount": 4900, "quantity": 1}], ) pdf_bytes = _build_invoice_pdf(invoice) assert pdf_bytes.startswith(b"%PDF") assert len(pdf_bytes) > 100 - def test_pdf_includes_tax(self, user, gateway, plan): - subscription = BillingService().create_subscription(plan, user, gateway) - invoice = Invoice.objects.create( + def test_pdf_includes_tax(self, tenant, gateway, plan): + subscription = BillingService().create_subscription(plan, tenant, gateway) + invoice = Invoice.all_objects.create( + tenant=tenant, subscription=subscription, - user=user, gateway=gateway, invoice_number="INV-2026-000002", - amount=100, - tax_amount=19, + amount=10000, + tax_amount=1900, tax_name="IVA", currency="USD", status="open", - line_items=[{"description": "Plan", "amount": 100.0, "quantity": 1}], + line_items=[{"description": "Plan", "amount": 10000, "quantity": 1}], ) pdf_bytes = _build_invoice_pdf(invoice) assert pdf_bytes.startswith(b"%PDF") class TestGenerateInvoicePdfTask: - def test_task_generates_and_attaches_pdf(self, user, gateway, plan, media_root): - subscription = BillingService().create_subscription(plan, user, gateway) + def test_task_generates_and_attaches_pdf(self, tenant, gateway, plan, media_root): + subscription = BillingService().create_subscription(plan, tenant, gateway) invoice = BillingService().generate_invoice(subscription) invoice.refresh_from_db() assert invoice.pdf_file_id is not None diff --git a/tests/test_billing/test_models.py b/tests/test_billing/test_models.py index dd77187..1117b7c 100644 --- a/tests/test_billing/test_models.py +++ b/tests/test_billing/test_models.py @@ -1,12 +1,14 @@ import pytest from infrasynth.billing.models import ( - BillingPlan, + App, + Entitlement, Invoice, PaymentGateway, + Plan, Subscription, ) -from infrasynth.shared.enums import BillingInterval, InvoiceStatus, SubscriptionStatus +from infrasynth.shared.enums import EntitlementStatus, InvoiceStatus, MonetizationModel, SubscriptionStatus pytestmark = pytest.mark.django_db @@ -21,6 +23,24 @@ def gateway(): ) +@pytest.fixture +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION) + + +@pytest.fixture +def plan(app, gateway): + return Plan.objects.create( + app=app, + slug="pro", + name="Pro", + price_amount=4900, + price_currency="USD", + interval="monthly", + gateway=gateway, + ) + + class TestPaymentGateway: def test_create(self): gateway = PaymentGateway.objects.create( @@ -37,40 +57,73 @@ class TestPaymentGateway: assert gateway.pk == "stripe" -class TestBillingPlan: - def test_create(self, gateway): - plan = BillingPlan.objects.create( +class TestApp: + def test_create(self): + app = App.objects.create(slug="invoicer", name="Invoicer", monetization=MonetizationModel.ONE_TIME) + assert app.is_active is True + assert str(app) == "Invoicer" + + def test_unique_slug(self): + App.objects.create(slug="x", name="X", monetization="one_time") + with pytest.raises(Exception): + App.objects.create(slug="x", name="Y", monetization="one_time") + + +class TestPlan: + def test_create(self, app, gateway): + plan = Plan.objects.create( + app=app, slug="pro", name="Pro", - price_amount="49.00", + price_amount=4900, price_currency="USD", - interval=BillingInterval.MONTHLY, + interval="monthly", gateway=gateway, ) assert plan.is_active is True assert plan.trial_days == 0 - assert plan.features == [] - assert str(plan) == "Pro" + assert plan.features == {} + assert plan.limits == {} + assert str(plan) == "messenger:pro" - def test_unique_slug(self, gateway): - BillingPlan.objects.create(slug="pro", name="A", price_amount=1, price_currency="USD", interval="monthly") + def test_slug_unique_per_app(self, app): + Plan.objects.create(app=app, slug="pro", name="A", price_amount=1, interval="monthly") with pytest.raises(Exception): - BillingPlan.objects.create(slug="pro", name="B", price_amount=1, price_currency="USD", interval="monthly") + Plan.objects.create(app=app, slug="pro", name="B", price_amount=1, interval="monthly") + + def test_same_slug_allowed_across_apps(self, app): + other = App.objects.create(slug="other", name="Other", monetization="one_time") + Plan.objects.create(app=app, slug="pro", name="A", price_amount=1, interval="monthly") + Plan.objects.create(app=other, slug="pro", name="B", price_amount=1, interval="one_time") + + +class TestEntitlement: + def test_create(self, tenant, app, plan): + ent = Entitlement.all_objects.create(tenant=tenant, app=app, plan=plan) + assert ent.status == EntitlementStatus.ACTIVE + assert ent.metadata == {} + + def test_unique_per_tenant_app(self, tenant, app): + Entitlement.all_objects.create(tenant=tenant, app=app) + with pytest.raises(Exception): + Entitlement.all_objects.create(tenant=tenant, app=app) class TestSubscription: - def test_create(self, user, gateway): - subscription = Subscription.objects.create( - user=user, + def test_create(self, tenant, plan, gateway): + subscription = Subscription.all_objects.create( + tenant=tenant, + plan=plan, gateway=gateway, status=SubscriptionStatus.ACTIVE, ) assert subscription.cancel_at_period_end is False assert subscription.metadata == {} - def test_status_choices(self, user, gateway): - subscription = Subscription.objects.create( - user=user, + def test_status_choices(self, tenant, plan, gateway): + subscription = Subscription.all_objects.create( + tenant=tenant, + plan=plan, gateway=gateway, status=SubscriptionStatus.TRIALING, ) @@ -78,12 +131,12 @@ class TestSubscription: class TestInvoice: - def test_create(self, user, gateway): - invoice = Invoice.objects.create( - user=user, + def test_create(self, tenant, gateway): + invoice = Invoice.all_objects.create( + tenant=tenant, gateway=gateway, invoice_number="INV-2026-000001", - amount="49.00", + amount=4900, currency="USD", status=InvoiceStatus.DRAFT, ) @@ -94,16 +147,7 @@ class TestInvoice: assert invoice.pdf_file_id is None assert str(invoice) == "INV-2026-000001" - def test_no_default_status(self, user, gateway): - invoice = Invoice.objects.create( - user=user, - invoice_number="INV-2", - amount=10, - currency="USD", - ) - assert invoice.status == "" - - def test_unique_invoice_number(self, user, gateway): - Invoice.objects.create(user=user, invoice_number="INV-1", amount=1, currency="USD", status="open") + def test_unique_invoice_number_per_tenant(self, tenant, gateway): + Invoice.all_objects.create(tenant=tenant, invoice_number="INV-1", amount=1, currency="USD", status="open") with pytest.raises(Exception): - Invoice.objects.create(user=user, invoice_number="INV-1", amount=1, currency="USD", status="open") + Invoice.all_objects.create(tenant=tenant, invoice_number="INV-1", amount=1, currency="USD", status="open") diff --git a/tests/test_billing/test_services.py b/tests/test_billing/test_services.py index e8bae87..c1f23d4 100644 --- a/tests/test_billing/test_services.py +++ b/tests/test_billing/test_services.py @@ -1,11 +1,8 @@ import pytest -from infrasynth.billing.models import ( - BillingPlan, - PaymentGateway, -) +from infrasynth.billing.models import App, PaymentGateway, Plan from infrasynth.billing.services import BillingService -from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus +from infrasynth.shared.enums import InvoiceStatus, MonetizationModel, SubscriptionStatus pytestmark = pytest.mark.django_db @@ -21,11 +18,17 @@ def gateway(): @pytest.fixture -def plan(gateway): - return BillingPlan.objects.create( +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION) + + +@pytest.fixture +def plan(app, gateway): + return Plan.objects.create( + app=app, slug="pro", name="Pro", - price_amount="49.00", + price_amount=4900, price_currency="USD", interval="monthly", gateway=gateway, @@ -34,81 +37,84 @@ def plan(gateway): class TestCreateCheckoutSession: - def test_returns_session_and_gateway(self, user, plan): + def test_returns_session_and_gateway(self, user, tenant, plan): result, gateway = BillingService().create_checkout_session( - "pro", user, success_url="https://x/success", cancel_url="https://x/cancel" + plan, tenant, user, success_url="https://x/success", cancel_url="https://x/cancel" ) assert result.session_id == "cs_fake_123" assert result.checkout_url == "https://checkout.example.com/cs_fake_123" assert result.client_secret == "secret_123" assert gateway.slug == "fake" - def test_inactive_plan_raises(self, user, plan): - plan.is_active = False - plan.save(update_fields=["is_active"]) - with pytest.raises(BillingPlan.DoesNotExist): - BillingService().create_checkout_session("pro", user) - - def test_no_gateway_raises(self, user, plan, gateway): + def test_no_gateway_raises(self, tenant, plan, gateway): plan.gateway = None plan.save(update_fields=["gateway"]) gateway.is_active = False gateway.save(update_fields=["is_active"]) with pytest.raises(ValueError, match="No active payment gateway"): - BillingService().create_checkout_session("pro", user) + BillingService().create_checkout_session(plan, tenant) - def test_gateway_failure_propagates(self, user, plan): + def test_gateway_failure_propagates(self, tenant, plan): plan.gateway.config = {"create_should_fail": True} plan.gateway.save(update_fields=["config"]) with pytest.raises(ValueError, match="Fake gateway failure"): - BillingService().create_checkout_session("pro", user) + BillingService().create_checkout_session(plan, tenant) class TestCreateSubscription: - def test_creates_active_subscription(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") - assert subscription.user_id == user.id + def test_creates_active_subscription(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") + assert subscription.tenant_id == tenant.id assert subscription.plan_id == plan.id assert subscription.gateway_id == gateway.slug assert subscription.external_id == "sub_1" assert subscription.status == SubscriptionStatus.ACTIVE assert subscription.current_period_start is not None assert subscription.current_period_end is not None + assert subscription.entitlement_id is not None - def test_trial_subscription(self, user, plan, gateway): + def test_creates_entitlement(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway) + entitlement = subscription.entitlement + assert entitlement.app_id == plan.app_id + assert entitlement.status == "active" + + def test_trial_subscription(self, tenant, plan, gateway): plan.trial_days = 14 plan.save(update_fields=["trial_days"]) - subscription = BillingService().create_subscription(plan, user, gateway) + subscription = BillingService().create_subscription(plan, tenant, gateway) assert subscription.status == SubscriptionStatus.TRIALING assert subscription.trial_end is not None + assert subscription.entitlement.status == "trialing" - def test_emits_subscription_created_signal(self, user, plan, gateway): + def test_emits_subscription_created_signal(self, tenant, plan, gateway): from infrasynth.billing.signals import subscription_created sent = [] receiver = lambda **kw: sent.append(kw) # noqa: E731 subscription_created.connect(receiver, weak=False) - BillingService().create_subscription(plan, user, gateway) + BillingService().create_subscription(plan, tenant, gateway) assert sent - assert sent[0]["user"] == user + assert sent[0]["tenant_id"] == str(tenant.pk) assert sent[0]["plan_slug"] == "pro" + assert sent[0]["app_slug"] == "messenger" assert sent[0]["gateway"] == "fake" subscription_created.disconnect(receiver) class TestCancelSubscription: - def test_cancels_local_and_gateway(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + def test_cancels_local_and_gateway(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") assert BillingService().cancel_subscription(subscription) is True subscription.refresh_from_db() assert subscription.status == SubscriptionStatus.CANCELLED assert subscription.cancelled_at is not None assert subscription.cancel_at_period_end is False - def test_emits_cancelled_signal(self, user, plan, gateway): + def test_emits_cancelled_signal(self, tenant, plan, gateway): from infrasynth.billing.signals import subscription_cancelled - subscription = BillingService().create_subscription(plan, user, gateway) + subscription = BillingService().create_subscription(plan, tenant, gateway) sent = [] receiver = lambda **kw: sent.append(kw) # noqa: E731 subscription_cancelled.connect(receiver, weak=False) @@ -120,72 +126,126 @@ class TestCancelSubscription: class TestSyncSubscription: - def test_syncs_fields_from_gateway(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + def test_syncs_fields_from_gateway(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") data = BillingService().sync_subscription(subscription) assert data["status"] == "active" subscription.refresh_from_db() assert subscription.status == SubscriptionStatus.ACTIVE assert subscription.metadata.get("synced") is True - def test_without_gateway_returns_empty(self, user, plan): - subscription = BillingService().create_subscription(plan, user, None, external_id="") + def test_without_gateway_returns_empty(self, tenant, plan): + subscription = BillingService().create_subscription(plan, tenant, None, external_id="") assert BillingService().sync_subscription(subscription) == {} class TestGenerateInvoice: - def test_creates_invoice_and_pdf(self, user, plan, gateway, media_root): - subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + def test_creates_invoice_and_pdf(self, tenant, plan, gateway, media_root): + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") invoice = BillingService().generate_invoice(subscription) invoice.refresh_from_db() assert invoice.status == InvoiceStatus.OPEN - assert invoice.amount == 49 + assert invoice.amount == 4900 assert invoice.currency == "USD" assert invoice.invoice_number.startswith("INV-") assert invoice.due_date is not None assert invoice.subscription_id == subscription.id - assert invoice.user_id == user.id + assert invoice.tenant_id == tenant.id - def test_invoice_numbers_are_sequential(self, user, plan, gateway): + def test_invoice_numbers_are_sequential(self, tenant, plan, gateway): service = BillingService() - subscription = service.create_subscription(plan, user, gateway) + subscription = service.create_subscription(plan, tenant, gateway) first = service.generate_invoice(subscription) second = service.generate_invoice(subscription) assert first.invoice_number != second.invoice_number assert first.invoice_number < second.invoice_number - def test_tax_applied(self, user, plan, gateway, settings): + def test_tax_applied(self, tenant, plan, gateway, settings): settings.INFRASYNTH_BILLING = { **settings.INFRASYNTH_BILLING, "TAX_PERCENTAGE": 19, "TAX_NAME": "IVA", } - subscription = BillingService().create_subscription(plan, user, gateway) + subscription = BillingService().create_subscription(plan, tenant, gateway) invoice = BillingService().generate_invoice(subscription) - from decimal import Decimal - - assert invoice.tax_amount == Decimal("9.31") + assert invoice.tax_amount == 931 # 19% of 4900 minor units assert invoice.tax_name == "IVA" - def test_custom_line_items(self, user, plan, gateway): - subscription = BillingService().create_subscription(plan, user, gateway) + def test_custom_line_items(self, tenant, plan, gateway): + subscription = BillingService().create_subscription(plan, tenant, gateway) invoice = BillingService().generate_invoice( subscription, - amount=99, - line_items=[{"description": "Extra", "amount": 99.0, "quantity": 1}], + amount=9900, + line_items=[{"description": "Extra", "amount": 9900, "quantity": 1}], ) - assert invoice.amount == 99 + assert invoice.amount == 9900 assert invoice.line_items[0]["description"] == "Extra" - def test_emits_invoice_generated_signal(self, user, plan, gateway): + def test_emits_invoice_generated_signal(self, tenant, plan, gateway): from infrasynth.billing.signals import invoice_generated - subscription = BillingService().create_subscription(plan, user, gateway) + subscription = BillingService().create_subscription(plan, tenant, gateway) sent = [] receiver = lambda **kw: sent.append(kw) # noqa: E731 invoice_generated.connect(receiver, weak=False) invoice = BillingService().generate_invoice(subscription) assert sent assert sent[0]["invoice_id"] == invoice.id - assert sent[0]["amount"] == 49.0 + assert sent[0]["amount"] == 4900 + assert sent[0]["tenant_id"] == str(tenant.pk) invoice_generated.disconnect(receiver) + + +class TestProcessWebhookEvent: + def test_paid_event_reinstates_entitlement(self, tenant, plan, gateway): + from infrasynth.billing.models import PaymentTransaction + from infrasynth.shared.enums import EntitlementStatus + + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") + entitlement = subscription.entitlement + entitlement.status = EntitlementStatus.PAST_DUE + entitlement.save(update_fields=["status"]) + tenant.status = "suspended" + tenant.save(update_fields=["status"]) + + result = BillingService().process_webhook_event( + gateway, + "invoice.paid", + { + "metadata": {"tenant_id": str(tenant.pk)}, + "amount": 4900, + "currency": "usd", + "id": "evt_1", + }, + event_id="evt_1", + ) + assert result["action"] == "reinstated" + entitlement.refresh_from_db() + tenant.refresh_from_db() + assert entitlement.status == EntitlementStatus.ACTIVE + assert tenant.status == "active" + assert PaymentTransaction.objects.filter(tenant=tenant).count() == 1 + + def test_duplicate_event_is_idempotent(self, tenant, plan, gateway): + from infrasynth.billing.models import PaymentTransaction + + BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") + payload = {"metadata": {"tenant_id": str(tenant.pk)}, "amount": 100, "id": "evt_dup"} + first = BillingService().process_webhook_event(gateway, "invoice.paid", payload, event_id="evt_dup") + second = BillingService().process_webhook_event(gateway, "invoice.paid", payload, event_id="evt_dup") + assert first["status"] == "processed" + assert second["status"] == "duplicate" + assert PaymentTransaction.objects.filter(tenant=tenant).count() == 1 + + def test_failed_event_marks_past_due(self, tenant, plan, gateway): + from infrasynth.shared.enums import EntitlementStatus + + subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_1") + BillingService().process_webhook_event( + gateway, + "invoice.payment_failed", + {"metadata": {"tenant_id": str(tenant.pk)}, "amount": 4900, "id": "evt_fail"}, + event_id="evt_fail", + ) + subscription.entitlement.refresh_from_db() + assert subscription.entitlement.status == EntitlementStatus.PAST_DUE diff --git a/tests/test_billing/test_views.py b/tests/test_billing/test_views.py index 2a8396e..6d8eb4c 100644 --- a/tests/test_billing/test_views.py +++ b/tests/test_billing/test_views.py @@ -1,23 +1,20 @@ import pytest -from infrasynth.billing.models import ( - BillingPlan, - Invoice, - PaymentGateway, - PaymentTransaction, - Subscription, -) +from infrasynth.billing.models import App, Entitlement, Invoice, PaymentGateway, PaymentTransaction, Plan, Subscription from infrasynth.features.models import FeatureFlag -from infrasynth.shared.enums import SubscriptionStatus +from infrasynth.shared.enums import MonetizationModel, SubscriptionStatus pytestmark = pytest.mark.django_db -GATEWAYS_URL = "/api/billing/gateways/" -PLANS_URL = "/api/billing/plans/" -SUBSCRIPTIONS_URL = "/api/billing/subscriptions/" -INVOICES_URL = "/api/billing/invoices/" -TRANSACTIONS_URL = "/api/billing/transactions/" -WEBHOOK_URL = "/api/billing/webhook/receive/" +GATEWAYS_URL = "/api/v1/billing/gateways/" +APPS_URL = "/api/v1/billing/apps/" +PLANS_URL = "/api/v1/billing/plans/" +ENTITLEMENTS_URL = "/api/v1/billing/entitlements/" +SUBSCRIPTIONS_URL = "/api/v1/billing/subscriptions/" +INVOICES_URL = "/api/v1/billing/invoices/" +TRANSACTIONS_URL = "/api/v1/billing/transactions/" +CHECKOUT_URL = "/api/v1/billing/checkout/" +WEBHOOK_URL = "/api/v1/billing/webhook/receive/" @pytest.fixture @@ -36,11 +33,17 @@ def gateway(billing_enabled): @pytest.fixture -def plan(gateway): - return BillingPlan.objects.create( +def app(): + return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION) + + +@pytest.fixture +def plan(app, gateway): + return Plan.objects.create( + app=app, slug="pro", name="Pro", - price_amount="49.00", + price_amount=4900, price_currency="USD", interval="monthly", gateway=gateway, @@ -49,9 +52,9 @@ def plan(gateway): @pytest.fixture -def subscription(user, plan, gateway): - return Subscription.objects.create( - user=user, +def subscription(tenant, plan, gateway): + return Subscription.all_objects.create( + tenant=tenant, plan=plan, gateway=gateway, external_id="sub_1", @@ -62,8 +65,12 @@ def subscription(user, plan, gateway): class TestFeatureGate: def test_disabled_feature_returns_404(self, authenticated_client): assert authenticated_client.get(GATEWAYS_URL).status_code == 404 - assert authenticated_client.get(PLANS_URL).status_code == 404 assert authenticated_client.get(SUBSCRIPTIONS_URL).status_code == 404 + assert authenticated_client.get(INVOICES_URL).status_code == 404 + + def test_catalog_is_public(self, api_client, plan): + assert api_client.get(APPS_URL).status_code == 200 + assert api_client.get(PLANS_URL).status_code == 200 class TestPaymentGatewayViewSet: @@ -90,7 +97,7 @@ class TestPaymentGatewayViewSet: assert api_client.get(GATEWAYS_URL).status_code == 401 -class TestBillingPlanViewSet: +class TestPlanViewSet: def test_list_plans_public(self, api_client, plan): response = api_client.get(PLANS_URL) assert response.status_code == 200 @@ -98,15 +105,29 @@ class TestBillingPlanViewSet: assert response.data["results"][0]["slug"] == "pro" def test_retrieve_plan(self, api_client, plan): - response = api_client.get(f"{PLANS_URL}{plan.id}/") + response = api_client.get(f"{PLANS_URL}{plan.slug}/") assert response.status_code == 200 - assert response.data["price_amount"] == "49.00" + assert response.data["price_amount"] == 4900 assert response.data["gateway_info"]["slug"] == "fake" + assert response.data["app_info"]["slug"] == "messenger" def test_retrieve_inactive_plan_404(self, api_client, plan): plan.is_active = False plan.save(update_fields=["is_active"]) - assert api_client.get(f"{PLANS_URL}{plan.id}/").status_code == 404 + assert api_client.get(f"{PLANS_URL}{plan.slug}/").status_code == 404 + + +class TestEntitlementViewSet: + def test_list_entitlements(self, authenticated_client, tenant, plan): + Entitlement.all_objects.create(tenant=tenant, app=plan.app, plan=plan) + response = authenticated_client.get(ENTITLEMENTS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["app_info"]["slug"] == "messenger" + + def test_requires_auth(self, api_client, tenant, plan): + Entitlement.all_objects.create(tenant=tenant, app=plan.app, plan=plan) + assert api_client.get(ENTITLEMENTS_URL).status_code == 401 class TestSubscriptionViewSet: @@ -138,14 +159,24 @@ class TestSubscriptionViewSet: ) assert response.status_code == 404 + def test_checkout_action(self, authenticated_client, plan): + response = authenticated_client.post( + CHECKOUT_URL, + {"app": "messenger", "plan": "pro"}, + format="json", + ) + assert response.status_code == 200 + assert response.data["checkout_url"] == "https://checkout.example.com/cs_fake_123" + assert response.data["app_slug"] == "messenger" + class TestInvoiceViewSet: - def test_list_invoices(self, authenticated_client, subscription, user): - Invoice.objects.create( + def test_list_invoices(self, authenticated_client, tenant, subscription): + Invoice.all_objects.create( + tenant=tenant, subscription=subscription, - user=user, invoice_number="INV-1", - amount=49, + amount=4900, currency="USD", status="open", ) @@ -154,16 +185,17 @@ class TestInvoiceViewSet: assert response.data["count"] == 1 assert response.data["results"][0]["invoice_number"] == "INV-1" - def test_requires_auth(self, api_client, subscription, user): + def test_requires_auth(self, api_client, subscription): assert api_client.get(INVOICES_URL).status_code == 401 class TestPaymentTransactionViewSet: - def test_list_transactions(self, authenticated_client, subscription, user): - PaymentTransaction.objects.create( + def test_list_transactions(self, authenticated_client, tenant, subscription): + PaymentTransaction.all_objects.create( + tenant=tenant, invoice=None, gateway=subscription.gateway, - amount=49, + amount=4900, currency="USD", status="approved", ) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 636d11c..e837430 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -48,7 +48,7 @@ def test_e2e_login_permission_feature_webhook_audit(user, clean_feature_registry # a. Force-authenticate the user and verify the session check endpoint client = APIClient() client.force_authenticate(user=user) - resp = client.get("/api/auth/check/") + resp = client.get("/api/v1/auth/check/") assert resp.status_code == 200 assert resp.json()["id"] == user.pk diff --git a/tests/test_features/__pycache__/__init__.cpython-314.pyc b/tests/test_features/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 8218e4ffc3487bae68a2a7a30e460fc09a87b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITX{m`N wrA4X5`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00HhTEC2ui diff --git a/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index ccb9c7bc26ad719048d7ab609f87fafe08eca38f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4296 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 diff --git a/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index b644c43b957e0b6c88839311f74b4971e7905e9b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24062 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 MlbCuU0HOE$-)F>rC;$Ke diff --git a/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 2c480b5f7e6dccc5b55cdf87dcc0435ec1084ff5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13171 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 Nkw3=Y=Mb`f{|k(6uEqcW diff --git a/tests/test_features/test_services.py b/tests/test_features/test_services.py index d40435d..df70ad3 100644 --- a/tests/test_features/test_services.py +++ b/tests/test_features/test_services.py @@ -1,5 +1,3 @@ -import uuid - import pytest from django.contrib.auth import get_user_model from django.core.cache import cache @@ -76,25 +74,25 @@ class TestIsEnabled: anon = UserModel.objects.create(username="anon", password="x") assert service.is_enabled("my.flag", user=anon) is True - def test_tenant_id_scope(self, service, db): + def test_tenant_id_scope(self, service, tenant): FeatureRegistry.register("tenant.flag", default=False) - tid = uuid.UUID("00000000-0000-0000-0000-000000000001") - FeatureFlag.objects.create(slug="tenant.flag", is_active=True, tenant_id=tid) - assert service.is_enabled("tenant.flag", tenant_id=tid) is True + FeatureFlag.all_objects.create(slug="tenant.flag", is_active=True, tenant=tenant) + assert service.is_enabled("tenant.flag", tenant_id=tenant.pk) is True - def test_global_flag_used_when_no_tenant(self, service, db): + def test_global_fallback_used_when_no_tenant_override(self, service, tenant): FeatureRegistry.register("g.flag", default=False) - FeatureFlag.objects.create(slug="g.flag", is_active=True, tenant_id=None) - tid = uuid.UUID("00000000-0000-0000-0000-000000000002") - assert service.is_enabled("g.flag", tenant_id=tid) is False + FeatureFlag.all_objects.create(slug="g.flag", is_active=True, tenant=None) + # Resolution: tenant override -> global default -> registry (TENANCY.md §5) + assert service.is_enabled("g.flag", tenant_id=tenant.pk) is True + + def test_tenant_specific_flag(self, service, tenant): + from infrasynth.tenancy.models import Tenant - def test_tenant_specific_flag(self, service, db): FeatureRegistry.register("t.flag", default=False) - tid = uuid.UUID("00000000-0000-0000-0000-000000000003") - FeatureFlag.objects.create(slug="t.flag", is_active=True, tenant_id=tid) - assert service.is_enabled("t.flag", tenant_id=tid) is True - other_tid = uuid.UUID("00000000-0000-0000-0000-000000000004") - assert service.is_enabled("t.flag", tenant_id=other_tid) is False + other = Tenant.objects.create(slug="other-ws", name="Other") + FeatureFlag.all_objects.create(slug="t.flag", is_active=True, tenant=tenant) + assert service.is_enabled("t.flag", tenant_id=tenant.pk) is True + assert service.is_enabled("t.flag", tenant_id=other.pk) is False class TestGetActiveFlags: @@ -116,3 +114,24 @@ class TestGetActiveFlags: FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) result = service.get_active_flags(user=user) assert result["my.flag"] is True + + +class TestRollout: + def test_zero_percent_disables_everyone(self, service, user, db): + FeatureFlag.objects.create(slug="rollout.zero", is_active=True, rollout_percentage=0) + assert service.is_enabled("rollout.zero", user=user) is False + + def test_hundred_percent_enables_everyone(self, service, user, db): + FeatureFlag.objects.create(slug="rollout.all", is_active=True, rollout_percentage=100) + assert service.is_enabled("rollout.all", user=user) is True + + def test_partial_rollout_is_deterministic(self, service, user, db): + FeatureFlag.objects.create(slug="rollout.partial", is_active=True, rollout_percentage=50) + first = service.is_enabled("rollout.partial", user=user) + second = service.is_enabled("rollout.partial", user=user) + assert first == second + + def test_environment_targeting(self, service, user, settings, db): + settings.ENVIRONMENT = "production" + FeatureFlag.objects.create(slug="rollout.env", is_active=True, rollout_percentage=100, environments=["staging"]) + assert service.is_enabled("rollout.env", user=user) is False diff --git a/tests/test_features/test_views.py b/tests/test_features/test_views.py index 8f0f358..5a9f811 100644 --- a/tests/test_features/test_views.py +++ b/tests/test_features/test_views.py @@ -17,26 +17,30 @@ def clean_registry(clean_feature_registry): cache.clear() +def _flags(resp): + """The endpoint returns a list of {slug, enabled}; expose it as a dict.""" + return {entry["slug"]: entry["enabled"] for entry in resp.json()["flags"]} + + class TestActiveFlagsEndpoint: def test_returns_registry_flags(self, authenticated_client, db): FeatureRegistry.register("flag.x", default=True) FeatureRegistry.register("flag.y", default=False) - resp = authenticated_client.get("/api/features/active/") + resp = authenticated_client.get("/api/v1/features/active/") assert resp.status_code == status.HTTP_200_OK - data = resp.json() - assert "flags" in data - assert data["flags"]["flag.x"] is True - assert data["flags"]["flag.y"] is False + data = _flags(resp) + assert data["flag.x"] is True + assert data["flag.y"] is False def test_requires_auth(self, api_client, db): - resp = api_client.get("/api/features/active/") + resp = api_client.get("/api/v1/features/active/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_includes_db_flags(self, authenticated_client, db): FeatureRegistry.register("mixed", default=False) FeatureFlag.objects.create(slug="mixed", is_active=True) - resp = authenticated_client.get("/api/features/active/") - assert resp.json()["flags"]["mixed"] is True + resp = authenticated_client.get("/api/v1/features/active/") + assert _flags(resp)["mixed"] is True def test_includes_user_overrides(self, authenticated_client, user, db): FeatureRegistry.register("override_me", default=False) @@ -44,23 +48,23 @@ class TestActiveFlagsEndpoint: from infrasynth.features.models import FeatureFlagOverride FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) - resp = authenticated_client.get("/api/features/active/") - assert resp.json()["flags"]["override_me"] is True + resp = authenticated_client.get("/api/v1/features/active/") + assert _flags(resp)["override_me"] is True class TestCheckFlagEndpoint: def test_check_enabled(self, authenticated_client, db): FeatureRegistry.register("my.flag", default=True) - resp = authenticated_client.get("/api/features/check/my.flag/") + resp = authenticated_client.get("/api/v1/features/check/my.flag/") assert resp.status_code == status.HTTP_200_OK assert resp.json() == {"slug": "my.flag", "is_enabled": True} def test_check_disabled(self, authenticated_client, db): FeatureRegistry.register("off.flag", default=False) - resp = authenticated_client.get("/api/features/check/off.flag/") + resp = authenticated_client.get("/api/v1/features/check/off.flag/") assert resp.status_code == status.HTTP_200_OK assert resp.json() == {"slug": "off.flag", "is_enabled": False} def test_check_requires_auth(self, api_client, db): - resp = api_client.get("/api/features/check/anything/") + resp = api_client.get("/api/v1/features/check/anything/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/test_files/__pycache__/__init__.cpython-314.pyc b/tests/test_files/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 2f70cb2bfcf672bf36570925ee266564bfb83bc8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmdPq_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITX_+~x t#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=7PY%c~eJ~1;gG8QodSpZ%>E8GA8 diff --git a/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 65c7d45149b84ebb2a643a4fb14346c072b1e624..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20607 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 diff --git a/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index d6af52cbdfd7f578b500939300f62741a8239e8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16144 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 diff --git a/tests/test_files/test_scanner.py b/tests/test_files/test_scanner.py new file mode 100644 index 0000000..960fdcd --- /dev/null +++ b/tests/test_files/test_scanner.py @@ -0,0 +1,34 @@ +import pytest +from django.core.exceptions import ImproperlyConfigured + +from infrasynth.files.scanner import NoOpScanner, get_scanner + + +class TestScanner: + def test_default_is_noop_and_clean(self): + scanner = get_scanner() + assert isinstance(scanner, NoOpScanner) + assert scanner.scan(b"hello").clean is True + + def test_require_scan_without_scanner_raises(self, settings): + settings.INFRASYNTH_FILES = { + **settings.INFRASYNTH_FILES, + "VIRUS_SCANNER": "noop", + "REQUIRE_VIRUS_SCAN": True, + } + with pytest.raises(ImproperlyConfigured): + NoOpScanner().scan(b"hello") + + def test_custom_scanner_path(self, settings): + settings.INFRASYNTH_FILES = { + **settings.INFRASYNTH_FILES, + "VIRUS_SCANNER": "tests.test_files.test_scanner.AlwaysCleanScanner", + } + assert get_scanner().scan(b"x").clean is True + + +class AlwaysCleanScanner: + def scan(self, data): + from infrasynth.files.scanner import ScanResult + + return ScanResult(clean=True, scanner="custom") diff --git a/tests/test_files/test_services.py b/tests/test_files/test_services.py index bbc1cfa..f62cf8d 100644 --- a/tests/test_files/test_services.py +++ b/tests/test_files/test_services.py @@ -39,7 +39,7 @@ class TestUpload: def test_upload_with_category_uses_storage_path(self, user, media_root): category = FileCategory.objects.create(slug="contracts", name="Contracts", storage_path="contracts") stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, category_slug="contracts") - assert stored.category_id == category.slug + assert stored.category_id == category.pk assert stored.storage_key.startswith("contracts/") def test_upload_rejects_invalid_extension(self, user, media_root): diff --git a/tests/test_files/test_views.py b/tests/test_files/test_views.py index b8d0f04..20f76bf 100644 --- a/tests/test_files/test_views.py +++ b/tests/test_files/test_views.py @@ -7,9 +7,9 @@ from infrasynth.files.services import FileService pytestmark = pytest.mark.django_db -FILES_URL = "/api/files/files/" -CATEGORIES_URL = "/api/files/categories/" -PIPELINES_URL = "/api/files/pipelines/" +FILES_URL = "/api/v1/files/files/" +CATEGORIES_URL = "/api/v1/files/categories/" +PIPELINES_URL = "/api/v1/files/pipelines/" @pytest.fixture diff --git a/tests/test_notifications/__pycache__/__init__.cpython-314.pyc b/tests/test_notifications/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 07c71570b95894240cae460796a7e9156d26c271..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITdHE%o zX_?81C7Jno#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=tcTu}^Sd}3x~WGrF^vH%xI BFG~Ob diff --git a/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 7872fe265938c62dbba5909ca60ae1e7da42b5f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41201 zcmeHwd2k$8df(jBbD?n%04aeYz)Ktw#6f_UsDrmaEyabJ6sX8p%?^PfIN|^^=pKk7 zcD0hP613K?q}r4v*|HbwP1#_jQlaBi!qlb`y5dAC*CsW<02sI}Ra})#>{Ko;xegNM zkL35g?tb0VXkb8*lI0W{jc?w2{jPqm`+ev8dRJ8}?85PXpU=Gf`X-lafFj1>(*t*Y ziohGLmt2zjlq>IMzn&iNm}ir#$CvkS(yj#Z0bKE%3ib$jp(m6Nv2^dLa8D#3VPW5? zXiqF3V`2ZPsva>fvT)#3bx%#cW|OPl)#8$ZjV?*(_GoSAYgt+dXE5i}JK} zEG>q#DxOx)(nO?H^Rx!Zy~P!;`Oj4SnYc#{AIayF1DA$VBY8D)G^wPHT}qCOq%v{0 zDxOV^3_dNV2hF6&*=Nu7n$hZWSJIhu_UyA~%{1{`Dw7(Llf!ya*@(KeyK>!WMIebW z{RYNUa^*cQh4bD9R~94O=*s&f51?Q2<^z4Md{FYC$9@$RD|t%~2aL+7&LIB1E|+lU z0|ef1^-~%Nlp=)vecZ$enPHdB(g6SGS&ZldGRE-JucViaX$1P)6kn! z+Z#YDwgc~1q|evomZImkba}e<$3XH*zJ9^T+RIi1Bc}bf@W&)Cn2=nN^&v}fk&12b&eev_yV9cKR zi+S|i=R8)e&EuCmkL%6(GeVfh5Y6K=_r9u%Y*npZo5e4A79TR^&iutJhVN?@Yu0A* z%bZ0i1p2mi+<&nx|7u<#l4LNMPmYK4saNyuxlA%WqIz>zJJfK}B-N3yhf??6tx76N zO3pWL+mXAvKdw;X{xc`9x$k=2ksvYHimFMF-M@D3n%Mm$lBd zt&lxEl1YzHGnNunGI0Q^VBD-4KCBVoiKML7BnFb1OhQiO1jHNUA z^oXJcGFjwM)b$DFQg$pe$g(DeGT95sOoGNv4JDN1#T3<`Rvlqe0dkv>^J-nn0*(rt}&X%Vfheb&eU zC5T$KH?u<)sm2CI)0B1RM(K>eo4;8f%F)?)or6X6AM zLrL5`Ep9FdyC;Vjm=<@J1pO=uy9?rG9_4|FaUPrzb{nyIOYT{!k7u$5P=K}CGA*_g zgx1OP3`~oyB|$%nLTf>6;ZYu-cJwSWLaPy*x8$Cs`gkU500mg9Tc*V=1)*#5+YC&L zT_r(3i$YgH+`^+gK<(&RW`r&yHgCy2OZD+g)&L5i)px%0)^`fRw#juG5Vw^C{VWRG zCceXCJV1@;IcAVUkIkEzXQ?)x#~MI>cAw4DVsk-=PoB_#7%vI>Srp<0v6)ACfSS>> z%m{HKHgCy2OZD+g)&Q>D!6W9n2H}LD3JLa(B@${lkr>Vnj%88^M-z$Bv1EqcKE%WX z9${I@)0;n%O(f)M6qnlp)cQn1$>XgXNZ>h^(-+3@?4v|YA~BG`OEs7t$Pc9O`X{g! z#Vm;^Mhxj;_QuG>t1>`rp+c|a|9TUT|80-Q|Infk@bA3k0+2h6Yy$}J!(eRhJc*<- zfya6$fd|AM0m%m#l>C5#6v&4l0trh&pa?+|fmlQ^&OG&R`d1?Y(Kv15d?kN7=L#9e zt#ifvCJn?pYz$hj{LJBbg^*UW=qbT@NyitS&p6# z+hOI@!Eu8R?yr>J^YXD>!f}Toi&)BW*BRZj)a<}JFT-(1EgZKqr{-OjQ#0>+ zrFj&~sKd2+^h;m#JUVk~{`;KAa$aq19>3sul%gbeSA9KSKYQ+OwKAN~wWVH74(Bqd zHjtjGulMWHx%6j1HmdOa*%Q*4BhQ{t-D5JX2A~-=xvgA}m`ty=3`wF(rU&DUya5n& zbn6O)@ujS?gk)bSe=aL8$-fdOF$NZ7DJKz=fr&uGUX-)L36Q-P(?hC{x*|V9y@$HR zB{vgjA+VXi76MxdkeFHZQAbq4sNSHBk5Pu51a=W1WrDn$KpVhPnzxzJyp~=hGTAFB zc>r>AC2=`*RZ%;L`n4cdP4c}$2^w?dbzpbU!L=JpwegwS_|(2a*NL02e3CCbpC}{; z3p-QA+KUsx1+n3sZ@&dHv9qwLQ-jlDXGze{qR=@3n$Jwo!V_0`+6;2&v3WD|EUS-a zvIbCqH@fA6d_e#JwOSJNvnXt#Cd~vbOpWMiGsvOG=FQBrR1wc(4In>SEQy<@#Z3ia z`(zgb)8h7$pr1uydqLd9qdY*(=viij?M7_gl6#iwYM^62oXuS00$qxxD$CJvCja9Ou%UrZU=wN8bDw5e_ zK}YmZ>mnG)VKgbsW`>cdhlwlcJS0o`1dRoxOZhQ*gmqcnUXPy;V$z z8AlKSEak$Tf6P#^1fA=Man04wb2=^LSWcj6#`5iI=Syae7JaJIb`Xy&fkKvq{W?m> zdHcLh+^$6%t)YhyaR-)Hd$pV{U)+10Iaa=G8%wB;9?WviOEoVC^bnDv{gv|brMu{= zx1FHWqnd$(d;)FCPXVYwJ)itFiurpW%E^w09>4HpPx|rp3s0VdB}EXg|E2tJ=5Bmd z%T@NWzFNubrKdYQ6OXE~6VDzwbvkkM$g!`TICGpWFYzP`wR2h}OWgl}i-g|<{|GnWcwY+xXzC~V092}@&>(8oaCyZmagLNOw; zmEQn(!?n=TJ0Z*pk8pB!Ay!wYe{^a?p?jbZ9lYc9)P*O4w*s!(4f=}GczxH+{JTxX zn$y~I1@QIxoGe8~pEuq}oj}A}CdK;sd*937II|H)>k|In_d37H z{LM7l(^mkqoU_!3m%|DWldAeF<>zm&Z3PJQ6A#J`OC1ifie)5KB5Y1XtN_(=0+pEW zwfs#)$Ag2}fwtV>MWzqi)_eTqxR}i4GP*faJFc+TUQQyj>gh@2WsjKJ3Pm2EI*EC< zzREFqkWz>%)m~tF%Gg^hrzq4+poPG4RD!{QUZhOy^`$}9+So}U_72kntG&ZIzHeFP zB{qAkcCPXTHj5C)t9%5o+L!ki0aruwWN2!Cq5DL!{v?z(oH}T{zV+sdH&4^s{M;At zHsihJ2U!#`oV^G0P{~v;2kSt|-R>T3!BfQX^k|n8Mjt(HzDIbrJj7$?>f>%@hsH$lv9MZRPn}YHe|8 zdA!qPF=pPOV>#0@4GdzXynNIlv9FYuQx~g{q4RY}sw_k2YmgMK*awqM3YIan0?|7T zY!WRJ?6B1AymC6RNincVZcXN%^WxWMuuY~-qw!pf);K=3o6RQ&EOXW9kNd)9_Ba2> z_9Qq87^GUJV_A3pF$-(F_RPYX+A^!o?}GWviWT#QjrX+OU-oQ8q-v?AFJfcCqy4LY zwxUMO#xGcK+p{GDQh6WB-K0EDMkJ3AQc0`fGiL^{DY zv*f*JPEyW;cgwDV(`HvYWvoFYSpFX4zhN+1_7_BMkvPX`hjVfS$CBc)%iE z4ZpU7+USP$N%*z4OQ~cgepp}f{b1XgUD>Rh3j zgXl-Zq!QGqwMVO!UB_dZ41rMvB%!v-2liSc`>m0KngNP7U7dT%Vx3w{GxX@}E{o}Q zA6mHsWtTe2VtbSrI%5hXxJ;sk(J0niRa+cs5^I)ys7o0EdWXb8e5fRDhs?JioSq6? z_fA5_aJnFFpM0$-?nSU99-S7Cvb3W`@i+pX_%tA%F5xmeXQ{|>9_4|zAxzM-%m}BA z*gR{1mz;!*f-B`<|&0(bKpxPk7H7@rp72o%J4QQU>V)R+du z?h-Duvq+bB@hA^aKlLm#Lbnl{7rIHBLKkLPy_T?E3DT#=cv)Kj&1=>MdocKC@T7L( zS0*eY?liW=1BhX9$ zywdUaa$7akJT>w%vE8^i< zVaHPSX%IKdxbv66>si&O#E0+Km-U>xA9=I`yS4OGC+@~kn$9WirJL?@o|paFMWL61 z9lg#nGCnG{6~!#)VC#>Sj+euVLK6K-`8nThljr&Sv8zoxEOn3|%yw8;S7YwMtq3{a z3!dI)TV_OiH!n?-Vy%|D$p!iXQk6C3C6_fOiPo6yTDA3B4u+GXZ}tgnXW)m%NDf_P zJhVA7TbK3-`k;sULQpS1eEwAn4_#v!3rmlU@)(PtF$Pj?#k_)%*EaTHsZLtg7q;<_ zk$aeth*7igOEUsl1k||l2N>OY>nPbqu)%r_BiOh$f;7Lj5nOK_CCUEOZCD#K` z7T3yKqfdBF|3u#!-E=pg4DAN`h1x%u%%#aL3x;1K={&Kecb_!(JGYhXYi{FPnp31D zOb|97!k+auIW;;)d!gHMWMm*Dp*qXuQ-c<*f&Y9~&J3P$QWdDamzC^@TAfSD_)TWg z5lro&26M@) znQU@Uev$H#%tQVq8U?nCMUz_Sf)6dSUy0(fNz5765t<}|(Pg^sQr+gj0b0m6J=;-K zr`!QK!pOC$9kaDv6TweojUR-Iu`Q+8j_KHrQmkz{*0vBmz3BB+g@54!_={N0LTmfP z@ltf-badlvG`_IS65G5`-}HX}d;OE~sq@!|iuF%UoLr2c)UBwiS~u*wPo3wi@bE%y z!~1RTwUufgovwX!Dlk{u&g=#Yjqy2g=TzsMxO-}FPVBmVWKP_7{mPv96a*(X4>B-q zk)9Mu9`ckX&zQ4>A?&z$aH*IBDUfu)aPQr#b7IS6%bd7%vTsgoEeMCMM;MqE50wP{ zEDDDTw6}($bC&Qd<>tApff?ZtRsPENUjG1nezYJQF6=t2!D;buNzl(C(#1!$=$s`y z%kuMV)&MRo)?*SDY5pLGs0!az$T`73+jqLf|I?Pp={?K%8`$joo1to#rCCj?*8e%F zT4tAGP_fV;gy_$RubQfbl?rIbuO?MX`g4(38a=uENV9dbzkbPZqKnYssh*=EKVl4_;8zL1Ru4&MU} z%(XKX8kjs#1V977NxN(-P$TZs@9We^gAD5}m;8MK?-FcF#-zD%v0-sNG0wFQS-SY2IJ>{rd z!`RH2o-nq>dXZLD3x?L@Et)|+L(rG<`5c*fX5}(*OZUq7+PM@7kd%%GfWEs-WWF-` zeqe@>*pa#QhtUtC`1v3@Ep9Cd`dJjVPC$}Ru{kryFV7=?fX8Oc7+r|+3)aA_uys*% z#C=ac;s5xFNKe%=xKCiXPxE3`F2Rw!T(aZoH!%`lQ=L5NINC}1A$g%E#&$^N+7;aI z(Jn5P{cM7TM=vR-YzNt1!TXgiaRP>O)1MJ%7>*up+p$D$quF6q8`+$#svIQ2>v#!{ zgNzg=Y3A}50}JSOSZ28j@*c=-$PTMgem)yE*@3^G^t9}-)X`+5nZL`(ZD6w?1%Tlk zBt6SwIJ2+KX8lHJSqx{Eavl-$DX9j=|EkVZjb)Ub?}JN(R*B(OKYvabj;v2K4ClTl z42N|V2+IM_slhxvHoztwtawC852g~Six=T^%w(MzvU9?9t3h)$s$lTuP6$t}mNd@& z#H+-y!_7uGkI)^|>?@4Ox_u7CWN-&NJV=<-&D|C+!p!PT(Q&bfx@LYO$$2Zs8- zT#dzI40ph8@)q6XT_!^K`m^S0D819o?zeR>7KN4aa{X|7pv;mh$aZrFZCr?8*-T~D%(-Pg=%jh(GJazQAV;QM`8F#-Ib}YH z-FgME5Y(w7vqtcjn=MbFSBj1PpfBWLA!zoC>%q)g*fU6e4{OgKTzdvFOP}uX83G4u6l1#78I*Z>XVw7C6vd+GxO4oP&hT>UJNa9b^V*CD?~>Ayzc4FbPH;8y{d zJkieiG83v5gwa;hxFM?K9K+=ckM1l8N8f&ZQkfNDLUyzy9+(ynAW#qw6vf97xG|^! z5vFSTIZKxx<53=X`*j|ip-Q#byl`|<(SocX49Tb-7CvAJ>lGsX#-NtG6rwAiijNe@ z@6UmfxEp?)5GaVdi()$h*I~>@Kt#3rS)|MDJjw&qIX%maaKMPoQ*QVTqARnkVoO-B z1?kse!DlvM4Ws?a25{e{iTr&6f1kh~5vX7QH$iy_9Kye{6@VMS#cxNW{v+<&O(Flb z+YMoVd~vKT4r*PYVgvZ+he2lCIe?@x&p(+;7I1l?&y^2J0c?&5s_TfHwEI~e zFlwjfXu}dCa@KadukCnWGk&92!m?GznMmVk+Ja2&JKkfv_U`ZM=;++j)xCG$etBgx zI5jdLrv~8}F%5Ttt6_tqGLdPYled*$Kv_pqxVXut8m4fTj+HU0PdcqE7Nu&1^25{x z!!~Ynv39Fz8<(9@t{*Gb9C*OH&70uh?+>Csf{?X!5<*t!BJ~q~{>Y-5VsmDYUxs79 zNeEf>%y8gm#OOklU$6$?|8E7`xL*O4Znur2z2y6`4AW62UFBTKs_o-AZ?aTI%66zb zSjS;M`ZAJN+PiAVO2Nb0wSzt<-)l?uuxi>t&Y|^k*_?L21Wg{rV)MpdK4&YrK0Y>_ zOKoIas#(ri=E}=~J0b|Tb8kn--n{@F`>uJh9d=+K zJ2nED74n2hXO&^5>-NVk)i_)!s~iMs3;sxT-LW9r4K_ zd`BUGbuO7tsf`+G&pf-P1{2wlffU;@Z*1K?hWga-0J{k|f~11l4VA0Jmbj5(#H8jY z)lQCC7ERp}ML{FVPXWjhW%Jb7&Bj9C;B0Nm*&LCu5X<< zX;_}LU+=mRDb^o{<+LHco{;w%Za|!5JnlJ< zWh*{^!JN#09o0&lC3aXSu^>X|!Bj_5#ApvrI~>Gjy?kNAgz1OV5;wwK13*z#Z{#$8 zX(__zR}3WJ1Iv#;BMwECuh(HYTe8?Uw*OOV$^D>W2=#7{-RC(ELav>au~2~G~Y{>b+&_yFKHx3 zO_Se`86q+Jy2DlkQ9K_`CcpKT-ZeXwvY7lfe1&JKvEqGjQKd~$6_(aZ8vqs-#czz|Ets zCQ{^C>Kdc_oe*H(Z|yPyX!Qu;rf}eSV?3?%hOT=VQO{dXBlPCY~upw@gR3l%lQE z(biJ5YdYFB8+{5WQ&)x&G7T2%4+EJp#P)!-VPCQ#NKD7g1wzkcJMqWvBc7fb|0gw( zp3UV5x(-*pqb+ z+zJj_u_tW0CVJ#;2NvIYW(U_$W(P+u3psz&OPjrHhuJnmeJ!?@9MHRyR*R!R$hJSH zR*XF6`i#ywkq|&*rdF)7_R7>qRMuW`n-loMsMH6J_Th)DQeKX_VPRM)FGtbfzbfVB zgj%~$rfmyJRg&mn^9QY}c35iWJf3lliMpIDBC3~a+sKunW+h=h>jnw6Dy>`1=pXe( zK8Mz=p84fyJb%cdbz6JJ!R1J%|i?V%HQJXe5b`M&&m_dLV zztHB|o-g@pQZ+>N>s5D>ZIe~q1B?*SwQmgF6SpAe2$z{*H~oICXxN`n!0Q&3lHaztc2!uhZbMZ?))}Jl!3J9_x~SnP<@b zWgIYddraS_&|_yMb@c5gnvRcs59J)A;>^!mYJ9#`$@eDeP`<;67wjP^2$yag1Nh`D zz^u4w>ewe|XT|3bDTyyli!UKi5ML^a-=x6cB^`>FO31*@S<3KD9_0bl#lthKYArf1 zT$(zj1yS^d8T{ldPc4YgS;8+_!g@p0=%CpLEdz(R`l-07NS|0aSQ6W&#Wn;AVp~z{ zK;Sw)3qnA|_7eRp(&Y{wT|5Lnh!ldhkE|LE zQDa;Zv!)MR(+95U1L>7`5c)vX&oqHd~Fo)9?hjg4NHcBj`a^ozslcXDDHD@z93#_T! z%yQ1;I4=h&FnuDaQhrXp+f;7%`(1$$Hh_d5@OK5OnZwVTGKZg)UGrF&&m~cMfag68#<10)7G8m$)F$wsNn-c(Y>}$3nauR&Kn}6}K<(F}JPU zfqU3!#@7^!*d)S?B^PaT|o}+F_}g zYi5mWj8kzEw?%+4$dwU(<=>JbtI!?J=>{?nGu`1ex3-=7zn~@gKLK3x$p4MP{{!Ie zfy%Zod7VbK`IPZwFQ1eDJL*t_XU8tQOgrR~h&kH8s9xYV`R`Jzy#$`4VqUmV(R}66 zRF=7sfeTtD_KUmk2H@`opT2VT z%a=8GGA~kwT>w_kxBHm081@M_%>aKn`P}bQLFN#~CIzveWZewsLu6H+b`u)6h32keQ#XQz zrtax-&$G0>oP5d7!L6(wN?B)j62| zoGrx8Y8F8MptEPE|C61OoHCSO;+noy7z$^zW0J|c~`s;{2Ot+~I8286uyAy)#jsx~z)^^9dw`RP? zwgdRPk-6(zU*@i_GXCbONZ{|U?o6$#I8&Ssv2kr-sa|Suu-#cb4(7@=b&%m# zd66RY92Wp325(BH#tD7#jP2>+sb-MguU>Wzg>?YI2iuBs&%(cD^Y!HEn{++tqQ9@yS; zd`z_c4<7%~1R=BXP*n|qh0LuXT(>H*w&a@XQNaP5L@s8Ber^m*c!?ZTXjW*U_K=^ zWncAjw43SHpD|K=>K@NcbnD2Nnxq70NHxK+SK9eM$pS6HfJBX_lMX#9I<)s z0kJiji0zCMW>bSBsr;3!d|Cd77+@O```nIGEI8~Bsalhp`$rUIjNJX_4aI-;}~`077szEEtu+d}l@QU@sOogAQ8u+;yj4p2Pnp5E&J zcx&Y8z2#_1#5gip?3x3VH3uko7W4ypKvT-Q<^V+>6(^m+SH|4JBg|JR?Vl3J1C$wC5@7+UYyQ67o^aKsVPRRpaMI%Vg!TJg3@7Cq z=yfIEC5}khYS#EKsgiRH{Vv4e5Bm7quTRy@k{^)cCGoLoa#?a5&Pj?QIJ4t7@fj}y z^xZD~oTba)vdk1MOfEq5v>B>Yi_Htir|PsIE4bSdhQAUjV6710n?5ahDMVNPHu)}z zIvOkf6^38FMSx`8vI`TjDhDP%qud1E#=o)yfSWDi<6S;KyqJXidv7;H{2jL+io%IW zQ@IlpDjIK8g$rZp48BmUFky(gA(6XEGKn_LwiX`wpa{7lr?J;I#FNyZo~DUSh>caZ zS5~di1}T~NVi{D5OYNv!qTJ0t^9-c6uDaN8KF2=nx0)j6mS?tQOWj$y6+=^`k>8>j$EabbIE(%^S7%JASu42 zJPdDNcfbCb?wR+#_qqoY@hAt^pIeHz>cHhOVO;@}dH8!^KH=WyB>o1c^Yqs@>0kAA za+3i)*vU#ldI(BUI=|B$4sUUK{1A7Vll(_GDKO$= zxq5=;1tBkF=ZQ2g40(c`*DCQnTsrbktTvVQ8}X}4N~xq4ua-*-g+-%%s;n0l3VB5@ zluOqNT1CT;#1=xW0h@aEti4gsUUB)kU`AaT0y{h01Q%#~qEj&OQF@&OG> zem#`q^sp3wp#+VN8CBEW{n|ZPtzzR5`1f<1@bK?IKjCIEk6zQ}6j*8eoesKFjF(^x zl27tW0Vybj#zHK&!sqyDlXust_#|%Se9k{U$#E;!`pu%nuQ=`mH!E0@)AXgVQtTI& zCXowV;3e^9&jsH|faOYINtlgUlG6!`bAC^ABWB_f{4Sns>Z#R{w7An;jvq1S$Qt4N zk*0cWmbCNhuJJnq1i*$$XqRZ zdxsvAZ4q^8Bv=;ryWV)_U$#ZEfg9D1LcU@sQ7t`7oyx}WoP%FDf zEptFnv7phWpkg{sP@@>(GJP7&2<3?i+L*LpZuI5Knhx4jR)~?5^GdNO6SYEMCMyb2 zjTjWmbE}1-UMNA$Vi_bfqeIq~%B#hBDk?7)%X3Omrjr+uHDy7?7L3Fz^x45GOb}8g zMoL~NlVwGh)jMDfO4MEu)KTd$%)eS(AgD-;7VMEt+ps$^;!a#(3D)4KJ29?>p_RTa zdKtOw61)=GBYAG@r$p&?uElF0*E4 zEtlujqL!(w87*|m*gP7&PqIcCmsA`^r5y#;`w90=s=t=XR#VwpYP6afttXG+-G01& za;Sc6sNUYO8|T``9&+LK=sjV#m22(zZ2kWFhLC;`p`}_SXdS-V*vZ#7;YDGuVwOvMKaisU4T} zHnwLAxfv*V2s6ax-3A=zj2iI2Jg))wqY<|Zc;~Z0fgptfekpYDmR6EpKw+iZG29jZ z`@WT{xc~(XZ(AmL@jp!_*&8$@b3p(NZ8t}r12mj&>d6x{IMB&5qt;00k2lr#1Ot!P zKOl&(!O8(ZgabHQT(H1%JUJV1NU?oo1J*NVgAHaaB*g)?g_^)g_$k0i*lOAO1#lt) zIN=#UsrUh;%*@&lW%?L?A&u8*ZuW$gW3_Ubm-$uSs$aI@;WNJRt>zow@a;$<{*AyF ziB~5li9K8wH^^CU#3F#}6I=m2n3GV7e<*R;f08Tk=@daAZ1ArTe4`C69imov!bEMUy6Cm3eaibmHn`SNN>kFF1-9Xovq%&o}A2OkDU zaBYkr6(j)9IdTd~Cz38C@bz(|8%Yn6ULZz@8Dt|wl!B&eMr7hnUag>al@`bu6zfAW zfU+)QdC{F1cPGY}6=%kB_*_HkyoL*6n#(RSh>f12W^1)nSXotN#36D;CDgpL)fn5` z-aWD6QD|Jd4hkMjpm3rl^j3x5nlM-u2B`^brzWtoe(Xg3Fq*)~(05!o68)}~i=5n3 z_+*1{Yi~^)tcrsh!gx&_u8PCJY>2~K;ss#7T%n{Yj@O`=-dk9F!A{!Dw%B9yo5HxA z+(G$4Rd%wF>i=X*Ae&(B4S1mSBjSbXd*=j6X zKXj^osH5J|SwG!VKh*`Z0Z$h{2U8JGJo0miKBxuvX8g?e8h$l@J=J$T5&SCAa=kP1 z1^DNJA;@&CWZ8(yGC-Eqq6&OWmRDAlB7Rel5@mUzKs3ErD5<5gEE9CX$yHqaL$a*t z0M7EVqU)qEw+e3q)JV#5z6hV!d?Byr0YS?$0MfWC6RN?|axc?&h@clo364=~2lDSv zVcq}biv|ZCbsP&Ob~zv+bOr}0SX97VONmDTi&tO7`eV+4=<(k^lq(=%W$>1Tq2dHQKSJ3y>?f@|#?w@hvsOUCxtx zhONwZwEgC#|HF4wbj`Tu`;v=TGu;&Kib}0gG8b)H%a*5D%NDC;>(^OJQh;?t_|bG# z{MqMS$3s7^V}ob#1Fxf$fPmNG@59Q=bAUmY8IOG8A8V(D{J1XU=(04%|m+rrh0djzEr zPONGUNFKMaHZUgE_D1BHx6GQE>4~XpH>R#f*Y}Bk0c3$#9e{FI;0&sa?=iZ3xumOi zbXhAY6^Lpz)oo?d;(+-Odm1Ce#%6@gp&c0Bhs`k>t<62BV-(&SfmMhZk{r%1Mfxux z_c9U(^D{7isR7Fm6SP|-jpQs4iVyoNY!3l32Jeqkct5W$D62(yY&yk&@JsWB(xNfa zjO{J>#9~?AwZ^cszcFCn6x%x7iKxE)NIluU8=+CHAdPCpFsk+O{f{?2;@-!0%4RUeWJ+v;gqhm$5QaSL$0cnip=hJ~3M+zs zpl4gm%|MYWF4p@1{0bmd5nLGa-V%CgT+GQ~MK~@qZ&T>CQadi`ZEVUGax+kY_Bv1% z2R4MvA6})TDrRbed2b1s4ROFu+6?w%ifjrQE4Aa2-p2N9AvXgh6q+|+W_vsBH9*qO zgP9$?uAG-X+(%pQ85DZWNak!aHP z6!`fP<=eD^&A7j?Pi#R6(N_cWG%FfHAc+)6glZp-s)4Un&>!5C$6B zTQfIGzY1Mwe+2UQZUbuMqf8^yjfhM&m8qpJR#O*uTd0B#Q3ai_74+E$pm~5)#j`cR zytjn2sE?ft#-o-o^EN@kOzk+r+t`pT<7S|QMFS>a)DXrXui-3RM>2`z1`-9yB9hmU z{1nL>K-`gspJ67FQ7ph0!;Cycg4suhh2Y>W2ZZ&7Vh4;oyt!}WA&Cpra5kc0Gs9M& z9iYmdQGJ@{5wbieL@Vs@gwqI;Z^-5D!Jf9?<7~njRgd@r(#tCl0cR7EbXU8rVOKlo z3Y*(X&uWK*!zPZZgV2N1gSV`$uCl%cARWXr5FY#g7$2sWt7ThRTV-?=cLhOxqhO5Tbyp^w-#414B?4!IK|<5)2&&mgdscut4PBWPrHsux{il_T*rp5@dsW< zDS~b-^Jaq+cyz-EER{9=ZmYJeS2F4y1x}Hv=<%il?Ce6!JgFh;YZW-EfIe+eSz`wg zLf~ED@ig#dhpB&i*L@ZP{7vfWPR%eRQGwTnxx$S|!U7U)2qa)b$#N@Umxv$^0^f3Y z4~zlFF_gSs_&^1iQE(1q@ORJcQK>=-Wm3@BnxP5CuZiu55;|mOPpKPAdyw=3F(P)? zO}kBoV6~raVY^A#kbAr6oXgE*`)TJ{G4naieI}L|V;(9hAs3>-gC)&wC*5H%PCjNM z-Df1Gpq_@d98gR0R4v(8P4?B2FI1B+)RP_cqg{BnAKN&Q*+^xDPb{yetY{-^zGf=|( zJ(zp@vH?rmv-^7#u#91339cXH;AE77la>Z>0{hi2ugBBp8(~A@JWZ}Ql(0?b6HTrM zm~fkFFu8D$)MZA!(2H@l^>j0G=*$+Q5mb>KLAQE2@h`SvG_jlS}*A zhJFd=mz0sv9s4KgSKCuNyO%<|-KYvA7^*0FE?`ga*#Sz%_Ve zBK62Bw`EYP^r+zJCnz#U7nF!`AMvZ3umzzfMV2 z?5hdpy(RSBTeDL(gI~ERu?Z4pYR3`Y#@1{ZHv=X6J6uCJCF@9@>~P(}VkB?Ef1|_I z`{-~e*uTpGVSS-kI%x>ji9%|ejH5C;@{nQX22MI;i25PeK-GxYSw`5*V@rJ*S~5DF zb9|YG^LiO}RFAzVG_Z~Ck`Y)|$ZaDse@iJXmgV_5Bg#6)Y_?^M9wB`gf_^9)G(8j{ z{Df_#zeGNT0@RZldZ@d8p6CC;alhhz8GjTA@V;+3AYhOZ?Vr7S|J_==yBhEQB>E`S c&nLd+fIOPv+xfw7IUtWRZ9JTM148@xFRhtRt^fc4 diff --git a/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index cfedec100f1bb0313b8f85241acff7583d3016c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14631 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 aApA)9VZ%QQgI@>U62+_Hra-@F@&5t_wAq;e diff --git a/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index fe243a642def69d8255b2ee756cfaa2ac4978281..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36125 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 diff --git a/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 293ba7e1c9b9fdb3fb03d7e74c0ed661161c4d9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33091 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 F{{z^JdaM8d diff --git a/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 475106651b6ae30f8a44af8fd0ea3ff9f26f8c91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28346 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 d!uCrJ;lo#K0b4I*5I$_!U^{Djz#$};`hQiMu-5

    _I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT#i_}q wMVTd)`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00ucNL;wH) diff --git a/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 5ee7e6ba008d15324d96db2b61ec8b58fcd6ddb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26650 zcmeHQYiu0HecyZU9Ups?sJE?$CDEiU@g-80qK6~Pa;3y(=@oUVU7FVud8E#!?v8s$ z$&~FxY15dF0~kq*MsACOL5xN+0@Q8OLTLIy9}2`D`r-Ii7dBd;?S~ZopjatX_)Giy z&+N|ZE|)wpv6?i|;_iR*_|NRj?Ck$JGxvCXT|&Y2KW9c?erb)O3{u3t!dBqUezrmBC@!&S)WVjsJ#&e?OC~|_QVkgzIYNf@?aioP#B~I3qYgk(NRPD*S zavcjtPSu~(${GttPbE(_lpD4wFU5Oz*$t4tdTfKzWkt(tb#;TH$2TaW$#Ua5Wrw0C z5U=S9a)k0amR5_jI+3=XrPU)%6KNY*S`ujuB5k7{*r~Lv`7u@1(-JV_<=lnrPX2a(TO zZq(vN^exepxT5@9&BnK%tb{k;K}=zNu-h6%0_($JhL}zZMj2hYUxRGxOnxRtJFhjX~nFf5g zU|h(Q*>F5!)KIb7bUKsI7w{mm>9j!u+JJAV3E+xyR|y233jBn@Kizkm{eC~he}53- z=|&w;2SxJ`3FFJUmS>g7=Jq(jBcg}9^ znyu+xta);}=E>QbW0mkRJ~k*MF8lG{=|oZnmAwRNpeyCzz`Am1y;1;u*r1fddJr(8 zhXA8`xExCE;YTgh%QnlW=s8_3sn-I9k zs<<@*x1lO-t&Q6jbG^HDt9W^kgUYgVdwf5Z63@lD`8{(C(NGTLayGovE~aP;#R zF2_!vJhqpZM5s8?L7?l?`b-HNy}W&Qb8%vSONkQq_n3*H-0*O2aD22pkxSyao3)Sv zxO|e%4P6fyO?c3kuRF?ibheZduTd=&+J1yp*wHQ$5wa2w*l7q@NnLgl0#=f!PS%Dh zS_|ixAd`5bbvQR#&Kij~nw>2>Vs)bh2-#P25Nd#c2z9I7h@sIG>LhT$45O{>40c`* zd>TA*q$O~94eJ55g7*LoYN<1mgT#EYX>ltv@%iEe6PGfE*^nN@*rbhY(IAGJF~EG& zrOa@aQq1~eY=FVJP8dePFxRHJXp+vpS{%*f8BgOai&>q6@WT2AR?fO$*5YRAbK^+N zN`NH_<5W8`DWVCQDGn&q4E@WHkAtGf*hna|G#$}^xs2={z+D%7f zpEJ_QBfOE_&d3u^v<_#)LgQ7t&<^s(^PG+j9^-X)>?;xBY--x>xVT`ll?|zEC@QQM)@sY*GebbHmW*fUFKj3uAyFlX{~KhwLY_ITSaT)(RoLBjt~Ww zGccpJeWYz&)S9NL&Vfa3@3giTftv&Khx+haLF`c9oOXs~_D^g5EU|x98(=8|izwvy z%&G(5__9ctk+B7;3r!>JIaaR>AL^4~?TjPb?+CkY!_xtmiq7;dGRf^5kb+h(xsC0! zxzWgz#EtZTOZM`W*re>`k(|Q))EagSi8%oKKU_FumCMV##` zh6a?e)2Rq)Vq?Q%EWMJiuk@(LQ&C91iL>3yNWN7@UynLQ*&jDzkFH1Zjp;F6O~pLV zPhG|H6Vio!2XIeFy-f1W>ZE1XFm36wlQDL*lne+>N)O|E#%=_SM+odBunPd1ec&kg zM}y5jjxq*F>?3Nl5mq|^(uItD1W1!JIsjVY#vuxmIB6Uva0I|fcRP)vl#F?<7@sF_ z44_29zM!3)Y(C^q0Ectg(V>#rxf-I$%aA%)`XPWH$y`6LZLg@?7qsn*Dt)Ha?G-!B zQvm1G?L-T=&TGvTwRu5nUR3Ebtu|NeFi!!TQ=28-eC%37MFp7F9$Qqc&#d|wBTf{Z zcZBB%QD8X($XQByNR4NtryZ~w>Dg9A5nM@2Cxx@uhl~_M{o=*y6>0=pWnE)WOM!jq zOA5gsGYAT*P&@oo4JfEJ>@}@*y{1Gag^JNDlm`3GB>3Al2Bs=J?75S#a*+z!FOyA3 zhBkQ0`KlPQ98`lK#C_Q#e|)JtLjhh@Ke);x>-X)4d@4)&!&M$x-nV^A%cEZrkh?17 zBva92RdFS0jJQ+?5JnK@rKAxp=a-{qdapefSeqYdgd#ztu?XXUaY9dkJk}Y{qNvdf zU`9%ri`gNgg%TJ�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 I09aH07b%AX@Bjb+ diff --git a/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index c8e62e46fa6dd272f7fbb646595ac2e07ba50527..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17437 zcmeHPTW}OtdhU_t(i)AXB}NigBY`kT7$k%)T*YO%6qq%P+hVXCExp621q0GtPPYug z#&I@VWs%JWJI*fhRwbb-n=Irdc2ypd?91jU`>>UoL6=dw?A)sMArDCrlPafp+5G=G zeNOj`S`6~q@ym9NPXFihf9|Kx_y7O-TQ5W#!V+A+IzN8(7Y&k>!U(_oHu3ld5bsDA zCDnIYGJW)3bEq~>(+RLBfby7shus?MyVwEuMYRJ~bG>A>m8sRpy5 zO=^)gNosJlq=x!ySRH1Rrpb_2C(;zv*CBO<{{vahcKNJ^iR3k1%OwrtdX|hrLeMai z=A;4C`WG(@ADuL>=ovGeN}B0x=B4!L=(v78Npxg98b5tTpDOE}kJT(aJ`O|@`u8p9 zn<|+#7iF`yRmwtdS4*Z}t${G0)g$ZW;h|4^{Nalt+Sejb;D$J zCDQu!S9CLFcaT;F11!}_lKglkgaexJ#d#y_w%|>Liq#smR`qxLdTP{w8r%bU@XFOB zYU1wlShAF>9r4?w6c2LBx#y21{CiJHQvUolJMo0?@^g~3K^l=cSDY>b9%FZyr1gnF zpHJOzp}()D->yRqsq#pKbH<~ba_*a?emk-c?n`QFbT~P+NlMi8+hf7oIJ&x`g{wHX zb9d)^$*0O{ovTOQ{I$FuHLTXJs2`gu+T+;|XC`<(qCe{^@_V*#X@2Ney&9?7FF&uZ z$G^k12692QL5(JYJ-)Kk&`1j(MOVqKJk7bo2#0vd{C>r!DhGVI+51N+>eXhoB~f27N0AlGQG~aO-`O0IAE~Xs8Gi}j#+kSO z-|=vq`26M?6ODWYUcQU( zle$J-(c4k=O$zh6)RwBe4T5)VRoVQ*w=}0x;h8B-WG6rF)VfPav`Lnm%RpSpH0MLVprGcDfxT2@pL}*_>`X2@^dz zx_{EpiDCJ#8rjSPkT%A$WFl!=wYjNXmY?WG4$0jZ^b^E#qz0cvL6Fgj+5UWIZfb9r zfr)$1TD4FclD!!8S^m+anJi-+V5|dHFh}&U^bO0`ee-o!QN6pmjPRRSC{pc1wERAv z285l|+hufv{6tLSdfL1azSY@@d7$+67ig9}T}D?|_!c%}kLAbG9{8Tr3=MVpZoY<` zyLpj7*hNBpSR{yKS}HrLhi|nHvFZmoFAz3wKk%L!9*%3fclBz=)DuUCPaF^50$Hsl zoq;M=(GCE2I>S5A6@_MG4b!T3)xpNIv9cs9d?Ytzf-t5fiPfZ~lH=nV(Q_n~oydVm zXhq<$c4;y_Zl*Ja6&%k3hheSJj4Rp6@lndET^`R~N{(wd?N*&;B*%0t!HOQG(*`2u z2|}{OifL@RHT_0zJei@wCqb#m3OmEwwaW4v`uLdDjH}9)QuR?SHJ*kDK`688u}y3u zd)<)%cchO+Se1L+k$nUNjIvU8xiju|^Y*(VgKjqa32R2#lf7=XeQX3-GkSXs?7-Ho z?G=~xO3#(-gx)ieCCN9E*Lvb4dsR=FMo&63Mv}%<#=NrgQqs_SE+tdf^vq}v&X_?% z&CpYmByCQy7`%oWzJ}eoDQgp**A%>XrVcY@PLhnFjZMM~1BaH)q;#uqi5%WtHB%jQ z!B_|4^*hp{(os@6i%REWWYc0p?7dfKZrtB-e`6uCcQLm1QAld+dMx=H!_)ppVX3jX zq_h>4wt~F>$AhJg{l$*`Ko)>d_LpS)HwP(7n}`Zx+7#qNc|VUWcyU7_l+OzUZXQGK z4yP)?0t9d^DzQ6Lx2L}M=CprNX)eecX8gBrmE;ZVH!E+Lo@C^_LyE@>$Sz_J%VHuK z2zauntS-oHGyO0UCE5PX%5Bp(SZv-Q#p4C!7qN$BF_8=eJb_-kcjEhR7UTmZ<@uuW zJP`Ny{&@y&9~m^5-wLCCpa4&{?w?f-09{fJlpvH36y*ccZ!&U@vIzQKMvxyFf-LZi zEJcJ+=3`hQQkh0q4+&JGdlkYjsL|Ql9hS{6E-Ufe?}&cB(u zDP;6`vr`_YK)-BG)PaoOa^Z$c#z$>OXd>}7oZGoO{dVb>PE8*26-Z7&j{dxjw%qRI z+u+o|DR(}Q8zXANNHu<;EvZpeSy4Zje(99&*$-zXmh^||mrj1q{w&Q8{ZiD%s{IoB zOND+JR-4pSiExGPQa{qh$Iw-BJ5O`&Fv9h`C;SfbSq$W{5a^dPY78W@R)TArpqfZ< z1ruDP1g$9)Xo-RpE))cISRjG`(;qEs$;Rf9C`%!$`-Hadb`>WZGa)gmldE9 z62oF}ol>EKpoUCvAri1eNN34r2p{-v4u84A&o1rU8IXIL$n#hel^bFpGMXA7K?fCAPdiS%KBTN4?;U=V{n5EwWeZuBQdA;H?{g4|Jx&2HJp zuy#Eaq&;%rgcu>%&Q^xd&1>%Ndw<^t2WBScW6w{^pUS=e*0k<}_0z#cIa-p}73FpJ zI}7rPy`Nog-C?FD)8$0ti$28&w;fh+)_02JH5IY?33 zMN|+7tTxL-d635zytp9|%6D<|7;je!xGIct2{1_Rfim#|An|#DjVeq~x!Odnmq9V-f4V^?Kb#0qAQ|JEv;YzT zJ8`kWvDRP+K+=>50+6h_aKZ&7U^*QUuoD-M;?9Sw87~)*I5l}fPeK?Fc^hpYaq@X? z+)h5&aD!@Sq#C~%O={L$Nk15nIOTix!|AaU}0_Gf8+=vSQ@uG+5vudf0~ zLTKhpgerhU9s#o)yIdu2!6Nt?054(E77@`9(nGgI7qH%UC8I`dsN#lwYW~$n#c4>;? zIAShEn<|J4xSX8??RYKlmzhAoia^nroQ5X=$--D*n;XEk0Dy|yn-$7lqAI5qWUU~0 zenP&9fq+i(5=Ng9GATs6antgn^@sImIfNv8u?E~^+=};r4V;eN%z!gWfpa_!LICQ# zR{JtQDL6WyU}KO1<0o?TPV_e5+H*zuxy7b+i?QcEY1w>l?`+GC#ntN{)k$?5|3?bc zg&#$v#w{gfTT$6o=p33=4xxS+lqO0Og%V=Uwz1 zatkgu0Z*3VC?X)>s{HuhzkVF`P}#(;6>#rb7bSNr=U0RI^9lbxSQdGKr}ohc3T*W9 zKE4Bi9?>**Vc4~Yg`6GVO9_PiSM2N*vBd7^t@`mkxRBRrl_ypw1R@HgVn>8dKCw6R z{!!Dv$cG43v1f+x7|;WCaL=KatJmH;w-@R}V82YL4Zj)xuXFqp{t8qNsJ{FU5_RPg z=H)4h>dqPxrVhRWsC?N>--wnAuYJKcLUFAl?^P-eE-O}OSZxI9cBz8>>?_nu!G;x@ z3&1K(s}ZVTzXH7_!x)^$Ab|lY-Bt*c(aCYsp!zk1VuJ616=dlItqcTZw6o*_k_i4x ziGCd^)Y-axPlB1O38s87?6-2mK1<=oeTL`1h1^k!+ys>u@+}M;$i}&G0X0=-D->LB z-S#48#2tx@Vj$p{?8oQ<45&`4A%%+VGSIxif#!_C%xm*a+wS}3o3?{$i?-g)GbfA6=8|mxX64POg<@>pA;seb`I#<-IHOXi?vJ z53ITn6qTJN+5XMSJE{DE(Rmj=hm3;DO~8|-2#>ZNityh7XNj*$QwBYF8BN(+KaNOh zV7JgObN+MBk&9Ug1aYI!`gnCx<536Ri)`Gm|@WLUAD_P~t>B(7bo~zukU5UOk$=k{2y-LMi27QjH%~ks$j-tTjq*~cR zs!?03@(TATb#+x<;mXn@m${rMD|m;5n+{(y&O3z18r1Ei9`2|o{IePb32Yr%Rs!>} zQ;KJ?n4tSa?%K_t+U_7IVo`BRxPSDz{6D|}#P$;b%J$0G$J2mt95omlSXJz#Wn4dO zQ%m?(_ZT=<=mdO9a@U5i0pLr*$^gPH0~}4dbJyVH1K9N&VCyh|9U@HlLxGY>ob5oN z56V93gVIaGJ}NDOFgfz&p2hTQXU}QJ&Yc}TadsG57V{EBt>CCWu0!vxViSc8$%@&> zFYYp2qH2YdT7C=z9z%4%tRNeSu143f3>96`gj>bLG=waNaI~m%i|t6Vt1Py=EY@G< z#4%u?Ub5AFjsxx-%p#>wv6pomZBo{zWh7mP1!ZdEADM6)ZN8Jaohd~-iqQ@)o0PKU zu@q3k)3vr$YI{-HUf6l$XR&{1{rlFR$v^iMUU+d3;;{uUZb(F(|f zvC_f6I{Dxv+zPSI`zPmOee}`(VhjXZNZUUfI|#&_f?-KHSc1^?o0Sj#5wyZid+5X# zxg3?G1yk0sKKeJ&cLXOUjk43-XIQ9j5dY1GL0CC;4w z4pNtQf#5sg*Wkn+rcUhjWlrqiV(-$(J#~H$3zg$8@2$g&^Rfz@bn*#QQ+;d8GJsIVbrOm)K864f@YmjX z`}W&~jeWC9f7!-<7&rFA#g4-T`EUUU<#0*1e{+z68@rRjXxz%}w1@Ix9$WC@hD6-T z9c~^&?vBcW86#9nx4j4O6;i~KHR=+oX;xU%CbFZG<2uk0P0LRv$I)37Vv44XrHNsV zr!#sct7+sL&}kCK;M*9CVeke9Z$eBn&W=(xkx*0gjKRQ)WsB z|3>Wrd<5l6glqt?(h)i>B!uN*fHP*G!12HD!bpEs6Am2wY)vGv=d%rUfv(S5>jT}N zt*Z+RecoCVIO2OGVMzN&Gp8V*xUh7*9`Asl>>H^wUf)QWLHHIbr264)3RrEpBA)VD zR+-}hni{D&X3evKNbLx4vkShKir}W%BhC94Vo!rVq5lhcW~M#HfE-fIr*BMwsHy-ZUG#y zA>-Fl)%bCGWeGE<;`HTAmhOYRYH&)Y6iBC8g|)k8SM^SZ zK3Tnf=H$I^+~4>&&4tcGbE^-}u0A>)`u)f!a>vIT=j0EyXF2Qp#K=WGuu{r*>M%;JQLb4so=X6nxDu@U9R5;gRMPguV%|B702g=H)5 zX{%CW)b`zc;gZYtjh5Jry!LoIr<}V3HTxjZ{?yf}X|u!0Dfc;f#pdng+rqh>yE`9j z-smJ7M)t5@^C~1)uW=))xL}mL?AJ{IA(z!A zfRGk~Pylwah^mjlo_l3D3tyfJcyXgpL32w&>duk1nfX{K3(4LY%#eAV-=gyruec~1JLu8}v_9Jx{ z0}4J}NIlh}))i(Jv>8zZHi=d2flKVj7k4Db{^iD}J{m6#o+%EV`T6nL!Iw*e7mI@zXLoC}+rK>>x})4yW}-!T+ZTef zjv2Fv{!;dDR_;KcauOJgsAi`<1P(j4;K2-OGoXw)%&wS724YU`VCxlMm9wCfz72Qs z+Zeoq!S7)3BMg3m!FMqDT@1bpfm`W$7ZWi!gAWi7Y^BE^==wZT7ufPhf`HY9Y+a3( z%)k4iOUPQQu`dO?ncM~6^>q{dI(&KtU;T+B6ryMvU4Yj5(W}YKOOWmjf zccZf-dPgGYS6BfkHZ=*_?Ee7NP{Sht diff --git a/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 97e567f8e62bef1a5f9c5df8c6b2bbf584615f9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50570 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

    7y`na3`>L2>&E@&`rik{hENQE|f?K&EMJEp&tD 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 TKmoY9__;t}^9_X{Yta7(Oq&Y} diff --git a/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 699211ebf2a529fcc7a73342e4175d70910d1a41..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80328 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≶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 XktTTOBs!!F!8@B90dI#0va$X@Una^J diff --git a/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 720abb7878dcc3960dcc400a12abfef48a3800c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54552 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 diff --git a/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 872784e1a326ccdffd670031e9adbb08bd52d40c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17202 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 D_uA>a diff --git a/tests/test_security/test_altcha.py b/tests/test_security/test_altcha.py index 5e2aab5..00e2c33 100644 --- a/tests/test_security/test_altcha.py +++ b/tests/test_security/test_altcha.py @@ -40,22 +40,27 @@ class TestChallengeCreation: class TestVerification: def test_verify_correct_solution(self, altcha_service): challenge = altcha_service.create_challenge() - number = 5 - solution = compute_solution(challenge["salt"], number) + solution, number = altcha_service.compute_solution(challenge["salt"], challenge["difficulty"]) assert altcha_service.verify(challenge["challenge_id"], solution, number) is True def test_verify_marks_used(self, altcha_service): challenge = altcha_service.create_challenge() - solution = compute_solution(challenge["salt"], 5) - assert altcha_service.verify(challenge["challenge_id"], solution, 5) is True + solution, number = altcha_service.compute_solution(challenge["salt"], challenge["difficulty"]) + assert altcha_service.verify(challenge["challenge_id"], solution, number) is True saved = ALTCHAChallenge.objects.get(challenge_id=challenge["challenge_id"]) assert saved.is_verified is True def test_replay_rejected(self, altcha_service): challenge = altcha_service.create_challenge() - solution = compute_solution(challenge["salt"], 5) - assert altcha_service.verify(challenge["challenge_id"], solution, 5) is True - assert altcha_service.verify(challenge["challenge_id"], solution, 5) is False + solution, number = altcha_service.compute_solution(challenge["salt"], challenge["difficulty"]) + assert altcha_service.verify(challenge["challenge_id"], solution, number) is True + assert altcha_service.verify(challenge["challenge_id"], solution, number) is False + + def test_solution_without_proof_of_work_rejected(self, altcha_service): + challenge = altcha_service.create_challenge() + # Correct hash of salt+0 but almost certainly not divisible by difficulty. + solution = compute_solution(challenge["salt"], 0) + assert altcha_service.verify(challenge["challenge_id"], solution, 0) is False def test_wrong_solution_rejected(self, altcha_service): challenge = altcha_service.create_challenge() @@ -67,11 +72,11 @@ class TestVerification: def test_expired_challenge_rejected(self, altcha_service): challenge = altcha_service.create_challenge() + solution, number = altcha_service.compute_solution(challenge["salt"], challenge["difficulty"]) ALTCHAChallenge.objects.filter(challenge_id=challenge["challenge_id"]).update( expires_at=timezone.now() - timedelta(minutes=10) ) - solution = compute_solution(challenge["salt"], 5) - assert altcha_service.verify(challenge["challenge_id"], solution, 5) is False + assert altcha_service.verify(challenge["challenge_id"], solution, number) is False class TestComputeSolution: @@ -83,7 +88,7 @@ class TestComputeSolution: class TestALTCHAViews: def test_challenge_endpoint(self, api_client): - resp = api_client.post("/api/auth/altcha/challenge/") + resp = api_client.post("/api/v1/auth/altcha/challenge/") assert resp.status_code == status.HTTP_200_OK data = resp.json() assert "challenge_id" in data @@ -93,13 +98,13 @@ class TestALTCHAViews: def test_verify_endpoint_correct(self, api_client): svc = ALTCHAService() challenge = svc.create_challenge() - solution = compute_solution(challenge["salt"], 3) + solution, number = svc.compute_solution(challenge["salt"], challenge["difficulty"]) resp = api_client.post( - "/api/auth/altcha/verify/", + "/api/v1/auth/altcha/verify/", { "challenge_id": challenge["challenge_id"], "solution": solution, - "number": 3, + "number": number, }, format="json", ) @@ -110,7 +115,7 @@ class TestALTCHAViews: svc = ALTCHAService() challenge = svc.create_challenge() resp = api_client.post( - "/api/auth/altcha/verify/", + "/api/v1/auth/altcha/verify/", { "challenge_id": challenge["challenge_id"], "solution": "deadbeef", diff --git a/tests/test_security/test_api_keys.py b/tests/test_security/test_api_keys.py index e18c85a..f0727b3 100644 --- a/tests/test_security/test_api_keys.py +++ b/tests/test_security/test_api_keys.py @@ -8,7 +8,7 @@ from infrasynth.security.models import APIKey class TestAPIKeyViewSet: def test_create_returns_full_key_once(self, authenticated_client): resp = authenticated_client.post( - "/api/auth/api-keys/", + "/api/v1/auth/api-keys/", {"name": "CI Key", "scopes": ["read:users"]}, format="json", ) @@ -20,7 +20,7 @@ class TestAPIKeyViewSet: def test_created_key_authenticates(self, authenticated_client): resp = authenticated_client.post( - "/api/auth/api-keys/", + "/api/v1/auth/api-keys/", {"name": "CI Key", "scopes": ["read:users"]}, format="json", ) @@ -29,7 +29,7 @@ class TestAPIKeyViewSet: client = APIClient() client.credentials(HTTP_X_API_KEY=full_key) - check = client.get("/api/auth/check/") + check = client.get("/api/v1/auth/check/") assert check.status_code == status.HTTP_200_OK def test_list_keys(self, authenticated_client, user): @@ -39,7 +39,7 @@ class TestAPIKeyViewSet: key_hash=make_password("secret"), created_by=user, ) - resp = authenticated_client.get("/api/auth/api-keys/") + resp = authenticated_client.get("/api/v1/auth/api-keys/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 @@ -50,7 +50,7 @@ class TestAPIKeyViewSet: key_hash=make_password("secret"), created_by=user, ) - resp = authenticated_client.get("/api/auth/api-keys/") + resp = authenticated_client.get("/api/v1/auth/api-keys/") result = resp.json()["results"][0] assert "key" not in result assert "key_hash" not in result @@ -63,18 +63,35 @@ class TestAPIKeyViewSet: key_hash=make_password("secret"), created_by=user, ) - resp = authenticated_client.delete(f"/api/auth/api-keys/{key.pk}/") + resp = authenticated_client.delete(f"/api/v1/auth/api-keys/{key.pk}/") assert resp.status_code == status.HTTP_204_NO_CONTENT assert not APIKey.objects.filter(pk=key.pk).exists() + def test_rotate_issues_new_secret_and_deactivates_old(self, authenticated_client, user): + created = APIKey.objects.create( + name="Rotate Me", + prefix="rot0aaaa", + key_hash=make_password("old-secret"), + scopes=["read:users"], + created_by=user, + ) + resp = authenticated_client.post(f"/api/v1/auth/api-keys/{created.pk}/rotate/", format="json") + assert resp.status_code == status.HTTP_201_CREATED + created.refresh_from_db() + assert created.is_active is False + new = APIKey.objects.get(prefix=resp.json()["prefix"]) + assert new.rotated_from_id == created.pk + assert new.is_active is True + assert resp.json()["key"].startswith(new.prefix + ".") + def test_requires_auth(self, api_client): - resp = api_client.get("/api/auth/api-keys/") + resp = api_client.get("/api/v1/auth/api-keys/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestJWTAuthenticationMiddleware: def test_ignores_anonymous_requests(self, rf, db): - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = type("Anon", (), {"is_anonymous": True})() JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request) assert request.user.is_anonymous @@ -84,7 +101,7 @@ class TestJWTAuthenticationMiddleware: from infrasynth.shared.crypto import encrypt - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.COOKIES["access_token"] = encrypt(str(RefreshToken.for_user(user).access_token)) request.user = type("Anon", (), {"is_anonymous": True})() JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request) diff --git a/tests/test_security/test_auth.py b/tests/test_security/test_auth.py index 053a547..f579e0f 100644 --- a/tests/test_security/test_auth.py +++ b/tests/test_security/test_auth.py @@ -170,7 +170,7 @@ class TestAuthFlow: def test_login_sets_cookies(self, db, user): client = APIClient() resp = client.post( - "/api/auth/login/", + "/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json", ) @@ -181,7 +181,7 @@ class TestAuthFlow: def test_login_by_email(self, db, user): client = APIClient() resp = client.post( - "/api/auth/login/", + "/api/v1/auth/login/", {"username": "test@example.com", "password": "testpass123"}, format="json", ) @@ -190,7 +190,7 @@ class TestAuthFlow: def test_login_invalid_credentials(self, db, user): client = APIClient() resp = client.post( - "/api/auth/login/", + "/api/v1/auth/login/", {"username": "testuser", "password": "wrongpass"}, format="json", ) @@ -201,11 +201,11 @@ class TestAuthFlow: username="banned", email="banned@example.com", password="testpass123", is_active=False ) client = APIClient() - resp = client.post("/api/auth/login/", {"username": "banned", "password": "testpass123"}, format="json") + resp = client.post("/api/v1/auth/login/", {"username": "banned", "password": "testpass123"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_check_authenticated(self, authenticated_client, user): - resp = authenticated_client.get("/api/auth/check/") + resp = authenticated_client.get("/api/v1/auth/check/") assert resp.status_code == status.HTTP_200_OK data = resp.json() assert data["id"] == user.pk @@ -213,12 +213,12 @@ class TestAuthFlow: assert "effective_permissions" in data def test_check_superuser_has_wildcard(self, admin_client, admin_user): - resp = admin_client.get("/api/auth/check/") + resp = admin_client.get("/api/v1/auth/check/") data = resp.json() assert data["effective_permissions"] == ["*"] def test_check_unauthenticated(self, api_client): - resp = api_client.get("/api/auth/check/") + resp = api_client.get("/api/v1/auth/check/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED assert resp.json() == {"authenticated": False} @@ -228,21 +228,21 @@ class TestAuthFlow: refresh = str(RefreshToken.for_user(user)) client = APIClient() client.cookies["refresh_token"] = encrypt(refresh) - resp = client.post("/api/auth/refresh/") + resp = client.post("/api/v1/auth/refresh/") assert resp.status_code == status.HTTP_200_OK assert "access_token" in resp.cookies def test_refresh_without_cookie(self, api_client): - resp = api_client.post("/api/auth/refresh/") + resp = api_client.post("/api/v1/auth/refresh/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_refresh_invalid_cookie(self, api_client): api_client.cookies["refresh_token"] = "garbage-token" - resp = api_client.post("/api/auth/refresh/") + resp = api_client.post("/api/v1/auth/refresh/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_logout_clears_cookies(self, authenticated_client): - resp = authenticated_client.post("/api/auth/logout/") + resp = authenticated_client.post("/api/v1/auth/logout/") assert resp.status_code == status.HTTP_200_OK assert resp.cookies["access_token"].value == "" assert resp.cookies["refresh_token"].value == "" @@ -255,7 +255,7 @@ class TestAuthFlow: } client = APIClient() resp = client.post( - "/api/auth/login/", + "/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json", ) @@ -265,11 +265,11 @@ class TestAuthFlow: def test_full_login_roundtrip(self, db, user): client = APIClient() resp = client.post( - "/api/auth/login/", + "/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json", ) assert resp.status_code == status.HTTP_200_OK - check = client.get("/api/auth/check/") + check = client.get("/api/v1/auth/check/") assert check.status_code == status.HTTP_200_OK assert check.json()["id"] == user.pk diff --git a/tests/test_security/test_authorization.py b/tests/test_security/test_authorization.py index e913aa9..6625a96 100644 --- a/tests/test_security/test_authorization.py +++ b/tests/test_security/test_authorization.py @@ -170,12 +170,17 @@ class TestHybridPermission: view = _PermissionView(["one.of.these", "other.perm"]) assert perm.has_permission(request, view) is True - def test_no_permission_denied(self, user): - request = type("R", (), {"user": user})() + def test_no_permission_denied_for_member(self, member_user): + request = type("R", (), {"user": member_user})() perm = HybridPermission() view = _PermissionView(["missing.perm"]) assert perm.has_permission(request, view) is False + def test_owner_is_allowed(self, user): + request = type("R", (), {"user": user})() + perm = HybridPermission() + assert perm.has_permission(request, _PermissionView(["missing.perm"])) is True + def test_unauthenticated_denied(self, db): anon = type("R", (), {"user": type("U", (), {"is_authenticated": False})()})() perm = HybridPermission() @@ -190,22 +195,22 @@ class TestRequirePermission: perm_class = require_permission("perm.a", "perm.b") assert perm_class().has_permission(request, _PermissionView()) is True - def test_missing_any_denied(self, user): - Grant.objects.create(user=user, codename="perm.a") - request = type("R", (), {"user": user})() + def test_missing_any_denied(self, member_user): + Grant.objects.create(user=member_user, codename="perm.a") + request = type("R", (), {"user": member_user})() perm_class = require_permission("perm.a", "perm.b") assert perm_class().has_permission(request, _PermissionView()) is False class TestRoleViewSet: def test_list_roles(self, authenticated_client, role): - resp = authenticated_client.get("/api/auth/roles/") + resp = authenticated_client.get("/api/v1/auth/roles/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 def test_create_role(self, authenticated_client): resp = authenticated_client.post( - "/api/auth/roles/", + "/api/v1/auth/roles/", {"name": "Viewer", "slug": "viewer", "permissions": ["content.view"]}, format="json", ) @@ -213,13 +218,13 @@ class TestRoleViewSet: assert Role.objects.filter(slug="viewer").exists() def test_retrieve_role(self, authenticated_client, role): - resp = authenticated_client.get(f"/api/auth/roles/{role.slug}/") + resp = authenticated_client.get(f"/api/v1/auth/roles/{role.slug}/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["permissions"] == ["content.edit", "content.view"] def test_update_role(self, authenticated_client, role): resp = authenticated_client.put( - f"/api/auth/roles/{role.slug}/", + f"/api/v1/auth/roles/{role.slug}/", {"name": "Editor2", "slug": "editor", "permissions": ["content.edit"]}, format="json", ) @@ -228,13 +233,13 @@ class TestRoleViewSet: assert role.name == "Editor2" def test_delete_role(self, authenticated_client, role): - resp = authenticated_client.delete(f"/api/auth/roles/{role.slug}/") + resp = authenticated_client.delete(f"/api/v1/auth/roles/{role.slug}/") assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Role.objects.filter(pk=role.pk).exists() def test_system_role_cannot_be_deleted(self, authenticated_client, db): system_role = Role.objects.create(slug="sys", name="Sys", permissions=[], is_system=True) - resp = authenticated_client.delete(f"/api/auth/roles/{system_role.slug}/") + resp = authenticated_client.delete(f"/api/v1/auth/roles/{system_role.slug}/") assert resp.status_code in ( status.HTTP_400_BAD_REQUEST, status.HTTP_403_FORBIDDEN, @@ -242,14 +247,47 @@ class TestRoleViewSet: assert Role.objects.filter(pk=system_role.pk).exists() def test_requires_auth(self, api_client): - resp = api_client.get("/api/auth/roles/") + resp = api_client.get("/api/v1/auth/roles/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED +class TestPermissionEnforcement: + def test_owner_allowed(self, authenticated_client): + assert authenticated_client.get("/api/v1/auth/roles/").status_code == status.HTTP_200_OK + + def test_member_denied_without_permission(self, member_client): + assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_403_FORBIDDEN + + def test_member_allowed_with_grant(self, member_client, member_user): + Grant.objects.create(user=member_user, codename="security.manage_roles") + assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_200_OK + + def test_member_denied_for_unrelated_permission(self, member_client, member_user): + Grant.objects.create(user=member_user, codename="content.view") + assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_403_FORBIDDEN + + def test_user_permissions_endpoint(self, authenticated_client, user): + Grant.objects.create(user=user, codename="billing.view") + resp = authenticated_client.get(f"/api/v1/auth/users/{user.pk}/permissions/") + assert resp.status_code == status.HTTP_200_OK + assert "billing.view" in resp.json()["permissions"] + + def test_user_roles_set_endpoint(self, authenticated_client, user): + role = Role.objects.create(name="Viewer", slug="viewer", permissions=["content.view"]) + resp = authenticated_client.put( + f"/api/v1/auth/users/{user.pk}/roles/", + {"roles": [role.slug]}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert role.slug in resp.json()["roles"] + assert role.users.filter(pk=user.pk).exists() + + class TestGrantViewSet: def test_create_grant(self, authenticated_client, user): resp = authenticated_client.post( - "/api/auth/grants/", + "/api/v1/auth/grants/", {"user": user.pk, "codename": "billing.view", "reason": "temporary"}, format="json", ) @@ -259,25 +297,25 @@ class TestGrantViewSet: def test_list_grants(self, authenticated_client, user): Grant.objects.create(user=user, codename="a.perm") - resp = authenticated_client.get("/api/auth/grants/") + resp = authenticated_client.get("/api/v1/auth/grants/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 def test_delete_grant(self, authenticated_client, user): grant = Grant.objects.create(user=user, codename="a.perm") - resp = authenticated_client.delete(f"/api/auth/grants/{grant.pk}/") + resp = authenticated_client.delete(f"/api/v1/auth/grants/{grant.pk}/") assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Grant.objects.filter(pk=grant.pk).exists() def test_grant_requires_auth(self, api_client): - resp = api_client.get("/api/auth/grants/") + resp = api_client.get("/api/v1/auth/grants/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestRevokeViewSet: def test_create_revoke(self, authenticated_client, user): resp = authenticated_client.post( - "/api/auth/revokes/", + "/api/v1/auth/revokes/", {"user": user.pk, "codename": "billing.view", "reason": "audit"}, format="json", ) @@ -286,12 +324,12 @@ class TestRevokeViewSet: def test_list_revokes(self, authenticated_client, user): Revoke.objects.create(user=user, codename="a.perm") - resp = authenticated_client.get("/api/auth/revokes/") + resp = authenticated_client.get("/api/v1/auth/revokes/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 def test_delete_revoke(self, authenticated_client, user): revoke = Revoke.objects.create(user=user, codename="a.perm") - resp = authenticated_client.delete(f"/api/auth/revokes/{revoke.pk}/") + resp = authenticated_client.delete(f"/api/v1/auth/revokes/{revoke.pk}/") assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Revoke.objects.filter(pk=revoke.pk).exists() diff --git a/tests/test_security/test_two_factor.py b/tests/test_security/test_two_factor.py index a14088b..5321ed4 100644 --- a/tests/test_security/test_two_factor.py +++ b/tests/test_security/test_two_factor.py @@ -2,6 +2,7 @@ import json import pyotp import pytest +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -106,9 +107,58 @@ def two_factor_client(user): return client +class TestTwoFactorLoginFlow: + @staticmethod + def _enable(user): + secret = TOTPService().generate_secret() + rcs = RecoveryCodeService() + TwoFactorConfig.objects.create( + user=user, + is_enabled=True, + is_configured=True, + secret_key_encrypted=TOTPService().encrypt_secret(secret), + recovery_codes_encrypted=rcs.encrypt_codes(rcs.generate_codes()), + ) + return secret + + def test_login_challenges_for_second_factor(self, db, user): + self._enable(user) + client = APIClient() + resp = client.post( + "/api/v1/auth/login/", + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["requires2fa"] is True + assert "access_token" not in resp.cookies + + def test_verify_after_login_issues_tokens(self, db, user): + secret = self._enable(user) + client = APIClient() + client.post("/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json") + resp = client.post( + "/api/v1/auth/2fa/verify/", + {"code": pyotp.TOTP(secret).now()}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies + + def test_no_second_factor_logs_in_directly(self, db, user): + client = APIClient() + resp = client.post( + "/api/v1/auth/login/", + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies + + class TestTwoFactorSetup: def test_setup_returns_secret_and_qr(self, two_factor_client): - resp = two_factor_client.post("/api/auth/2fa/setup/") + resp = two_factor_client.post("/api/v1/auth/2fa/setup/") assert resp.status_code == status.HTTP_200_OK data = resp.json() assert "secret" in data @@ -116,16 +166,16 @@ class TestTwoFactorSetup: assert "provisioning_uri" in data def test_setup_requires_auth(self, api_client): - resp = api_client.post("/api/auth/2fa/setup/") + resp = api_client.post("/api/v1/auth/2fa/setup/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestTwoFactorVerifySetup: def test_verify_setup_complete_flow(self, two_factor_client, user): - setup = two_factor_client.post("/api/auth/2fa/setup/") + setup = two_factor_client.post("/api/v1/auth/2fa/setup/") secret = setup.json()["secret"] code = pyotp.TOTP(secret).now() - resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": code}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": code}, format="json") assert resp.status_code == status.HTTP_200_OK assert len(resp.json()["recovery_codes"]) == 8 @@ -135,18 +185,18 @@ class TestTwoFactorVerifySetup: assert config.secret_key_encrypted def test_verify_setup_wrong_code(self, two_factor_client): - two_factor_client.post("/api/auth/2fa/setup/") - resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": "000000"}, format="json") + two_factor_client.post("/api/v1/auth/2fa/setup/") + resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": "000000"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_verify_setup_without_session(self, two_factor_client): - resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": "123456"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": "123456"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestTwoFactorDisable: def test_disable_without_config(self, two_factor_client): - resp = two_factor_client.post("/api/auth/2fa/disable/") + resp = two_factor_client.post("/api/v1/auth/2fa/disable/") assert resp.status_code == status.HTTP_200_OK def test_disable_with_config(self, two_factor_client, user): @@ -157,7 +207,7 @@ class TestTwoFactorDisable: secret_key_encrypted="enc", recovery_codes_encrypted="enc", ) - resp = two_factor_client.post("/api/auth/2fa/disable/") + resp = two_factor_client.post("/api/v1/auth/2fa/disable/") assert resp.status_code == status.HTTP_200_OK user.two_factor_config.refresh_from_db() assert user.two_factor_config.is_enabled is False @@ -179,11 +229,11 @@ class TestTwoFactorVerifyAndRecovery: return user, secret def test_verify_requires_pre_auth_session(self, two_factor_client, configured_user): - resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "123456"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "123456"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_recovery_requires_pre_auth_session(self, two_factor_client, configured_user): - resp = two_factor_client.post("/api/auth/2fa/recovery/", {"recovery_code": "RC-X"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/recovery/", {"recovery_code": "RC-X"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_verify_with_correct_code(self, two_factor_client, configured_user): @@ -191,11 +241,13 @@ class TestTwoFactorVerifyAndRecovery: session = two_factor_client.session session["_2fa_user_id"] = user.pk session["_2fa_pre_auth_token"] = "pre-auth-token" + session["_2fa_started_at"] = timezone.now().isoformat() session.save() code = pyotp.TOTP(secret).now() - resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": code}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": code}, format="json") assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies def test_verify_with_wrong_code(self, two_factor_client, configured_user): user, secret = configured_user @@ -203,7 +255,7 @@ class TestTwoFactorVerifyAndRecovery: session["_2fa_user_id"] = user.pk session.save() - resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "000000"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "000000"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_verify_without_2fa_config(self, two_factor_client, user): @@ -211,7 +263,7 @@ class TestTwoFactorVerifyAndRecovery: session["_2fa_user_id"] = user.pk session.save() - resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "123456"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "123456"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_recovery_with_valid_code(self, two_factor_client, configured_user): @@ -220,14 +272,17 @@ class TestTwoFactorVerifyAndRecovery: recovery_codes = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted) session = two_factor_client.session session["_2fa_user_id"] = user.pk + session["_2fa_pre_auth_token"] = "pre-auth-token" + session["_2fa_started_at"] = timezone.now().isoformat() session.save() resp = two_factor_client.post( - "/api/auth/2fa/recovery/", + "/api/v1/auth/2fa/recovery/", {"recovery_code": recovery_codes[0]}, format="json", ) assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies config.refresh_from_db() remaining = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted) @@ -239,5 +294,5 @@ class TestTwoFactorVerifyAndRecovery: session["_2fa_user_id"] = user.pk session.save() - resp = two_factor_client.post("/api/auth/2fa/recovery/", {"recovery_code": "RC-INVALID"}, format="json") + resp = two_factor_client.post("/api/v1/auth/2fa/recovery/", {"recovery_code": "RC-INVALID"}, format="json") assert resp.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/test_security/test_two_factor_middleware.py b/tests/test_security/test_two_factor_middleware.py index 4e234b2..8991c3c 100644 --- a/tests/test_security/test_two_factor_middleware.py +++ b/tests/test_security/test_two_factor_middleware.py @@ -26,50 +26,69 @@ class TestPreAuthUtils: class _Request: - def __init__(self, user, path="/api/features/active/"): + def __init__(self, user, path="/api/v1/features/active/"): self.user = user self.path = path class TestTwoFactorMiddleware: def test_anonymous_passes(self, rf, db): - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = type("Anon", (), {"is_authenticated": False})() result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None def test_user_without_config_passes(self, rf, user): - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = user result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None def test_disabled_config_passes(self, rf, user): TwoFactorConfig.objects.create(user=user, is_enabled=False, is_configured=True) - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = user result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None def test_unconfigured_enabled_passes(self, rf, user): TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=False) - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = user result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None - def test_redirects_when_2fa_required(self, rf, user): + def test_api_path_returns_403_when_2fa_required(self, rf, user): TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is not None + assert result.status_code == 403 + import json as _json + + assert _json.loads(result.content)["code"] == "AUTH_2FA_REQUIRED" + + def test_non_api_path_redirects(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/some-page/") request.user = user result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is not None assert result.status_code == 302 - assert result.url == "/api/auth/2fa/verify/" + assert result.url == "/api/v1/auth/2fa/verify/" + + def test_jwt_authenticated_request_passes(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/api/v1/features/active/") + request.user = user + request.auth = object() # a minted token implies 2FA already happened + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None def test_exempt_paths_not_redirected(self, rf, user): TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) - request = rf.get("/api/auth/check/") + request = rf.get("/api/v1/auth/check/") request.user = user result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None @@ -81,10 +100,10 @@ class TestTwoFactorMiddleware: result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None - def test_verified_user_not_redirected(self, rf, user): + def test_verified_session_not_blocked(self, rf, user): TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) - request = rf.get("/api/features/active/") + request = rf.get("/api/v1/features/active/") request.user = user - request._2fa_verified = True + request.session = {"_2fa_verified": True} result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) assert result is None diff --git a/tests/test_security/test_workspace.py b/tests/test_security/test_workspace.py new file mode 100644 index 0000000..30e9ea5 --- /dev/null +++ b/tests/test_security/test_workspace.py @@ -0,0 +1,83 @@ +"""Multi-workspace login, selection, switching, and tenant-scoped API keys.""" + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.test import RequestFactory +from rest_framework_simplejwt.tokens import AccessToken + +from infrasynth.shared.crypto import decrypt +from infrasynth.tenancy.context import get_current_tenant +from infrasynth.tenancy.models import Tenant, TenantMembership + +pytestmark = pytest.mark.django_db +UserModel = get_user_model() + +LOGIN = "/api/v1/auth/login/" +SELECT = "/api/v1/auth/select-workspace/" +SWITCH = "/api/v1/auth/switch-workspace/" + +CREDS = {"username": "testuser", "password": "testpass123"} + + +class TestLoginWorkspaceFlow: + def test_single_membership_issues_tenant_token(self, api_client, user, tenant): + resp = api_client.post(LOGIN, CREDS, format="json") + assert resp.status_code == 200 + assert resp.json()["tenant"] == str(tenant.pk) + token = AccessToken(decrypt(api_client.cookies["access_token"].value)) + assert token["tenant"] == str(tenant.pk) + + def test_multiple_memberships_returns_picker_without_access_token(self, api_client, user, tenant): + other = Tenant.objects.create(slug="second", name="Second") + TenantMembership.objects.create(tenant=other, user=user, is_active=True) + resp = api_client.post(LOGIN, CREDS, format="json") + assert resp.status_code == 200 + assert len(resp.json()["workspaces"]) == 2 + assert "access_token" not in resp.cookies + + def test_select_workspace_issues_bound_tokens(self, api_client, user, tenant): + other = Tenant.objects.create(slug="second", name="Second") + TenantMembership.objects.create(tenant=other, user=user, is_active=True) + assert api_client.post(LOGIN, CREDS, format="json").status_code == 200 + + resp = api_client.post(SELECT, {"tenantId": str(other.pk)}, format="json") + assert resp.status_code == 200 + assert resp.json()["tenant"] == str(other.pk) + token = AccessToken(decrypt(api_client.cookies["access_token"].value)) + assert token["tenant"] == str(other.pk) + + def test_select_workspace_without_pending_session(self, api_client): + resp = api_client.post(SELECT, {"tenantId": "whatever"}, format="json") + assert resp.status_code == 401 + + def test_select_workspace_rejects_foreign_tenant(self, api_client, user, tenant): + other = Tenant.objects.create(slug="second", name="Second") + TenantMembership.objects.create(tenant=other, user=user, is_active=True) + api_client.post(LOGIN, CREDS, format="json") + stranger = Tenant.objects.create(slug="stranger", name="Stranger") + resp = api_client.post(SELECT, {"tenantId": str(stranger.pk)}, format="json") + assert resp.status_code == 404 + + def test_switch_workspace_requires_auth(self, api_client, tenant): + resp = api_client.post(SWITCH, {"tenantId": str(tenant.pk)}, format="json") + assert resp.status_code == 401 + + +class TestAPIKeyTenantScope: + def test_api_key_authentication_binds_tenant(self, tenant): + from infrasynth.security.auth.api_keys import APIKeyAuthentication + from infrasynth.security.models import APIKey + + APIKey.all_objects.create( + tenant=tenant, + name="svc", + prefix="abcd1234", + key_hash=make_password("secret"), + scopes=["read:users"], + ) + request = RequestFactory().get("/", HTTP_X_API_KEY="abcd1234.secret") + user, auth = APIKeyAuthentication().authenticate(request) + assert user.tenant_id == tenant.id + assert user.scopes == ["read:users"] + assert get_current_tenant() == tenant diff --git a/tests/test_shared/__pycache__/__init__.cpython-314.pyc b/tests/test_shared/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bdc3a8d4f17a9c1ff39f5d3a3173c19812250ee9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 175 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 +i>y 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 X@?M+g%u@xNm0c~Kp=WTF&5QbflD@mS diff --git a/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index f9382ab47be4764ce4019d3922fd2bf53421e86f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20532 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 diff --git a/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index ddccea85d79d2fe98415962b3c423986227199a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19724 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 diff --git a/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 5b0134d9bc39fa16e559fbba26c3e6d0d8673083..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7268 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 aVg5$QFUVg>^zY<@*WNCs{}lnE>;4C4GzwAx diff --git a/tests/test_tenancy/test_isolation.py b/tests/test_tenancy/test_isolation.py new file mode 100644 index 0000000..8413a1e --- /dev/null +++ b/tests/test_tenancy/test_isolation.py @@ -0,0 +1,81 @@ +"""Tenant isolation suite (AGENTS.backend-packages.md §11). + +Proves tenant A cannot read, write, update, or delete tenant B's rows and that +cross-tenant access returns 404 through the API. +""" + +import pytest + +from infrasynth.notifications.models import NotificationDispatch +from infrasynth.scheduler.models import ScheduledTask +from infrasynth.security.models import Grant +from infrasynth.tenancy.context import tenant_context +from infrasynth.tenancy.models import Tenant +from infrasynth.webhooks.models import OutboundEndpoint +from infrasynth.workflows.models import Workflow + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def other_tenant(): + return Tenant.objects.create(slug="other-ws", name="Other Workspace") + + +def _seed(tenant): + ScheduledTask.all_objects.create( + tenant=tenant, name="task", task_path="tests.helpers.noop_task", schedule_type="manual" + ) + OutboundEndpoint.all_objects.create(tenant=tenant, name="ep", url="https://x.test/h", secret="s") + Workflow.all_objects.create(tenant=tenant, slug="wf", name="WF") + NotificationDispatch.all_objects.create(tenant=tenant, recipient="a@b.c", channel="email", subject="s", body="b") + + +class TestManagerIsolation: + def test_tenant_a_cannot_read_tenant_b(self, tenant, other_tenant): + _seed(tenant) + with tenant_context(other_tenant): + assert ScheduledTask.objects.count() == 0 + assert OutboundEndpoint.objects.count() == 0 + assert Workflow.objects.count() == 0 + assert NotificationDispatch.objects.count() == 0 + + def test_tenant_b_cannot_read_tenant_a(self, tenant, other_tenant): + _seed(other_tenant) + with tenant_context(tenant): + assert ScheduledTask.objects.count() == 0 + assert OutboundEndpoint.objects.count() == 0 + + def test_cross_tenant_update_is_invisible(self, tenant, other_tenant): + _seed(other_tenant) + with tenant_context(tenant), pytest.raises(ScheduledTask.DoesNotExist): + ScheduledTask.objects.get(name="task") + + def test_cross_tenant_delete_is_invisible(self, tenant, other_tenant): + _seed(other_tenant) + with tenant_context(tenant): + assert ScheduledTask.objects.filter(name="task").delete()[0] == 0 + with tenant_context(other_tenant): + assert ScheduledTask.objects.filter(name="task").exists() + + def test_grant_scoped(self, tenant, other_tenant, user): + Grant.all_objects.create(tenant=tenant, user=user, codename="a") + Grant.all_objects.create(tenant=other_tenant, user=user, codename="b") + with tenant_context(tenant): + assert list(Grant.objects.values_list("codename", flat=True)) == ["a"] + + +class TestCrossTenantApiReturns404: + def test_other_tenant_task_is_404(self, authenticated_client, tenant, other_tenant): + task = ScheduledTask.all_objects.create( + tenant=other_tenant, name="secret", task_path="tests.helpers.noop_task", schedule_type="manual" + ) + # authenticated_client's context tenant is `tenant`, so B's row is invisible. + assert authenticated_client.get(f"/api/v1/scheduler/tasks/{task.pk}/").status_code == 404 + assert authenticated_client.delete(f"/api/v1/scheduler/tasks/{task.pk}/").status_code == 404 + + def test_other_tenant_endpoint_is_404(self, authenticated_client, other_tenant): + endpoint = OutboundEndpoint.all_objects.create( + tenant=other_tenant, name="secret", url="https://x.test/h", secret="s" + ) + assert authenticated_client.get(f"/api/v1/webhooks/outbound/endpoints/{endpoint.pk}/").status_code == 404 diff --git a/tests/test_tenancy/test_managers.py b/tests/test_tenancy/test_managers.py new file mode 100644 index 0000000..97586a5 --- /dev/null +++ b/tests/test_tenancy/test_managers.py @@ -0,0 +1,80 @@ +"""Tenant-scoped manager behaviour (TENANCY.md §4).""" + +import pytest + +from infrasynth.features.models import FeatureFlag +from infrasynth.scheduler.models import ScheduledTask +from infrasynth.tenancy.context import tenant_context +from infrasynth.tenancy.models import Tenant + +pytestmark = pytest.mark.django_db + + +def _task(tenant, name): + return ScheduledTask.all_objects.create( + tenant=tenant, + name=name, + task_path="tests.helpers.noop_task", + schedule_type="manual", + ) + + +class TestTenantManager: + def test_fail_closed_without_context(self, tenant): + _task(tenant, "a") + with tenant_context(None): + assert ScheduledTask.objects.count() == 0 + assert list(ScheduledTask.objects.all()) == [] + + def test_scopes_to_current_tenant(self, tenant): + other = Tenant.objects.create(slug="other", name="Other") + _task(tenant, "a") + _task(other, "b") + with tenant_context(tenant): + assert list(ScheduledTask.objects.values_list("name", flat=True)) == ["a"] + with tenant_context(other): + assert list(ScheduledTask.objects.values_list("name", flat=True)) == ["b"] + + def test_cross_tenant_get_raises_does_not_exist(self, tenant): + other = Tenant.objects.create(slug="other", name="Other") + _task(tenant, "a") + with tenant_context(other), pytest.raises(ScheduledTask.DoesNotExist): + ScheduledTask.objects.get(name="a") + + def test_unsafe_all_sees_every_tenant(self, tenant): + other = Tenant.objects.create(slug="other", name="Other") + _task(tenant, "a") + _task(other, "b") + with tenant_context(None): + assert ScheduledTask.objects.unsafe_all().count() == 2 + + def test_save_auto_assigns_current_tenant(self, tenant): + with tenant_context(tenant): + task = ScheduledTask.objects.create( + name="auto", + task_path="tests.helpers.noop_task", + schedule_type="manual", + ) + assert task.tenant_id == tenant.id + + +class TestGlobalOrTenantManager: + def test_exposes_global_plus_override_only(self, tenant): + other = Tenant.objects.create(slug="other", name="Other") + FeatureFlag.all_objects.create(slug="f", is_active=True, tenant=None) + FeatureFlag.all_objects.create(slug="f", is_active=False, tenant=tenant) + FeatureFlag.all_objects.create(slug="f", is_active=True, tenant=other) + + with tenant_context(tenant): + assert FeatureFlag.objects.filter(slug="f").count() == 2 # global + own override + resolved = FeatureFlag.objects.resolve(slug="f") + assert resolved is not None and resolved.is_active is False + + with tenant_context(other): + assert FeatureFlag.objects.filter(slug="f").count() == 2 + + def test_resolve_falls_back_to_global(self, tenant): + FeatureFlag.all_objects.create(slug="only_global", is_active=True, tenant=None) + with tenant_context(tenant): + resolved = FeatureFlag.objects.resolve(slug="only_global") + assert resolved is not None and resolved.tenant_id is None diff --git a/tests/test_tenancy/test_middleware.py b/tests/test_tenancy/test_middleware.py new file mode 100644 index 0000000..31d579e --- /dev/null +++ b/tests/test_tenancy/test_middleware.py @@ -0,0 +1,78 @@ +"""TenantMiddleware resolution and rejection (TENANCY.md §3.2).""" + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.http import HttpResponse +from django.test import RequestFactory + +from infrasynth.tenancy.context import get_current_tenant +from infrasynth.tenancy.middleware import TenantMiddleware +from infrasynth.tenancy.models import Tenant, TenantMembership + +pytestmark = pytest.mark.django_db + +TENANCY_ON = { + "ENABLED": True, + "REQUIRE_TENANT_BY_DEFAULT": True, + "TENANT_CLAIM": "tenant", + "TENANT_ALLOWLIST_PATHS": ["/api/v1/auth/login/", "/api/v1/auth/select-workspace/"], +} + + +@pytest.fixture(autouse=True) +def tenancy_enabled(settings): + settings.INFRASYNTH_TENANCY = TENANCY_ON + + +def _call(request): + return TenantMiddleware(lambda r: HttpResponse("ok"))(request) + + +class TestTenantMiddleware: + def test_binds_tenant_from_token_claim(self, user, tenant): + from infrasynth.tenancy.context import tenant_context + + req = RequestFactory().get("/api/v1/scheduler/tasks/") + req.user = user + req.auth = {"tenant": str(tenant.pk)} + with tenant_context(None): + resp = _call(req) + assert resp.status_code == 200 + assert req.tenant == tenant + assert get_current_tenant() is None # previous context restored + + def test_single_membership_auto_selected(self, user, tenant): + req = RequestFactory().get("/api/v1/scheduler/tasks/") + req.user = user + resp = _call(req) + assert resp.status_code == 200 + assert req.tenant == tenant + + def test_rejects_when_no_tenant_and_multiple_memberships(self, user, tenant): + other = Tenant.objects.create(slug="second", name="Second") + TenantMembership.objects.create(tenant=other, user=user, is_active=True) + req = RequestFactory().get("/api/v1/scheduler/tasks/") + req.user = user + resp = _call(req) + assert resp.status_code == 403 + assert b"AUTH_TENANT_REQUIRED" in resp.content + + def test_rejects_revoked_membership_immediately(self, user, tenant): + TenantMembership.objects.filter(tenant=tenant, user=user).update(is_active=False) + req = RequestFactory().get("/api/v1/scheduler/tasks/") + req.user = user + req.auth = {"tenant": str(tenant.pk)} + resp = _call(req) + assert resp.status_code == 403 + assert b"AUTH_MEMBERSHIP_REVOKED" in resp.content + + def test_allowlisted_path_passes_without_tenant(self): + req = RequestFactory().get("/api/v1/auth/login/") + req.user = AnonymousUser() + assert _call(req).status_code == 200 + + def test_unauthenticated_request_is_not_403ed(self): + req = RequestFactory().get("/api/v1/scheduler/tasks/") + req.user = AnonymousUser() + # Auth classes own the 401; middleware must not turn it into a 403. + assert _call(req).status_code == 200 diff --git a/tests/test_tenancy/test_services.py b/tests/test_tenancy/test_services.py new file mode 100644 index 0000000..5fd760f --- /dev/null +++ b/tests/test_tenancy/test_services.py @@ -0,0 +1,93 @@ +"""TenantService behaviour (TENANCY.md §3, §6, §8).""" + +import pytest +from django.contrib.auth import get_user_model + +from infrasynth.shared.exceptions import EntitlementError, NotFoundError, ValidationAppError +from infrasynth.tenancy.models import Tenant, TenantMembership +from infrasynth.tenancy.services import TenantService + +UserModel = get_user_model() +pytestmark = pytest.mark.django_db + + +class TestCreateTenant: + def test_creates_owner_membership(self, user): + tenant = TenantService().create_tenant("Acme Ltd", user) + assert tenant.slug == "acme-ltd" + assert TenantMembership.objects.filter(tenant=tenant, user=user, is_owner=True, role="owner").exists() + + def test_slug_is_deduplicated(self, user): + svc = TenantService() + a = svc.create_tenant("Acme", user) + b = svc.create_tenant("Acme", user) + assert a.slug != b.slug + + def test_requires_authenticated_owner(self): + with pytest.raises(ValidationAppError): + TenantService().create_tenant("Acme", None) + + +class TestSelectTenant: + def test_selects_active_membership(self, user, tenant): + assert TenantService().select_tenant(user, tenant.pk) == tenant + + def test_cross_tenant_raises_not_found(self, user): + other = Tenant.objects.create(slug="elsewhere", name="Elsewhere") + with pytest.raises(NotFoundError): + TenantService().select_tenant(user, other.pk) + + def test_suspended_tenant_denied(self, user, tenant): + tenant.status = Tenant.Status.SUSPENDED + tenant.save(update_fields=["status"]) + with pytest.raises(EntitlementError): + TenantService().select_tenant(user, tenant.pk) + + +class TestLifecycle: + def test_suspend_then_reinstate(self, tenant): + svc = TenantService() + svc.suspend(tenant, reason="nonpayment") + tenant.refresh_from_db() + assert tenant.status == Tenant.Status.SUSPENDED + assert tenant.suspended_at is not None + + svc.reinstate(tenant) + tenant.refresh_from_db() + assert tenant.status == Tenant.Status.ACTIVE + assert tenant.suspended_at is None + + def test_offboard_archives_and_revokes(self, user, tenant): + TenantService().offboard(tenant) + tenant.refresh_from_db() + assert tenant.status == Tenant.Status.ARCHIVED + assert not TenantMembership.objects.filter(tenant=tenant, is_active=True).exists() + + +class TestMembership: + def test_remove_member(self, tenant): + other = UserModel.objects.create_user(username="m", password="x") + TenantMembership.objects.create(tenant=tenant, user=other) + assert TenantService().remove_member(tenant, other) is True + assert not TenantMembership.objects.filter(tenant=tenant, user=other, is_active=True).exists() + + def test_invite_and_accept(self, tenant, user): + invitation = TenantService().invite(tenant, "new@example.com", invited_by=user) + assert invitation.token + new_user = UserModel.objects.create_user(username="new", email="new@example.com", password="x") + membership = TenantService().accept_invitation(invitation.token, new_user) + assert membership.tenant_id == tenant.id + invitation.refresh_from_db() + assert invitation.accepted_at is not None + + def test_expired_invitation_rejected(self, tenant, user): + from datetime import timedelta + + from django.utils import timezone + + invitation = TenantService().invite(tenant, "late@example.com") + invitation.expires_at = timezone.now() - timedelta(days=1) + invitation.save(update_fields=["expires_at"]) + new_user = UserModel.objects.create_user(username="late", password="x") + with pytest.raises(NotFoundError): + TenantService().accept_invitation(invitation.token, new_user) diff --git a/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc b/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 18c2f95910a42821725314772e3b18035f3c46c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT<*7*- w`T5z!`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00ImxE&u=k diff --git a/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index e59e3686143a477a07262111d1c6f88ae0f22ea7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25591 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 diff --git a/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 8e615725da23eccd61ff482043f469cafc077d26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5066 zcmeHL&2JmW6`$SZE|(N3N^))4sjUwyR?ONa^+Cz9Wf_uOxpImc!or|YK-OYOt|ZzN zmzr5hm5mqz8X!thASh6vKJ;Lqm*&QzJ?NOfpkOT54i*7=>Y+Cy3g9BAzBl`!m8@6^ z(B3lSzJ2e_n~#0-_RVkhLTfTkpuC@`-2YG_q=|#%YBxDfdWj}=5-oC1%|Xvt>=@U|pjC3Br;BSsA4y05jpFUJ z;B-tI%)C`CSoLaY)~J>$CfzqH!EHx&F9Ku0yzarAG-8VjiY=WW7R<7f*bz+xDr=Ih zGvG*dczye5-xT3C%|lyyfEjFg|%MMNY2ydrLkE;pkKj} zZR>$q1x8vj?R3P6Ftf7gL~F)I#WG4xblEgYCS{JoOdOrzsQpkJExT;3nAsJJ8f(Ub z>>Rc3n?;*t%hg3{u#Kv{e5nBL$QFzu3{cA2CWBg}`np+IwyX!9RCXPfscksQnn}xx z8&2oJht0SZQ5c+^0J2HGNS*lP(Z`RTT;56bJze`eb*ZUd+QkPgdMk!JQQ+c!=>cYw zXy6%5m=AoD7k!UpMev3QkIR>>`(ikWh4h6oT z39OCQs8uu>b56c7d#?uLl^q8H({DTVvRIebP(Dl{2QSoY0y1rPZ%&wHHX4TSlnuO*Qdv}iOIFcG0X5b&xd!%`^Di8~M~hrCdJ&o~su17F@J zz-*Fv)bTRsy`sL1_g*rOkHm|=?Bjwi)Wy1_lbl!6P3EyzUiNevgbutuxZY5@f$t+d zO9b{^*|#K z!|i?{Xc0{gJ<;LzK_1eSBW)#rPFnOx+el!m9%(BFwy`5^mB2QBq;1r<%|diEv49Mc|U*QpN@P(OcrQ^{sK5 z`XNqTuY<>bh*K{BYyY=6)snFNbx?d$9LXpY?>JIzW57oy6r~{~IV7V<#+*oPV{m}e z;fE>M;Z17yoI4N7ku>A#k=u^EPOa+FO@K6F8Gy}0gBuK_S^SaD^-STQ`4Is~A0!nap?0jJV}W4T@$3=6|yVI(YE4hvV@$$A|O?UM#w$(_Jp zZl5&DT4414o}jp}@igDPiypYwT>wgT4+#&yZOi|bM#o?n_Iuc>Vf*hmyVLRRRus|l zZsk{KXdk3rLz*-2dmVY}p#)bvZ$`#)Ewo2jdT7%*Gw0s9Pk z@uU~np<96syD|aa5ck-bfDO6^>+O=Oev`rg!9&czLqwZh15I_NaTbXQ1Rr9cVeX`^ zf$*8^k`xT%{hzBd&D6C(3>ciYXYves@uU~np<96sy9NTjadwY212*UytUr@o^_vtb zIbPm}u@G)@jP-ZEWZ#0ZJm?9wK|zGIAZX%(m=*mqDc~*%^bypp4!P&N)t~z4W$7;m z&`W4P_PYgq)sl|@zDnHf3*jpnkjeiZUj_S#Kf~{cuYz`fuaxhAg96Ny75AYHSyQ!G zUOs?9l<$E-0wff|AaTGOH?%n9@#MoqnX$T=v1(@3EIESV2nC7=ha#k*cwC1gfywF> z8&mo4!`4vx#_SEs5%{+7jW{s@NxK7cFC^oh2{%=TvMiqb=m?TgB$ttlAwjrHuOb;o zat%m2x|a^=&p{E+Z$oq<(71^#=T^JID8qpD(wLjV&K#b?{PS?`d*v{%)kzqH{T4_l ziM5~nL-+5xpLR9+8mrrV+pEpNsh#%eE#-@M`kpQ{?rs~+QF&;&aU%zIy9_ng zDSW_iVu8wudO9~m`k*K0-MvpW8UK?^#{U}Q?L&;8PThT+PZ=NHoelr!pyUO_gR(&Sukd;?pMQ00cxJvvT8eqBPyMo5+_fB7h+sVP_b7!JoyxYfdGZ@f5 zR+YK;2PTfY%Du#irU@=#BERNw%c8lR8@DEG?RdmIQ8sQ**a>f$;Kv;a2jNW<&V&<= zTPMoLT?rS7vrV|iD-soiFPjj?D-)H3w@*}!S0}0o&rj5h*CuKS@0h3?uTRvEdlDWJ zc1|>mHzpbhUp~<^-kfMAylbLmyfx8Ec=trxczdFK+?()@cO*JUT*XA^cvqr}@WRBl z@$HH2dc1VF zSS{9!KpWymtTm+QujAloore?a_n7JNgpb5|Ag+PM4Uo7-h-+eTgCwpQ;#yeT5Q%Gr zxHc9yESkEwp7tMM_Q!inO2tfUJ`uj0xDcEV&V?n2v0jMHoQGe*AB;vq!9*lBKPE{r zJXD^Lg7b242IC;eOW~Pt=7q914e^hILN_6G62JW2}04SCk$4pbP%*wShKta_w+c zO<})~&!nmWS{Q#jn06gB1x$^F)4U4c-}WD#l8r6%f86`tQ3^5%qPz6o({@k-*j;1JY?icgk?C2G;k?A zeJ&O|uZBRw!gCV2bo%30l%_x!>JkXfhXPnTTD1U?m%LDT8~n?t89xFIxZ|kJI+|A< z%~?n5El2AQdH3x)Z??|2TIWmG4K6!xSJ%I>l=b*h9$&h8VA-DQ+P>_}@ZKNRwJeY2 zY8tLp{o20eC;rS;`KwR9XXENy@0WAVHpmOUtB%(HBez01Uu!$+Hm!^bqx)@t=yL-9 zhx^NqIvqc-I)Fb3%4-iUCSr?nI8R-%^Sa>w-dl(l9%Qg+CnugPLd#)b8UEh>x z9?mu&Ni`oy*MIKnv$s9XnU=w9%i&bZ;k4&S(pvjNt0UL4^OY}Ttc^Ku|0~aCtgX4; z{TXXZ#=0Z7b00*nS-1Z$8Qb)0M?FUyO?hglDbPKvS`e<77vU*Hm*#P+R(4v9!^B&=7Z6bvC+j)Br&01x-(1xpdjok zxB&QjKY>mUno5n-lQ>ird|(7LiDuCRYwEIkkluV6jy^t2Wz(k(a+&U#_vQzBB(k>^zY1kw>JY1C5o`vEzU23vgOR4{M zaC&Y0)=521+Dq7E%^kHZUze2Sg-!t7*&c3l6B+r{#QS35XAFS@knuZ1@! z4RH{M+H5hkwUG zlk{794;ItPNtjU;e#CNJ7vpZfdlCn=f{*AiK4C4~DRzA<{GDIeHxB1j>95pIZ0?T> zw5GoxV+g{0qsw0j*Xy=Ze=}^}qh4QPL&Bwt)nDZ^<;7NSL2IhDoV8z7YbronD&xWiecfj4 z>v)A|UHFRMzOHAl6tu;|dUpG%zxt3pyM@ufXU=P<8nIoh_SY1&UhNiIuU2a{?KiLW z>>IS6H^22>di%51qu2bp)*Dzq7A(x3FrlU`T+*&NxZ{CX-kobBO|!!o((NxhVKV4i zh1l^3Mh3U05n3%bIWn+xFfw#(?XD=;?ZzFOm1No9XtP^9OuQmVG1dQ6E`f1bv}3@tXLMM zs8U8FA2?B>pUi%4m=zvo)D)X>lBfVpa6WuRvB7+0F$|fwWCL2G*yF(~(O579872T# zl4rD;0jf66p5Up8yZ5_54t>by^%G`%V)vUS?T|^qM>K2wCt5a;urLYh1`_OU<+F%3 zv1~-0H30fyht}qm1{GHbFsz9{OtSpIF*r2RKLCG&N;yNOA|dGjR8uLZ4FX6*D7;i_ zKqGdOP8k>&ykS$+W=>p*hZP%OdNE0HM&f~BCZ2)6G4Fgf{IfLFDyb=1tOv2 zH>?uQI23zqnqVVJ8T4;30X++74D=B6?R+RKDP`v`1*JJzu>y!7$vENZ-gTAEF+DUz zAc)f~glg1NQm&$EZFX|jk7uGc3u^+IOy&X!DKZCermU7iG^+nlG&U2A%18P)r~!<9 zoO}kzPXLB-HD+BMDOX3DMZb#g63cWXFz&);%Rcf~!(oa?bl&%uxM1QP5oX$;9xAU90X zQ}FZPt)L9;mhf)h8NV_()FV5;q;cNEG;)AN0;fjq)8c?pqxNV~z^G9yJ7Cl($f95E zQ0L42vyo^bEID7^Nv}gxki%H~-X6IJ4u>xT&L+cQ2q>2Ll>w>|zztz=50bq|_9<4V z{Q#ju$I0np^dOR9h1a?agO5F^Fr4v7kBpHcq;c|&Kov-1DQ0M%6Qsc&m=7PmVOr`X z4TNVz8|X3Z#DP)Iav07s5^}x_($xjh&^i#i8)G+U9&&*=U)HjR1U-!sPJ*Q`AUTc% zXTnki5T)z{8IcrT96KqVnK=HGgmVuGAX84U;w7S(;aVkEQWGA4cf`V_=UlFhm11o7MU0Kg?$}^nx97uT%JYwcGMCYTTZyYr6JSvUVFOqBm93o2?m6)ePt5?Bbl&nd;qXNB{fga!18I3+MFR zMehA3&ao}a?_A|~Li${T_czPmEQdmK4b2}oY!2u96 z@*Gm&N1n?FA~|#>C7dCVXVSt8B<6)IBr$x`{0qPGGK*N%Lu;59R0E|mBww8$c~0jA z(ZHWE@Oj11_*?u7x7|%ycW27onRR!k+}+8cXOiyjwEGLo=A2OV`j=k&(zV04gq|e7 z?_a%e18ThQ`U@#xUzS(D8Gc_<=%K+|2A&D5L5kBXw5o^j0Mtm&it%ZFpH}ptTf$B( z^BF?c6}*$CAqA^EROtFMEGr{p$d;bb!1WVJ9!N?U$nxqp!w-=2!Qd?e5AoMeFexLG z<_EC0mtVV_SD>}vUkBsrURE*dCkuY9BcwvtC(}D3;ZUVQ}G2YU+t&Z`Q z#%*Q;brUM&J$O`cDx``G_Bvf$uN=;BshJN4k9^(w* zLDu}^vRR|2NORdG@+J-8xVV%OmrLrP^c61I?R=TnLA}0M)KX_+H0m z{8c2Ukeo(>wIdjibQZZ6kW3+Y5y?wP0zfEoCjA12gGdPYGJ{+QNf-&qe-=5!OekD) z4!JKPIgca?@{Q;*Gi7aR+{VHKjqupYoBT2=2w8$8vNDcf-18AEST%|U(aLPVAU z$v1c{qWf;cv9V{y#4~}DqbEKe7=7l{=$R9NvC*f-#Jp#nDyWkMT^j!bxSp1BXoPC2 z?u$N?Xw@76!4Xe8MQ##r8wH<90MZISXy_E}24_N(?nk@XJpADBDLR-Nq#4~RMdwQ) zpBY>tU0R8g6&k1i!g(;bV7(K49NY}S0kKk-*SL9hCZcNt3FbyQ<J0u8a%I zx-;X#n>*Cx;2Hvt(claTFj*e_)T!sMae8{RAgRuf+q9#T-5RI=f-@sKAI*ram8?2L z>gjjt=`^lbdT^Jh604_5r1RUf^2C~wvijt!Ry0CdrQ4vDxYg)&DcS~p=3>Ck9W)Ev zarAUW&s|Rk=UgY&mz385KTkn1SZ?+g?Kgm4z$5-CqW7RNhlB8l0#qMI)T^acLGC1GPG;PE@5di=_k2YTVk@ z^iwoSG#RwA_!Y)nf&myU)n?4`%xBAUlB^&urWFNFlEN0Q4F+r$zc!>l1-(YZqu>*_ ziEeNdw)rccHZ3qMoqKyzI@&>Wv=dYR3~Fg@$=x+QNn;+?lISjLr z4*IKn@<2gHLNIQ7>22DV`D4x|dy|ehU7z*lC*7N(yNi4H0?0AzGxz8M$dYn@Pvv^H zEj1dP{rY1PxZTS7HYnW^>N6NI$IbhT%@YSMGz^G9hIC&NEfirrX5-&LL zCoA-Vqevc3HVjA}0qBUc6asYU&$gddV2u@fiIG44TGME1_d5>V*yLkd+@0ZOIf zr0&=-rlpaGBm=+Fcx+yVCE~<8POmg3ZKKN}VNE%%Jp^D{z!oTQu7l|+r`U+zC@yd; zUXJ>+7TdIef>CA;KBAuBGqCC+#s0=0Y`av*r9Vpki7Vwj)9oP~nZ=&7NgCeCfB zcN`J3qB`qc0AVotN5GvO|ZMqA* z+8{_B+x8Lgle%}RYdx>v8fAhd1n3AMuXC=2<)F%on}HyxZ|;Z4{u@wfIRTvv{_W`+Kg&<{*f=)d_)MwlRR zlPLl4P>7pMquU!uflFH6H_eL~bd*vfR}H~6Ot^y{%8-ni7-c}B{qzu&10^#tBLjz^ z&RL-y9MF<{-%0{VQfP-?p)bp;-?SRXA{c{}Q-xOfJ}tCHNl;%u?WUsVXDw7->-$%h&(-#;UQBdWMppf{r3gXP0-t9)$RvW zMM2k-?y(0l&SQRHdF)h$?FaiB{0AI=RLKLscA)%JxjSof0H3WWKh^G#z6pW=&`Q4s zztTTO@+~CSkl2y@9+Eea{5q0<0>p@^{su-OL7R_^z!bq$|IAkZU2Den*n8$yn-9E6 z%l7^)auixcalECn4`Q_Vxrfk69<=(~`OCl)Hdp{&CRzYOwJLo$eidvmqJpW!C3+Ng z4T;DzxS7e7Jl{aP8^OTD-+L0c&4Z<6wYClG$-$l$c+;n!V(UWz03g7q=m0-Bo4yug zN=?tPy*_Z?jbcDY95ymoI0m2zTUP>8J+g1-jar{+AG_O4wQ1V5S?%*X!L)1{vQ@!bBhVQu53^243ly+md~CiFN!rV9pAQ87mUM>1O0Icz{kZ4+TM3d!X%CG zsx$SamQk-N<5f4e^Ug}K3*OOq+&lUtY+r!cfY&V!4bndYqS!;>+2CR{q3|=XJ;7W| zx&lBuNKN;{;^*iUa0`q<4ZO3v3at-8+^C=)iYRXC^$AqH$HXgt6W7;hlY z0J24_H(SVamlt=NFj#4{5(Z&|gPuyV!=m(C*a~=uk={l^+?SV-laVBlTtspS$z>#0 zkf8gQ!5V>(NsnOgX8t2Q{$(V(`|`iRAo?)pf%*f|?_&HPBl#U9SCQOA@--kbB2C@- z&^<-%2MB7fYLB)@;}wk|@DV|d$PQDmG|ac zUAd~xT*Ka6ZDX#k<%9AvvfA6xpdD__Rke`A4eW3wSJRQM?p(INE7WI&j+D@m2zm_V@kG?PRsN_JT0?0#5j>Is9<2o$ zujK;qH$yD3k%82A_~tC%mEyt0x;Mr5=Bn!O>uG`A5x_&90}It%u==obI8M&tI6a4q z0R3?M?K(?oA(B!p z2U5NbX12V9Gze06_?j%=oWgVMO!1w$s_nU|_FR24?Jh^x`y6cLkk?s{Cp-5h`NzqL z?oA7zfsfyOj1uATEU$jkC;_^o$50+mO^sXSAJ;-_BtLp21AOORo!4@K_?wTh#03nL zPb~=Fgz@TlkG-hX;!ok&Lkt$*!^22^8~$bVn4Zh3mI~*)#CJD04bS2uVJzXzO_;qeDrIf?;Iuj z&JKNtCM&(~oMP?5*^k(F|2(hn>K;MgG0);H_8m(HeOLGC?7NbERxi4JT;BCoF4`7e z@Y{^w)!gQ;w(aC<>pt~w^Lotl2zrdcD_iU_mJWK%^QrH#qUJr)SL)paHZN|}u3>$1 zV(;Sv$ik6~0aIEY>Nda?06^}@^F0?}BjBx!nH>eAbI~zV2cEPMN2{y4 zvZKUUxIR7fY6IDZcq?@+Y?Y#Q)mxXffQAa%;)U9bN8V4X_sl0W6a%UWbG6OiI{1x)*S>UpB3-jL>Do(RDy5vR`bosV#IsaR*9Dy< zfrh-cCt_YACj9{N{V!lk}e<8!3whDiK&V;9xa)X^vsZjG+q?)K$qTzYz7clp7ct2eP^dC0F2$C&>mW0$Cvh-i!1bS+z74J6i(z`$uhkA8G+x`Vc{t}We;}NpI zHC=JDhip2e%T7ywiDG|+uOaz&NPZE?e?#)$fyfBRRaD;*qqLLE|X*Ao*THk z$h}|2IXgAc#$0uKu3^Y1I>jz) zCzH>e&eWe-F25~Qzi}{)Yhkvn3=#snb7y(=o8h;?;uscW%&*9dUFEkKLM$4xdgCAq zfshtjGh_+buM9FFBLfL?0yLE`DR@^tpAvjoUj3%kI2v(_gxEpKtnt2;&$GDIyby~9 z2D198Y7bD~!KBc+ay}(MtEu0#8b>2;kq|pbnKgcJojZd8uLX*A4tufT3yKvF_?mRG+SzMI%0^%!~x)j^G0<+}|b77baw0sB$uLRx6e zkR@cl0vmS~<_Q zAMHVM-SQ*Rq#E|{{gYOB|0G?0MBhKzfdh5X7Xxcny+|G8T->zxW$;?L%8_?EbMQ`Q z_RtzPkvJ$%WE%#-yCqpwH!$D9J85*2m-dXdi8V?#zqv#@xN{VKz$nK|Ua(3DR-5>( zu=#`zBW}*r&_-ay_EUQd-~gB*AHige`RHwNvc}TEWDU%tB>_&>Dh1#`8SaJdFDn3Z z?OOnI*`_|)q#nRr*v(ox>9=0C$}FbwkfpmTVoirfCz?US^gnXvuP7A}udyN!J!yl6mUpw8H0|25&IF04$K6j zT?Lzjds#Xd9cn+Nql0^c(E&CR%NrdkaBDH2Y2movN_2gzV>xUg1S7(>Q+M)u>rt4E znLqp%dyA!m-tv4pdrLQl7F*#0+u*fm21}yoVi?dzMnHr%VL;qYM#gSTZ_t;m+(8^$G-hwkfXKGs z+M;f;bf8-eVtL6sAv4Xwc5tPPO3Y~L%Qp~L;~Ex!b-|smW(#*h21RSyM4u_}PDs`m zSH`PKj`S^B+IX#47PtCqVXWV}LD@@2j~$rWpbvJj^%0Er>|L~5>|>S=`ndJeIev&` zU0jLnrA*I!pl&a<^JAC+k@urLbOuCGaD4M5ZtW8RCMG=;0ta8nQNJb`@!5tO z@du0m{5|yWxJtIM^_l$UVPpm2&lpfMJ1}SEM`GjHr&})gFok1f>59!IQ*XLf@<~i-+;% zq!`jqOFWiqhs9;^R?Y|GZl?^wvQvA~)o=`GBx)?l$=6;6Fa`D{YI6ZbwYh)=1v`5&47{# zr)Bijq)M3-jL2b`D)k}AP;nUx>#3uL792Vx>~6`8gjrXldsv5G#&l)08>qeHWsF`% z@){BW6ICIpM)C^A{SJ~>k^BjgS~x)8|3K|2`IK5$x`}nA%W90W*f4@D$Pu%8P3#0DT_hsvStM$I* zz~kxq(dF_S+6lT>0H4rmH@;T$U5+-Z+0t03^D11=iyr5qe=c4E?70Ucjd*f6KsUP>@L(nN?Uu z16hXu;mc_Buo03fuzb8sZ5X$}#G$eolr>s57!P)kPr9W%omo#`%F}oKiRAFH@1IEy zJ)3;)MA~z5*_HDSe77greh62LwI535uMk`QvT+#~$y0L;Zknjc#f>>d+W$|jv#X+ z+jS(>g)CTjU<139a3ssC-?SDLEO!=97VhU3#`m8W_m)PW6s# zb>C`rUvk&cboH@id#iEE?ZG>u_l#^6dJRR*>T)Iw`~+cnT?^?;E9 zq5I{~5YSFZci#gU*McT{Yftyua=qoJ4{F<+6@QLu#4+bh9At{_Z1xj8f&@`~PF$bGW~IG!^dHN6Xi&I_--KwpS_)d^N>bLD$x-c$qI_n9m2B6qi*GxM*ueXZ@9 zHDhXh&+IVm$AfKmk-OjEH<`TefoGPij1j$&YbzrWzIQs{_lMM86T(%csV$J`NsU)LrokvgBR>@Xn|)gp5yLv zS2&SB!znyD+a~RKTL(AkP@Elj$9Bdw=~mn%Y(FDRdK3={^Jlz@7w$XG_$K{|pTs%O z1Sad0y2+ptoD3-;66ZQoKiQx(Og1Ww9o%Jatd~{7ILG!IVHQ%hU3QD^7H&|BP+B1E zVRqLz@8(27^o+oB;30ZVDYiFraM#zuiT*(wy|1*9xB$e}>2W(mzMBgNe~v2W!@OEI zlg%ipTgpN*lblOQ5aU?L&RmDHPmz)tIXR=Gvzf464SglAOlR|%+0&`{^o^8s+lU#@ z%;vJ`jG{I%oeTMCc}7a-NLrg39KVsuD5q0b)0s3zsR0rprsmSJBHga&E;m%H8~&c{ zg2(LQI-tv@u(ITcX^+OP@w{7awqtyP&)f3$gdWquiQFyjF5h9!gAE8%CPp4PZVGEa zjyM++SlBoZYR#YHBV3%z=()xD5I51taXDMuc7;FKW~9j3<94Hj4(>ObInKlFg1X_q zK|9sNx^{D&+yQQ%mUIrDb|;sH0oo4jfd5eAn7y4#^F#JDf1f`ec1lC`9$HupNcl`6n}Hh(3uz_nP`xG$O=qr3N%?k0xprteDW{^-$(ierQB+CEa2I9b zW@`FcHhW#WlC<8Alhh&CVZweslZ zaBN6EC?;%|JaAIoi4?wovg(|fPbH=M+%v3$1T!TE697bI60CQmoe+sFA|C`nn%_}h zc5E*?wwE0pMMp=;vHPbE&*RF-HOKD%lwFYE8)Nn{CtuM>+UvqS>xcVE=+DbA`-+@m zyV9fBw{cmRk6Sp!A=*GXMLS5B=uq5oP7y>W%vCPce-TC#oh(w)49gF4qJWo8OI^{(khc0TURRN;c$In6^uSN)(*v7d7rWTYJI>D?!Pi&hMVn}!BK8SO ziTSI`GEZTUgqz!q>89$KUM$2y#(6h4h0RS4_Drng*&S8s8t8)+SNM zLA@`*eAuUO1|)|mk8Lc@IPd1-c9sV_U*km&7$82@YUr_0&LMJ(7vs*+Nscqd>D)h5 z^@PcAv6O%;0&&{_FVA zC|?oleuW-%vz*x(d+->aa~$KPJ)#G(+KZNJsrlS&O1>UZ(qLqj$c&UqDyi9}p`^Uw%jt})Br`K9jOYXN zk2V6s{SuC83=N(~IrO5=RM1FR#>kQT{0rNO6JdVoAeI4H)8Zju6d%bX7gF9kohN98 zhM@d9YLWzMa8i=mjF5$0rDio#C3Qg+Xg&O2%H2*(=hO2FR9JP*XJ?Z0vg(Ccf-;hl z+M1BBW%Kg@CQ>;mF*l!`PR=K=Xw{RDlUGw1t=5f^#sc^`E=gHQZA@IvN()IPk-C+e z2lPvTse`1dPe2j#BsYL2xulc|3#vm-&0m#zuw{H$BibYb^nzwJWWLj9z8EyC4Vy1U zWo+!dh_&FLHo22L7;BRR?QS_SYXIm&W|J7HrM?GBka2+Bv1RAu;Pwyp{IFv+*jEtx%E7)eNYigs==;MZJz}NeN{@z= zAMDYuK*@BaM}yK2JM=41VpnTIUtvSJC82LU*!2F=drJjjxX?aKWidEh7PQ~0FuV*k zGa_hsIj_g9fQELpW+<0Pe!7x50JkJCr(6Z?=s;h&F?M zeiQ;Tyvb%jeaMlsiTq-J+)ifr)69-(GrVr6VmcOK=@5ay4DTQ_e79+aM~kcDm6(o5 z%;^|>CCG)M0iz^KN6}28?vpGq#Q*Gz(ixU$?NL$3SW^>%4VKS~bHvMX%_K zJF8}RmuZGKYGG}oU2JA&XPDvrFvHtmhJXDfmfA+g%NOj+;H6*prRgon{m=KM1$KgW z5>R#Yi@ukkU-xAN#`+)bOVJO=tA44q8hJ@6IXiy?pjYrBfnuF3LiJE=mPpU44&`<( zrMj}y$dmwe<^$};*f2Gix|LkW&8HH%81Dk*?Q!$m8i(eYqBw1C3s*^@GNlL3;nSug53F!mr5Q@_%kj<2aQAAKg zQS_ncM=^i`Er~RaVgf|iEsbEf2gOko$54!-IF8~32-yun!%h@B?Wn{;H`B_s#6n6* z&L)+l+PevAs?a=udH-D{U|RR@U2hGqxA%UyyRh@%dQ;m6lZD1T|L$>myua{s^}9?s zD)uP!Tgy&BE9GEkG1yrUh6+7HR2G9nWkLI`LVU1OztjT=Q8bkmVTfI=nY2qJM_tPt zfE1+Qo?@`4AdG&v<3X~}I|?C?#o%aJ(0;2B6YSA1^#B&9sjLX2>}t)VT_SnuTIK+x zK#caa_r3<$?w;#Iq0oUK5@gYY=Kw)2e@(}IdH~DPkS1V3b_H0FDFXt8oOC5~0B&u> zX*xU93wUcQIE`88t>dzsi4Q`Q$GG`8FkE|K5Q#tso9H~KNA_`X10NylqoX@(zNrG|!CVEbk5Ma2 zOl3SbYghJE8bf8Ix2)gB^ppiMm1~*KU_}7E)+i&cvj9~rj0-(2o38AQoUL)6)ym#W zabL~}*l*ByzL>?hFh)fs1G(5`$?Z6S+xdgIK#>0cYv;N5tNO`$^n~Je7(Mlu-u%3y zr)Deb{E6tRsVlJ2Qia4lq94{Mo+>2neFY@$WjV7mAaQe#jEY{c`VC9KgeZ}u>R!kz zNV9}_X$amM37LLmJ_ZPbK@Bw8<|Q*ba)h!YM+k8;TyblZ-WmmOf-Ri^TYeEOE08mt z=?tM~fLWm&3IsF`1sD?=1pzH;AS_2LDHHFVUSM|^P#~-f=+fj6(2_%WRjkY~urdMq zOoq|M(QPu?#7b9LZo_C}^MrlGUaKz6)nMXC4inRDrb9FagiTM_<4$z?WFNr*mTd%_j*^!$GPs<;4K=tC57bVDs6_6q#0DSSED8G_ z0|uxcT&W*itsh=?J#Gtsc)QegWZAWR^S$7Da9cq*`GZ5{wiCs+6A&!4omg!hHmmv7RmM@CS;y+$%&$}7ed+<+{pUJ1iSmU?0cz^%u@o#kNvO0a)5IQZbw zqaf7gTse5E7(4}mAI}j{44x}PEcvYp=L*47H2es|Pzv5Enc^Y*<2f2#CD(*=#4t;^ zG&&#ls6v7;bBToNO(Yhwvw48R3DuuSEasE*$iBfLmSkl?V3Qc-HB_>+<4`Hyb`On7Q@I7goPz0UsUtxo58{t1VYJ_Kr4IJcNT`q?$OQ{mh?Nu67l z9aeM;y1bY{1$P&^zTCw1C5R3PdqgKlujm5l!;JxoUlf!8xWwuRCG1rN9J%A*L#-gD zaIs5>DFo290FRX)5&Y4_T{Qx^-~rR2Lv>wDO7L31Vn|VP^3iBiTQEd`8wKZZ_WF_! zU${fy9EG=7d@ z>Pw&|KmgPuSgp93}H)f;-xY|4iExs;;MW++(#ZBevbhorU5y z(ZvvQ&JlOStoRbADc_werEy-o8pF4+>R8Oj-30qAGwcKYS1?e3QahPy3|$IU2RwIE zTmbabeCd)2{h}22Q%>b(qru{smNO zdLnCm18o6!`AW}0aeXJNg7tG4{ljpS{yfz4Y^1lW-^TRtT;NBm`CK}aDh6um>gYS_ ztl*72Vx1VYK#|B)ki(QS;+?7kdcz7u0-i@qpA!Jr&%wC1*t-SnV`%a39WGHpcx`D}un(*84KRa%N{(5omoWeH(Y5 zY`z_9+;;ZcbURi}y+5aV;T8$|`o2wi8&0a1fMo)RUhFq^R7Kd%smN?v&Lx$ZYY{26 zm1wi6P zaz39@eF!7-xEh#E0c{KWAj$SK>Y{ew9yQW#svYnTfEl8!x@a|3w^mEjGO22XRLO0s zZmqiD11Hob4gpER$~mxCB^j>?)%gZSnpRLFl=r-Z(dKnhA06`%h6)X(H~D1 zep@QE99?aZm;H|e_3wZ6TVE}-o-PN*OM&rEo7N9Tmrs}d?L~ik$sb7y>|=3SgEVO+;ybbbp*oYuCZd*7z7Fs2#%E@hWtvx*z&vj zl^$5Wr3Y6)L%UkDP=+`xhvf&_`X$^dbRD6$pd2V!S2G9T7Sy>M>?j61kRR?QEOGbH z29~(PO!3k%*385bYgdFN*0hl&?$%UvT^fK}unn>y0&QNXKlFmOEc8<$F#bYD~1Z z>SaA+oMC)=SpTy#z%3WRX4vdT;4_4!TcWEb?BdUUe@n27Kl?Wt>}pW?{n7$dM#`ed zfmre+Gq)pj(;I*5ytIgMe`|(CK$TLI(uX81-NFX^E{%hL7G*)(T5QI9$p$%%5%J!M zT!js8DG5kiGoCwv;0HksD5P$|UVgw;gyn@7Ps4P?TTCDxb_Br_#APgRPyQ!`>={Bi+T^2(}>}ToLm% z0i00^YiR2O{c8p}{jwLd4OBykq7DP5+)U5U&LpK-24~1;C)C-C6dBzfN3f5j=0(U| ze(Pm;W{TjM%U>MN+&lK5@j>!|PzoKXL^GjIN@W!Uk+NA3CFtdI1R*7V+tI+@`d~{ZigQV<=q&#Cv88d||il_09 z1QBq==9pq~5HZDMCt?Z+f7oB#3}+x;KcvR-*1cxK6N|2s zk<1dXay8emk=}yr14OVPJy=I1?P{bmxrRM8b#=N9Fj1|1b@Wi>Ms`o(L`@D;9+oI6 zu3KZ)CSV=WGTNzyl;Wae3zo5Si|bphgg_~XaxFMmhP$TUD%^sE zEM~=YxrWIP&|4*vl_^}235|!WDy#^pW5vppZ=7gxK5S~8uzMaJ6d?SG-8az`_@vH* z;TGRSZ>1GMG(%RVKOi=K3NGnkwYswMicy!3*@}QuhRi%5iEKRv z^E9Fua#()?1}j3SA0C#LCrIxZGyg^{C?mwQthYH=SswW4Sah2#Pt`S89(=v34rckl zLFBGAAD!L5mD%X*kTw?y(jP-%7o|S|IU)TiNZ2b8$md}5UJ5+Isrgx%PDilWzzugz z93LvfCmKnZu&`e!wym5Tq(8$9?Nx8MjOqX`DXQ!?P^JDxGWVnY{vO=y{jJu%3faql z1mY#{Zj!va)9dYB%R)(LTNgs_2i^a3|HAL|n$Y>&Nm1Dgc>&#Vkqt=`winJZ)PQ(X9Zy7NxHNmtSq(LhyO9ow zld8jSn&mn<)^_v{2>E^h||W^-MYMXK}{)CG?t`QftzU+QxdE zKijbta}9b2G=tr+AC~9_id;! zU15YYXIO$IHkdi-6lke#?JHM}nQJm-t=;LmLqD1zeltRYVK*BeO%P|Ll!v(Rwrad! zfP~rvnDh>2`z{JXcnxD{Q&-}t)V4IjHgUb>b!0Jold zHYKk3p^2l;hr?|Xdp#eA1PFh!*EcZ|_@vK+;iJAU`#jnP$DoAvRC)`=6pG6rR2ThL zS#@7c--7+8Df%A#9^U^BitnNLK8n9U@mDBt(I+KP{0<27#>E+oL~#;tpdcF;*PXs^ zUitc!&umVo_nDn@H9f)5(>kwn_-U))yud$gayf^dhTP8GPa7Q0-Ov32=g}t|2)elu za}4iP1@@^m8RF{p1g(37uB_qnY1oT!Q%WoFakU<$y0tj+f{_1S2A%2i>1oLZxvD}Q z{^K8n))eym5aT?vZznTz*~siP{V#0VtoA>^2GP{KOxN&;1j%cx?zB>+pG!-($w%Xw z2OmDGy3Ptp%ucIb`YaetneM;FJaNXRyFp3Iw&CRt6ipyh;W*u;`kM43xI{QC|1O9p zcAn?|iQ|5sd&KSe8MpmkxWLc2-On8k-u4*>;<=aOC;5L4G`|1VdvBEkUBy7xT`#P) zZK2O>0^bN7WD7mP(35SPP59=nZ|wTOQR3S_v)TDByt(TMhMxOd`Sbi|9Lndzuk-xT KXBW{baI2^O&b`0|umrWF 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 diff --git a/tests/test_webhooks/test_integration.py b/tests/test_webhooks/test_integration.py index 64d0a1e..e37e0e9 100644 --- a/tests/test_webhooks/test_integration.py +++ b/tests/test_webhooks/test_integration.py @@ -246,20 +246,14 @@ class TestVariableResolverRegistryCrossApp: return context.get("order_total", 0) context_a = {"user_name": "Alice"} - result_a = VariableResolverRegistry.resolve( - "user_name", "alice@example.com", context_a, namespace="app_a" - ) + result_a = VariableResolverRegistry.resolve("user_name", "alice@example.com", context_a, namespace="app_a") assert result_a == "Alice" context_b = {"order_total": 99.99} - result_b = VariableResolverRegistry.resolve( - "order_total", "alice@example.com", context_b, namespace="app_b" - ) + result_b = VariableResolverRegistry.resolve("order_total", "alice@example.com", context_b, namespace="app_b") assert result_b == 99.99 - result_unknown = VariableResolverRegistry.resolve( - "nonexistent", "alice@example.com", {}, namespace="app_a" - ) + result_unknown = VariableResolverRegistry.resolve("nonexistent", "alice@example.com", {}, namespace="app_a") assert result_unknown is None available_a = VariableResolverRegistry.get_available_variables(namespace="app_a") diff --git a/tests/test_webhooks/test_views.py b/tests/test_webhooks/test_views.py index 43efa0a..987f810 100644 --- a/tests/test_webhooks/test_views.py +++ b/tests/test_webhooks/test_views.py @@ -1,3 +1,5 @@ +import json + import pytest from infrasynth.webhooks.models import ( @@ -7,14 +9,15 @@ from infrasynth.webhooks.models import ( OutboundEndpoint, OutboundSubscription, ) +from infrasynth.webhooks.signature import sign_payload pytestmark = pytest.mark.django_db -OUTBOUND_ENDPOINTS_URL = "/api/webhooks/outbound/endpoints/" -OUTBOUND_SUBSCRIPTIONS_URL = "/api/webhooks/outbound/subscriptions/" -OUTBOUND_DELIVERIES_URL = "/api/webhooks/outbound/deliveries/" -INBOUND_ENDPOINTS_URL = "/api/webhooks/inbound/endpoints/" -INBOUND_EVENTS_URL = "/api/webhooks/inbound/events/" +OUTBOUND_ENDPOINTS_URL = "/api/v1/webhooks/outbound/endpoints/" +OUTBOUND_SUBSCRIPTIONS_URL = "/api/v1/webhooks/outbound/subscriptions/" +OUTBOUND_DELIVERIES_URL = "/api/v1/webhooks/outbound/deliveries/" +INBOUND_ENDPOINTS_URL = "/api/v1/webhooks/inbound/endpoints/" +INBOUND_EVENTS_URL = "/api/v1/webhooks/inbound/events/" @pytest.fixture @@ -38,7 +41,7 @@ def inbound_endpoint(): slug="stripe", source="stripe", secret="wh-secret", - handler="infrasynth.webhooks.inbound.handlers.BaseInboundHandler", + handler="infrasynth.webhooks.inbound.handlers.HMACInboundHandler", ) @@ -170,31 +173,55 @@ class TestInboundEventViewSet: class TestInboundReceiveView: - URL = "/api/webhooks/inbound/receive/{slug}/" + URL = "/api/v1/webhooks/inbound/receive/{slug}/" + + def _post(self, client, endpoint, payload, headers=None, *, secret=None, sign=True, event_id=None): + headers = dict(headers or {}) + if event_id: + headers["X-Event-Id"] = event_id + body = json.dumps({"payload": payload, "headers": headers}) + http_headers = {} + if sign: + http_headers["HTTP_X_WEBHOOK_SIGNATURE"] = sign_payload(secret or endpoint.secret, body) + return client.post( + self.URL.format(slug=endpoint.slug), + data=body, + content_type="application/json", + **http_headers, + ) def test_receive_creates_event(self, api_client, inbound_endpoint): - response = api_client.post( - self.URL.format(slug="stripe"), - { - "payload": {"type": "invoice.paid"}, - "headers": {"X-Event-Type": "invoice.paid"}, - }, - format="json", - ) + response = self._post(api_client, inbound_endpoint, {"type": "invoice.paid"}, {"X-Event-Type": "invoice.paid"}) assert response.status_code == 201 event = InboundEvent.objects.get(endpoint=inbound_endpoint) assert event.event_type == "invoice.paid" assert event.raw_payload == {"type": "invoice.paid"} + assert event.is_verified is True + assert event.is_processed is True def test_receive_is_public(self, api_client, inbound_endpoint): - response = api_client.post( - self.URL.format(slug="stripe"), - {"payload": {}, "headers": {}}, - format="json", - ) + response = self._post(api_client, inbound_endpoint, {}, {}) assert response.status_code == 201 - def test_receive_unknown_slug_404(self, api_client): + def test_receive_rejects_bad_signature(self, api_client, inbound_endpoint): + response = self._post(api_client, inbound_endpoint, {"a": 1}, sign=False) + assert response.status_code == 401 + assert response.data["code"] == "AUTH_INVALID_SIGNATURE" + assert not InboundEvent.objects.exists() + + def test_receive_rejects_wrong_secret(self, api_client, inbound_endpoint): + response = self._post(api_client, inbound_endpoint, {"a": 1}, secret="wrong") + assert response.status_code == 401 + assert not InboundEvent.objects.exists() + + def test_receive_is_idempotent_by_external_id(self, api_client, inbound_endpoint): + first = self._post(api_client, inbound_endpoint, {"a": 1}, event_id="evt_1") + second = self._post(api_client, inbound_endpoint, {"a": 1}, event_id="evt_1") + assert first.status_code == 201 + assert second.status_code == 200 + assert InboundEvent.objects.filter(endpoint=inbound_endpoint, external_id="evt_1").count() == 1 + + def test_receive_unknown_slug_404(self, api_client, inbound_endpoint): response = api_client.post( self.URL.format(slug="unknown"), {"payload": {}, "headers": {}}, @@ -205,11 +232,7 @@ class TestInboundReceiveView: def test_receive_inactive_endpoint_404(self, api_client, inbound_endpoint): inbound_endpoint.is_active = False inbound_endpoint.save(update_fields=["is_active"]) - response = api_client.post( - self.URL.format(slug="stripe"), - {"payload": {}, "headers": {}}, - format="json", - ) + response = self._post(api_client, inbound_endpoint, {}, {}) assert response.status_code == 404 def test_receive_missing_payload_400(self, api_client, inbound_endpoint): @@ -222,11 +245,7 @@ class TestInboundReceiveView: sent = [] receiver = lambda **kw: sent.append(kw) # noqa: E731 inbound_event_received.connect(receiver, weak=False) - api_client.post( - self.URL.format(slug="stripe"), - {"payload": {"a": 1}, "headers": {}}, - format="json", - ) + self._post(api_client, inbound_endpoint, {"a": 1}, {}) assert sent assert sent[0]["payload"] == {"a": 1} inbound_event_received.disconnect(receiver) diff --git a/tests/test_workflows/__pycache__/__init__.cpython-314.pyc b/tests/test_workflows/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 7c45e1eba73c4c01e7a4a4cd881a4f8118d498f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT<@rU~ xX*v1j#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb^q)Tu=;Rd}3x~WGrF^vH(BnExZ5# diff --git a/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 0c33c2af09a2b6e259f7a5539b4371a6e75916a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65425 zcmeHw3vgT4c_u&t-~|W}0H1n`qMp!$6ea3qS*~ckWtpNu3G_oTN|BI6n}O@@XKQb?smIf zt}Cu*U2*pjSJusbD-Ks)s95eg?8$nUn@@aMAD&bksXDA>H5ONS#D6%D4Y081NcG`h zHh4Ic4IQq@)*KFJ!-pf;$l+){%U-v3rsr>C;Bf~tDtw-AR{7(MHy-ltT>o(ii z;9B8|`O|a97FUnB1`)R`?q2DNHU14Xtt;j>BAw~9KJr4}P)}Cx%cjm=LR?Mni2nSU zp^=LRhR+TTr(!;%rfXy%)tSi*o*h1)8qOMl6MElpW-vQAGHhr(wGrW=Bg2_&-*A7* z2=W-pFP|Sjok{60r1YXU?n8?z@&E36jN290DQY3Rx~*~K_w?KCwcnZHIOa&aB3`+J zO2L(OouV??HP@}5F*uc*^)%RR?-qfVbKm#WTSaeaP+I%(`_N zWJ9Ab;RMy;?K#mIKVbxqbe%X5fBeAyBb_G>pu(>GI?e5vN3W&yUVKS<*6`_8gHpo- z8Cn~gaT)Qpb0gkjETwwK7VDQSmJD!??r!ntoEO?gYJxeFN*PqLI zmQH(~az5}~Kgu5T-mb2@a{Mm(Rg8Xsbl7$G5&ZO_)1LtHiMz5D&o0SUHo8Wz)HJ!W zo_GbIH(m+oi+cd82pMFxxG(EZxUzwG6(*==_)lQq*{tig)+SbX2oU$$-7i(K6^~ac zut~Q)HOqpv#+7i#-8-!BJYAWn?56qPxYA`-vC)&Nb!>DX;oEuGjE%dZK7(cJ6_j&aBD?})gD@lZQnReXUDZd^mG@VoL%ausos$sw<}zU z$_{IscpC?r9Qhh&&-b`HUWrv+_DG%H!|REAu|CVyu6#dMINGD^hg^MmJ)%F09r=~* zE6tC7`QlY&`{m{J#kI0&eLQWOccZ|iz^quuqey+cAza&0H^L zZhi0NAIF(%dk@N4l&Iq49}ksH8{lc%JVo!lI$jeGC#oH@AvllO5acEEU&m~C8ndBU zzHc5H?d)#hVfj0HBH=Ar#Tw=~7ibl0=sob`I2YoPd97k|TaDfPjq$H;j;pcAhgB;w zpH<5-_d@Y#TuX!;b5EPc+|zgu_^+IM`h|EkaZ7qNX4h4>;T^d+oYIZT^rg*)HprPA z+-{+`4!sJ=D72lx&JV(U8SpvT=GE)cm)bYSGKPnKyL7@Olw!vRHMSJHkN}ap49^AR zDtfdtmbvQwpkmLStM1FsQ)ydbnZPHlK=U3}0m^R$t(d)#3B1(2$BaWhGZ_L_vh6$y z0V}ElIYFGToUKQ?lHKv+hvEl%dIB$@x-O~(Da>*Zurkfig8mD-4hE4C#(|ewiT0Mw z*%8ac+?Nl}(g8NU$-x1cXNLAtl31-y^pOs9>oR<1-_2LxGrMC{+j?UJ_NFfp0i5j9 zjcBsJZ)hl~r_y@=$oX`i4ptS9lcz5X4rTFT4d2iRQe=z;$;`Qt3qu1eY4YsQ$mzbJ zB;zRj$xPpw6qR7qfHTH>1~Ys>*GF`tE_r4|Ki`*4rd~`B^$qtiHaTb?8k6DBcz2D8 z!GTx}7?#w~8GQ%!iAZjJ6LqYb+GS3YcKgjX`^^sf%~tcqY{M?IW9ChJQ3>1a>2?;S z+iZWdBSVOD&6+jDb6A(YirTi2jVDH4&t{V7FAJGW(Mmz`w$7LcD~v017u4Jp*PXf* zQ*|o~b+LS1Y-0Vp`*U@%sk+WF?RL27)t6s+Ij0?%ZrM_3*_ChEg>a$eK)&Sw0yzZ2 z2MUN`f79B5v6sc22#mcbf;W)Dy1S)Fj5w-?mxt8i7LRf*yUa(Z2Q`ai_5dEC&4uu) ze0Wt(>nMa{`EU$@iCzZs;f?}g+254bkqgJftq4#9tt2ii%?Xv1f95+ZiRyoS$Qk-**e4Q5gqBarAD zx{zXvh+NvPR_Ko*JzEztG+=Aim|x!#_F-KZetsD1h~UR-(YnP5^v^ zp}JQ;@tIHj>O&LH6hgahg?8NuE}9B1DFj>c!Itq|llybQmg(TGdzG%5hmZh&H$uBU z$PjM)+LF#{?>DQfJD2#?@04(Bxpz9VOpm{nG&ssc#jy2GT&_fg@=bbL-H^zkM!N9+ zg~YB~@OVmC z_26({e|GSN)TWDP4DE<@gIPi11BOTr8va47zbp~Nx1kH^0m!P0Vbo;!D+g5VzcxV01!|gq_QM=)jvVtSpqcL49CVQ>MQZ^lV zWhL`*tz$mUl#sr!%<}+*6c!iiR^|yWtk2i2pA20)nyXtsRd*bCVQI^lHmxnXtwml9 zy%HLKX8iH7(6qKi&f%vEp`EuvJB#MXxqKgiI$_-6BxM}6LJ z`Kljnnqd;V?#m>`gcPiFw?goW2`MHl2`czRQcg&k1MCtLGR$M-FPo5FUJfRtkN(*v zq_6CRL<(y{R$bPHM>dfJG;@q)nWmSXL>Wa3Tnn2~TvD05 zIG6=3X3~}!zRYcOOsiQ_w^9vH0AOxuk%G1$uPrEO%k$du@uw%B%W2D}wa(dR&_k0a z*bI6|IfHylIyZX1xv{!)kH1LJM@uBW747Z^9!L`-T%C2;ovvK&YITi75)rVSkR=Xj zX%ARk5&%1{gvUM?=8V+5Kx#hz=F{Guh(KQ#34O(@NT4p~n|gudbIT^A6H8{d5RAzN zuW8Q3etv!i6U$%a|JI zw`V1?f+&-_#3MZHzT)a}lNEsTO1OAaRzf(_1v=XY-4*m)S1XK)=PwdFJ)C6-W#bi- zMA04|>N|gWpzjgm5eM2xogV}X>ugn$6yAM9tq+^hfJYE8^AnGLmvHa*ueioNZ&klp zJ@M$ThZ#?E5bf|>O!Ym_ghq_qDw5?uPNQ!`PW=S}jKzo4r=3JUTM2B_X}Q(65!epU zWrPGyBlV)*hRC84AtKQ?Q*v6LAz*ow1T0&*$OZvRWYZ2gk6p9^af~U|LBL9~%}N5n zPc4;#EL@fZ(N8RFERm|kR^BYgo!BsC^bs_`sDe6l7>Z9xIO>m5>-G`YOJF~N0|X8d zI7HwmfldG;Fff?0iikyR6z6J6!D`U-RDTKtv2K%)SON~a9K;f0n6i^CdYlTkNmDxX z9xCw!f#c$tSD_Sx5IU> zb$;&RYnN|^TbQ6?bw0crfwvc5T}+~i#U-MO)uwRaCJ7%}Y=Q_YUWgtbshB`(r3i!H z(Sya}fjz)P6=r*0z8PMd)3!}K#6Uj0t)N+dQ`)v%c&&MR(-xkl)FPEVa6{Y125x0u zTRDFCmbQs~p;y6sR~uTA$?=j&Balp<9~rnXltMU|OpadY8zKoG?!rkhQFar#!RVuMGZ=prg$$T^&9T!x%0=rv^=W^)F#t-lIuUTtJk_U`^RC!RZsu|F7d@=z1P1>F%@YKz=(Cj6uVc>8SL(PpzqH(i z=c^nKahvwVL!*C~2p+W=TAcl(In9bXW@b^}J5)3)YCeWpF%zX z8y>vJALM(C2Zb`1(d9a=rB~gi#*J2ClQ1WmTyG^nq_&eBKVbxb&_VE$A*}u~QWO#6 zprsidu0Ml4gKW~KXT1Y5bjc|sc^CU0KB*b9@W{Q?gg1wV&B@s^)g@b-K!g}hA zp30m{G6Tth(?+PMUxvmjh}Ae~dc$WLA2MQI|1y<*i~!N(jA+*%r;sGpKZ9GlescSa z`pHG0!l3VE)}g6n-@prGl$0Sl(6BDwux?@jQPGp<3fsH#+q-g4oXmA6rW$&4Pd;_C;pwr! zcj^{$P3PO2u5NmF(e+hK-$`<9sXN@pb%)y~o+MiM$r4%^xKcw%ZM?L;aG>2a^!eVt8!A3w4d;$$hhQVMh>YKB%+s1j5v zi4RX)e^kYU6u7X3UH=kZ+vUc-Ay|G7Txvd@N)0!Qb(ZPEx?%yjSn6Lva($e@n*_c} zfJr4zQAp^+Vm`m%)Ab8KbE80T1`P)me-cOe)%gez46k-bMIzz+OlbFzfgtbHXSI04&DkK zydA8y)WHwEwdc(}lk2Z_Fm=dbQimLJBN6^?gbtb-wQq%&Kf2TVt)12T{QiCqTFWo- zLD8qAlW0pjE#f)l^aU#-$!vdNG1s;MUWaWmBB;+vF~KoPeFVDMz}krGb0Ae)Jc9Nu`0)B z0WxXe{NOO7J)ECN6{^K2LIy>4-SI_zWcV!00Q0P2wATDwvc~3_zL1%6kWer4Qvmnl z`{19!PfWHJLY=olouyyFL+mT)qOagldK;f-B%)TC&qJp1uPH6@sLZv~zEi94C9q4yXOF(o zO(IdpRmypbttw(ook6l@ZdGy0P<~7#YcS)VkA!fAL+YfI5X$$%r6WR82sIrx2gwmv zN(xK!LpBtG7Pw44xk)5GAK6gxRuz&BMN4EuHS>@S)$m^PUxyU%SD9R)L6V<_Kb3*? zFzq*F$dws<&qcPttd~t)UOVQyzJ&nkgrrD8-;LW?!(8arc{B0)9;%9vv;GKypP;J3 z4!rKbLTu0(2l9Pp6_XF8(_D94AovyBH3)vPSG5SewAZ#Q zl2FodbvHR8aGQW)2xU}>GOA!ywlJ{}BoVw0z^F_EA-PbIPosT&+&m2G3U#Zi=F)16 zkpg+7{ig&lDbH1o!N+mAsk9 z$zQw;6>_hKde4vlQ1$7*iFr+E*m0%(@%qt!PK1xTN5veqR6laO1X(LHV}`b{8M?Cf zD?f%aw81vs%Gr_-W@HW(UL@WaZ%RZQ%G&6uxxdLMpTGPUWSaFR_5X&q$Pt8m87{CE z+ika2Fdd99NY^HUiCVS>Nh8ny&WPP|@3)y5d9I@TKZb9(rtBLQO4@i+*|fF%T{%?u z(fGpnqD0g&8|vmU8|rw;{8!Ef`mWP~bZyT}5xf z)oWaHq7hhlHI-od29}kb4-LG9U=HPXrd~w(Tp|&B*kePQ1S3G( zDohJCrk?J%*G7~gaPxfZWA#y_*L8qcg?<4)hF>(sZX&DWOK=%KF%y@?^RV5Xu(V~W z)Kc@<^C%=klDwt!uBM2ZX5gV*a;_Dcbd4r6;tG z>tydOZDjRN(d};&xI*A10zXZFUKEU32WL^Elil7&+%&!K_~YHj4xBi!ADfchANZib zhwvKO`<=5?%ZG9R7K5hNpTzT&0a|=!J)r*#8XsG#|15Rn9Rl>?*;cHXl~o=}LX*R& zJe$huKS#I4Y`x(Z&DHnP<4yvP64*yzKY;@T4iY#7U0)umwJ=kYMM$SEOUZ53KE z!^>jH~}UhI8LE%0wgBTKSSUx0!-^4r_c!kpC&Ly;1vSDK!925 z(!0+r0&qwdtnJG9u}pSNlK|eGqIqo0XcC}I-cME5;+0^Me_Px4S2LRhL>dc`mH7xv zP~Up&&BrDe6xxsE+mBq|e|`H@B%bRzaWisq%qv9-tKVLCb=~CAYbTgU;pmu#q6^Wr z`RLk0bVEM6Ve-lA>vB(hA{X5-6@3bn9_lLwyo9s3N8EcE&FoXz8nJKeFe?>o7VQx)@m7HhRMRfio1an z*4-^dV#Kk%)H1cW#Ur-0njWDZ)GP|M2k^*d9~V}%+8F6feC@F)-Bv9W6Q+JoVEp)6cZ=&VeA;U{-&%r5g`IJHddk=+7^Cy zi_)VF^aR@%nPr9*o;WEo*aJ7UEq@VSfW2$>zbS3O*d-Bhqv+0zp4JxJ4lj5;`ngZN ze*9*5V@`W$G60YZZ=4L|!!Vz={-&)sGvX$@GsAc?84&60fhp}FHuYBLwbkS2VBS$G z#G!>9pNe+31_B%B4!9pyA&%IY=V3yWDjH#9^qWH^had^uu!idKS4$ ziT)!q5&hT`K&t-ER_+%VdUY>c|+)T)KM=yWnDVCMErcl$HiiNl7z;`K$Ew zC;>?p@1ZETXyj1Vr_t;VVngs^Gb%E;eOQ(=sbWt49*F>AMN?J&vU7m$9ojcBWw z`mD^0GIo5(H%vTXh9+rQN_UP1u(xgd+Db{*QggKH1I!?;=R<96D`z@-b54e#j0`k) zsJ-wx_#;{~4i7(T3YzsdrL7_InC@=Mpm=4b)ivae$X0KBufxYx#1Aw*H%U*q#0M7Y+w`QEyack_EL0QO?K!I=zo|oR zwdd5VhP`I~IDAdOXKEu&*3M$*)PvBg)0fLH4FTM1cVEQhybtbWDs7%Qcwcy3_*L2( zR>H}tk0(-D7%CNt?nDJVdw>?EtH@dF-0St0sW)hrZik>#t?`4JQJVbDvCiHD1Tj2^U<48KGH)@=RgGSU=Gc4;vs~~xZiL8I z&@{b=-!)UdLu9m3adt#+LLgS+NQBjASm_ER}+ zX(TwHJ~ej+^^l_G(O*}_{HSR`tqmi48+Ss2Eytx)S<9N#{EXOZ=b)cuKKhaz7kD+15A-wbK`obF zs;sNZrWJe>^F$+Q?fcbdu}gKHeHQu9XBbJ8?Z?b2YVhw074-@}%(jwSc%1y5K}8)h z`M-1kD(XMR>|G^u@DNNd*6^^(=wb9vREJpz(+i>Y6s8yQtrIQpxWHqFnc_2AWP0&q zGmoK=n$O(C+b#O?mEWU$KYU#kbJR4wkRz`AE=%)cuGV08b(wq#p*s5^pV#UYT~n>U zSkdiAf6$8l;l2rCL><>h{w8W4d=rpMKKLf=w7`d4>*FC%+;#d5Oi@#RT%gdG2rv=K zCJMC@VA2y3oalc>pq;=L0$TyPOfz4S7ehTdKx)Si?c~|+Q%LP`nz{ zpM2|?H=mhY_0HOFtc8E&-H%U2yI%2)RY(nC%iF83uAZzfa=y$R8AI=7ZvWl#_KvGN zp!u5EVOM)G)fHUxSPCu?;A$>J%%=2m=7d&^b_H9dAt%412Rp+3^5g8Esgac5yV{}))#2TZ5!bg~(kPLS=Ad{YqGu$UHvDApqD5+sVS zMipecV5HO=!s0cI{S23oD1zkdZsxVfUnTn~Yif}xdC1O23Njah9gxW~pMqqw=d;+) za_MdCCy3$s^ur`kWckW|$oDe?*)H=d`%{`9{elv>>^MkPIZ!t3eA!X{O6+fA9e;Q7 zw+uVVET$QW5}7lJQiamhHeW=Ym;^&Z5y8;)R2hQ7K?4x_pDX<^Co)0*3zTTMle3#K z%}ge&H1YlLB}pa-=OpCiI5{$$G9H?hR+u&KIGUGP>Hcs8gTq>>{+*_8G`-uAd+69y zS+Y(Li4 zB9M-BrK^xiEh8mb*l|JbDiD!;)LWbIwpI$sE0DVWN;-gYNk;Fu5}K+6=T@ael@g)- zjw#P&#lj!2E{i0j9Edg|-Tyud8R|UyEb^hxv`@HPpM`~tV|!4T zd5aD^tdt_Bcyr1lJK@Kiqk5QvlhWq&p(dyI9@xixj8o?PP`xfX8EKTFlDc0=A3-LJU`}YEiCoQ zIo8WbQV9vZ1A(@%Qzt(0W)2@yQi5bWBZOXKp029_O zvGHEo?bk(RWvdV?tI^KBl=)gvT^1Xl2WTB z)3anr@(EOs`Fl|3#e&^O@~a=2TKE$r*u5QDREVs~M^+Ug8}pHkg~-l)WasUMCAn2Q zuC0Hs(iPox*HsY>jCsH3bJaG_d6!D^nRlkPTrnK--#oM1L|ERq+^qA+Taeph9c-gX5^)9TS-`{c6bvhjW9M_v(51A z7?@N>-%I&B2{`N%zDjXK3>#ka=tLr9OPr$HNY*b?T6VlOiY4=7lVEl183|w;yKXkY zsMb?245lu!_LT9BPMhD!HsMS*r3>?GBUX&V#0k!&Nq-CN$-E1Ec|RPj!baNM=WNoc zQ_&9WZVopT8e8*?tvADMIc@jkP6qPf-3871o6>gY$QL=?-n50MDYZyt58Tjpf2VYl ziRtrL*@lKSg@%p!hK+@W&H0ASlV@`co2MEc84KKQTw7?|lyBT*T69mom}_jGYTP@9 z!<8C-<@^`U-=s63w@y?ukPmMyXx879wv}!BrQ4gf@HC|ssqBFp+Eyxb_zS}~>6oVW zi3JSg!|esl`kT_)b736DM7K9>;b}@OQrQD|GK^Hs1z3QvDuCbVlstVqnJp5dsQje zSB;9*cYD#j{U9>}oXmjtICbrsvUc)jCrA`FE-UP1n_>01aPlba@BJlJ*+nD+NR!bL zqkHQhMxy@Nr7{8qF}Z+=%NG`z+y`wQXV&oi@)u;B)W6%kwyDz!lFzZ-6zU8ZN{1XO ziJDU}UX!gA3dO<<`u7NkNp1=*1lzns%+ByBp@dK=9@02~R_Wy|ng}PN#T$sBTEnLS zg;)78rY+Zts05o*A_`$X|Fm2x?^{aC5?R+y*()Bxn1~GuLfmLtmdJeSwje}5g#qA#4lyxM8 zpEZZFt(JLkt98umx_QiOmCr7k*k&Xh`!^ z1-55Ql7R9=2rl&eGbFcmBF7ILJ+Kend(`wHeNX1`4)hI~cC+miA`OYnJMYiXEt8lo z)&CELnS@fK5G~#2G3&%*>CFTn0hnbQ@>l686PbD_^Z|jr1Ud;gNXk7FN3sReZ7(x- zd6aJV5!eraQ-L0%@D2j23DAr%{N`xkGzuHN`AvHKEdu|Sz)TdU)Aog^1k*Z}Naqr< zsc`jQ2O=^n4!r2n2&CpT$hV(oQm!q z^WCX$F4V8f*RLzox8>{GZa1vC9bIhOcGs3~Y)>aa&m9{ zAk<+>^gfTt(uEzPDyicCqlxNs1I#Q-{3l97;9dOB5Cvy#jt+Xa+*=Z1@>>;6r`rg0x_XP({LvV$5~!LTsR2X?8b_!X{G5&oRF z(}BfheuWrLl^uOz5B!`xcN{|E~c3{QIENi2c}#|95WzJnU}4RJh_g;X3#%CafE*gv(C!$C)>q z-EJ|U2kC`i^0?y_Md>PuXtkvSuC~&7iqd%<>CjOtov$cemHnczDFh_Oek9j35Gr)4BLG?>Au=Hk#RQy^q>*T}Q~ zB}&a)2KfHpVDR5_`RW4S4Y(F9f6e=z-xaETZNtsrvhlU~VC zSuy@}KGJrtl1G1sN1w2tRlA68Q1PmH{LS>9(xK|PKVhjMx8Yo3S9*PV1?7I61eu`*?zb&Q}a2~feUJ2-n z!yg^=jX--@E$++uK|=(X%v<9$#E?VrSckIl1!#Bcfp1k5gj$}5WmQrSm8It%j_y}E zi5Trs;q1}W3RiDm2d71pszAsV{v*S%g7WC5Z^dyzexo`Z8`OkN5l2FN$EC#OvL=DG zWj5eqal>((Ks{jUz>&-RwIZnqsPY4s)28?``Q+i+aJNt{tvHE*(KBTfsa*K73$3(V z13h>$`Y!WX^oy)VIaX4NLu$u)y-Uz6Mejo058(z*yu-QmL*zq0$Qfg~ zen_QrsBBtEhiOGeAqSY8u=Mb9A!D|cENDG3@m!$JganemJy2&PP4VEr7yrc}=1iti zC#f~Vd%U;nK-}C<%j*$j*k_{eG5;9fR&2yJrI4XiLHlHNjMJT9<~E7F;W1BFq?%V zVC6zPTGP)5UfRfN!Zu|T(Y(j3w;kT$t_CHEVAQM1*2k&GdkJ(BkmP}iG6*^;12$-q z5Qw7))6ttbTDy$k-t?uUdDFh7=&s$oJ2Rr6@5{D#6y0sl(D!Z^x0v)qfYB2p7l%{2 zQEyTXKnf;t$YTEv*)p}jKIF(~#Z=u&exQ-u|9dCAbSk_&r^O55j(ix${>I;V<{Qu8 zCl~IRd}bH^L&t@6Ol$^mtlBc4di@jmER0(&z0d`aT;OBq zhrrVF?1#vQevrJPTt6iCA-6^M|9uE-IM05FeCS6pepkOgff<2u%k^38_-(5y!lKo* zeJD}w&`AVOE#%Y8R3P!@_HkDB z;=H!_^}}Cz?2X4@R}t&X$2za=zqWl^J4(hX%;8^7d+hDqS9jwl7hXTHdn&vKLAVDb zkPkmr(5$~{O0Y-VionL59!O$KRlQ6KHQ#^$2P z=q*S%UgBh7x&{)Ce@TRygyRi9*3dCraC~tBt=hj)917xxgkwSPKRP5F558@?4B~!$ z6F>NGJ6d)f5N<_avs&)k6z2%z;G1%3g%xRA@wXO%t!9Jb4BWYYYr;0uMy5HxHF6zY z1F6MVnB0G%O={ukI^JbTEaoHjXH=TL7-W#X1fWabj_-&OaZI4mak)q#0HFel-fnKJ zS&AIc@taBkNEj@o075@-rNFLYaR;;!`Z8)=tx{H@kmobOgua3XqJ;nnCG=GQc0vD6 zeG|plB^q0cgc;k4?sjB|9W0V+z-EApHvCMqF--2l>KtMXiX;Qh%=b}UiHu)tAm%du zC%BCN2~)=Zgp~0=aeaGE1IUM;C}`H-w1xx_r^^^KOj3O-?uPaRzq>`LA>*efEGK0A zMLCgxr1~-^zoQ3`@jDZfR9`0McdQz#QK;tnc9F;tKzC;B_cd(U`Qht4y2E>EANpB) ziHD}B#;%tTnv9?Hs44JtB{r4Xb67~xafE{hsl3)B+mG5_Pi;#$%YK)6+7iM}p_1=b zVU~mntTZ||Zoo5(PNa-1JzmSWfx04Yp!m21rKDS-k(V{8+3}e)@-m-Vnl18yf-Kf@ zsMAY|p6F&FU{dXAa&R>!MK9ZXo!1P)AaaUUtP_bs zCsHq|3%LVBUr2ayBMLTm9kfztR>jCH5}~ixD1iEIuLo8^lsFe*$1GKL9HUS#SW+~=6Wz& zsi~i}Emmqa4x5^d!&0+x_*zp=1IULD7c}c{T0;UT6l9DUCS8UVcSAeO?`~1*_2P*c zKqTohWNLm#51`p7DjIR5%aDor9V^;u2pV;*NhERv&;`>(@AG)AU9oDV5#VZi$87|N z!qy)l&`Drs!@wqbOyF-(Zie>xS$hd8yglysHIMh0`~9E^E4^{|y?_^Iuf69YXtsy) z_9L6PaL=y0TkvBy8H~Fvlfep@26~vWpqCj7`r_Vf70d)-E{LzP$~s()X~a5*2aa1p z)XX5WPP8NcYSe&x?QRfEjZ(8SS8~Cl64BwjsLF1L0-e_k1cO&)4rnJMu-TQAfb5dy zWa+AB-KE|oAP6v#lpq$=x*DaiAp(%MhlkDPa&W>`)$WK3ZlT? zB5PLG^}b{iPuVxXt4Ci5siHSd*(2lxB9e9S?wrXtI&^M9UkML#bi5gW9Mz z30w1YXGYj>Mgn~&0kU)Ia+(ouqbDRaJi&y5kOyuiD<@bZnc_eMY{tLLYPd*NM}|u7 z%a6^F2eQOgZXlerA67JrnJ|j$n5DQE1~Y?jwsYb9>69+*h|vuF*U$@O-a1R>!3;kX zXaIQ!Gt45Hx=<^%+xlS|lCKaLCvcR&L82u0QmB)_tOm$NwXMrW(B5D?k}bg2s2SNE zwnhFBRr(6m_c#H@_?)CrH-Rw%aRNOAP5@+Row8eD5eH-%C9h`UN^^EFPobHl={6J| zh!`=-ri}40aX_6V)TwE_TrPJ2?age%Hj`QTp>UJw&~nUkyKzaOF_v$PO{{x+!_^Iw z*>^5};YuB9bWW$`{!`d>&`+rmJi1ec>9^Fu+H8&@k~Ap zzRCKV(st&;G4uAOwsQ;uT6!{Vixz2bA@h^s?uNXh2N1)`Qbic$h?cZ* zP5y!&gXc$#kmv;txLPBh^U&zG<@>_v3F~;g{7r|St$-t=a3Vqq|8z7F ze+NuZ`E?6qr)CMRT9|Dz`?8=mnC3 zK1e(MLAb;!%*_Fe@?sKlMt-$omYe5!c2G!=%U${rWU?KewH1$cDf4##b0xwZ+bZhc zZo1ku*)h3hTHEE^av`2wOXal1)7r5+jjIZc8}p4D3yoXzja%P+GS|3us__Z(!nvpr zZqAc$q_yyZG!u|=Kt8;-pjm%Y^teUbion<>Mev5UmfziCg_%#LZ8;Gh2lXn8 z6=6JKO<9=_ugq!3#=8J=;g#cEQ{k-$7Q#D0u(P7T8iIB@lG8SpqFLq>cu<|s)Ckym zq)spC&te%p@%D~O(q;4LhcGLiXFo(fi;gbWZ$VW$WWO`XBj>&&LbbhKXasDhKq>f$ z(Ths`z#A}LeFA>dA-kXpT7+g6C<1mmI9&t1mSx~dE4i8a5J`uD`&1@$UKMu28?x2~NMnRCk;+N( zgVn|560_LUo0pjpayAR!@J7tBYDor}4du(mkFj za@dRp*;I5~60w7#$pxvYvtzd;Wzf5b?Ie4D{WyVc0z?3EMcfShyG<)4xpM{k55}H( zB0zEPd}Q$g3NytE2$T^ooF@0t8Hf~22>`?lyv2K{84x3EY^HPh*c~bDkW?WUVe*Ek zP2RAT#%G)c?l6V+QmB)_tXe#CUycN|zcyr?NSuu%)E!6q!cwdn&PjaIsLguu!)q zU$|o?6_B0Qn!blHAa? z@Vi^8)QB7}O3m-+0XS<}nFyoEJJ{I}UJH3bPJ5~lZiV+{1ajflceUy8F%}nxcO(`U zp9(`|^;GW3r%ad+KUF{`_IHyqK`$j^%xs#m8-7tC>{olg{>v!ltyRN<=r<`ut$H)FX!XZ#O^n zE6qz%vAXiN57uCh$do45D0nnWkOePmc+ZMQ4V1P|N64^KN;+Uh_WgzE@x6 zeHwPA1kH*mc|TH$3-|0QabxYYGqv1Un+M=E&}y(hb*AsaQ1*o0Hw;S&IG7(?N2OaN zEGv}^HL(BDO zr%-I7-E`rUn-5c`*Op6Fr%<+!ltHD4>26FBtXtdM9rV^%| zMC{@OA&hy|C*_cPW+B%2p*krih?a%;xG-^{LV1+dPBmLyb#=G!ko+BeL#5apok#?j zIPrHl(F?H*C?|*$g?dw^#$>0{(8(zadp~1SLgLhzQ|o!1-J}|FTuL#Vtf|$J9JjNG z6J0BmClR2tf(&J;V-85I4X#S(^N@ ze<|Jsb>pJTfs1E04VVkFQDv>r`T)u>eUj4$t&_;!+^Pc>4Ze&|#5_7Fzx9O#Nbq0; zU_NFQqtmN2GGW0V%>+nbVk4`ih1{F;;Hw0V64*n4)%pm9exJZz0-XdNC9sdcegX#w z93*fEpy)6@5|^2N!&g%1>jX9t_%Z=WWp~jnBelCJ^aOx)^d9Nl_+^^Gbubdz8p32& z;7Brw8%SO}mm0=Spk)8Z@Bl-dNqGJ2KW7|O;#JcqZk}bufQvKl0Dum!TU@AHnJ4$3 z>+^N%Cxh2^b48dF{}2Z-1e8DBn9&=+*PRdM>hIDv}xV z-l97CgKX_y03|NideobHnINgO;4;h7)gU8|nDh?CG$hF?S(RaTt&-4T6f zGz|`)(fcx&hO_5dGw1sB)Ie(rRge^L_gBhJWC>zTvYY$$`^G!0ZEyt~xXLV)lZbGTjia zqk>*1kR$L<1c;7iySPl!nhoIR>A@NTs{jn`VKn5zQ0ftV6p8WY%6tvry-K&+{TD9R z&$)g!^w+MAZ@aeNtEh5U+;aiESL>>&fAyJHo+*S{@}ZV1f%kl_3hmR&etOw=k*5A` zrK=+H9)<4JuW+}1*9CCzuzSDTz4^N?g7>;AT5$r5 d7Q2ri4Z(X4FL6JOFu{9|R3qVC7eUsI{}0x*oB{v< diff --git a/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index eee229742cf9e39db382585fa08444be4ade1374..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18581 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 GzyE(ps~!vh diff --git a/tests/test_workflows/test_views.py b/tests/test_workflows/test_views.py index 743be54..693b4a7 100644 --- a/tests/test_workflows/test_views.py +++ b/tests/test_workflows/test_views.py @@ -11,12 +11,12 @@ from infrasynth.workflows.models import ( pytestmark = pytest.mark.django_db -DEFINITIONS_URL = "/api/workflows/definitions/" -INSTANCES_URL = "/api/workflows/instances/" -NODES_URL = "/api/workflows/nodes/" -TRANSITIONS_URL = "/api/workflows/transitions/" -ASSIGNMENTS_URL = "/api/workflows/assignments/" -OBSERVERS_URL = "/api/workflows/observers/" +DEFINITIONS_URL = "/api/v1/workflows/definitions/" +INSTANCES_URL = "/api/v1/workflows/instances/" +NODES_URL = "/api/v1/workflows/nodes/" +TRANSITIONS_URL = "/api/v1/workflows/transitions/" +ASSIGNMENTS_URL = "/api/v1/workflows/assignments/" +OBSERVERS_URL = "/api/v1/workflows/observers/" @pytest.fixture @@ -128,7 +128,22 @@ class TestWorkflowInstanceViewSet: response = authenticated_client.post(f"{INSTANCES_URL}{instance.pk}/submit/", {}, format="json") assert response.status_code == 400 - def test_assign_action(self, authenticated_client, workflow_graph, user): + def test_assign_action(self, authenticated_client, workflow_graph, user, admin_user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + response = authenticated_client.post( + f"{INSTANCES_URL}{instance.pk}/assign/", + { + "node_id": instance.current_node_id, + "users": [admin_user.id], + }, + format="json", + ) + assert response.status_code == 201 + assert len(response.data) == 1 + + def test_assign_self_rejected_by_default(self, authenticated_client, workflow_graph, user): from infrasynth.workflows.engine import WorkflowEngine instance = WorkflowEngine().start("approval", owner=user) @@ -140,8 +155,7 @@ class TestWorkflowInstanceViewSet: }, format="json", ) - assert response.status_code == 201 - assert len(response.data) == 1 + assert response.status_code == 400 def test_observers_add_and_delete(self, authenticated_client, workflow_graph, user, admin_user): from infrasynth.workflows.engine import WorkflowEngine