diff --git a/.coverage b/.coverage deleted file mode 100644 index 453678a..0000000 Binary files a/.coverage and /dev/null differ 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 62be409..0000000 Binary files a/config/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/config/__pycache__/celery.cpython-314.pyc b/config/__pycache__/celery.cpython-314.pyc deleted file mode 100644 index 5de0edc..0000000 Binary files a/config/__pycache__/celery.cpython-314.pyc and /dev/null differ diff --git a/config/__pycache__/urls.cpython-314.pyc b/config/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 721a8e1..0000000 Binary files a/config/__pycache__/urls.cpython-314.pyc and /dev/null differ 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 283ada4..0000000 Binary files a/config/settings/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/config/settings/__pycache__/base.cpython-314.pyc b/config/settings/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 8fe5348..0000000 Binary files a/config/settings/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/config/settings/__pycache__/dev.cpython-314.pyc b/config/settings/__pycache__/dev.cpython-314.pyc deleted file mode 100644 index c13c9df..0000000 Binary files a/config/settings/__pycache__/dev.cpython-314.pyc and /dev/null differ diff --git a/config/settings/__pycache__/test.cpython-314.pyc b/config/settings/__pycache__/test.cpython-314.pyc deleted file mode 100644 index 2bc457d..0000000 Binary files a/config/settings/__pycache__/test.cpython-314.pyc and /dev/null differ 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 40be78b..0000000 Binary files a/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png and /dev/null differ diff --git a/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png b/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png deleted file mode 100644 index 40be78b..0000000 Binary files a/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png and /dev/null differ 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 40be78b..0000000 Binary files a/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png and /dev/null differ 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 40be78b..0000000 Binary files a/docs/569c1d2348dd42348e16221e92268ded/img.png and /dev/null differ 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 40be78b..0000000 Binary files a/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png and /dev/null differ diff --git a/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png b/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png deleted file mode 100644 index 40be78b..0000000 Binary files a/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png and /dev/null differ diff --git a/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png b/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png deleted file mode 100644 index 40be78b..0000000 Binary files a/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png and /dev/null differ diff --git a/docs/98fd1b7317fc46668f945560910d0d77/img.png b/docs/98fd1b7317fc46668f945560910d0d77/img.png deleted file mode 100644 index 40be78b..0000000 Binary files a/docs/98fd1b7317fc46668f945560910d0d77/img.png and /dev/null differ 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 beab4b0..0000000 Binary files a/infrasynth/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 e4aa24d..0000000 Binary files a/infrasynth/audit/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/apps.cpython-314.pyc b/infrasynth/audit/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index fcd9ef5..0000000 Binary files a/infrasynth/audit/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/filters.cpython-314.pyc b/infrasynth/audit/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index d678554..0000000 Binary files a/infrasynth/audit/__pycache__/filters.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/middleware.cpython-314.pyc b/infrasynth/audit/__pycache__/middleware.cpython-314.pyc deleted file mode 100644 index 30d57f6..0000000 Binary files a/infrasynth/audit/__pycache__/middleware.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/models.cpython-314.pyc b/infrasynth/audit/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 1c8f79c..0000000 Binary files a/infrasynth/audit/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/receivers.cpython-314.pyc b/infrasynth/audit/__pycache__/receivers.cpython-314.pyc deleted file mode 100644 index 102b481..0000000 Binary files a/infrasynth/audit/__pycache__/receivers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/serializers.cpython-314.pyc b/infrasynth/audit/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 4781714..0000000 Binary files a/infrasynth/audit/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/signals.cpython-314.pyc b/infrasynth/audit/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index 64dc20b..0000000 Binary files a/infrasynth/audit/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/urls.cpython-314.pyc b/infrasynth/audit/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 6f7cf18..0000000 Binary files a/infrasynth/audit/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/audit/__pycache__/views.cpython-314.pyc b/infrasynth/audit/__pycache__/views.cpython-314.pyc deleted file mode 100644 index b10b7ff..0000000 Binary files a/infrasynth/audit/__pycache__/views.cpython-314.pyc and /dev/null differ 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 62bca94..0000000 Binary files a/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 07a141d..0000000 Binary files a/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 6ec94ae..0000000 Binary files a/infrasynth/billing/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/apps.cpython-314.pyc b/infrasynth/billing/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index ffca14b..0000000 Binary files a/infrasynth/billing/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/filters.cpython-314.pyc b/infrasynth/billing/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 14452e2..0000000 Binary files a/infrasynth/billing/__pycache__/filters.cpython-314.pyc and /dev/null differ 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 20b41ba..0000000 Binary files a/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/models.cpython-314.pyc b/infrasynth/billing/__pycache__/models.cpython-314.pyc deleted file mode 100644 index a23d431..0000000 Binary files a/infrasynth/billing/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/serializers.cpython-314.pyc b/infrasynth/billing/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index f74a747..0000000 Binary files a/infrasynth/billing/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/services.cpython-314.pyc b/infrasynth/billing/__pycache__/services.cpython-314.pyc deleted file mode 100644 index f76e46b..0000000 Binary files a/infrasynth/billing/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/signals.cpython-314.pyc b/infrasynth/billing/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f503896..0000000 Binary files a/infrasynth/billing/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/urls.cpython-314.pyc b/infrasynth/billing/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 413e011..0000000 Binary files a/infrasynth/billing/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/__pycache__/views.cpython-314.pyc b/infrasynth/billing/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 9802bc5..0000000 Binary files a/infrasynth/billing/__pycache__/views.cpython-314.pyc and /dev/null differ 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 00f925f..0000000 Binary files a/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc deleted file mode 100644 index d2c0caf..0000000 Binary files a/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc deleted file mode 100644 index 2c04963..0000000 Binary files a/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc deleted file mode 100644 index 2cda5e1..0000000 Binary files a/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc deleted file mode 100644 index 95cd5d7..0000000 Binary files a/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/billing/gateways/base.py b/infrasynth/billing/gateways/base.py index 1d176af..0965479 100644 --- a/infrasynth/billing/gateways/base.py +++ b/infrasynth/billing/gateways/base.py @@ -18,7 +18,7 @@ class WebhookResult: class BasePaymentGateway(ABC): @abstractmethod - def create_checkout_session(self, plan, user, **kwargs) -> 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 7a5f2da..0000000 Binary files a/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 9332614..0000000 Binary files a/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 723ebce..0000000 Binary files a/infrasynth/features/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/apps.cpython-314.pyc b/infrasynth/features/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 771a9f3..0000000 Binary files a/infrasynth/features/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/decorators.cpython-314.pyc b/infrasynth/features/__pycache__/decorators.cpython-314.pyc deleted file mode 100644 index bfb62bd..0000000 Binary files a/infrasynth/features/__pycache__/decorators.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/models.cpython-314.pyc b/infrasynth/features/__pycache__/models.cpython-314.pyc deleted file mode 100644 index dcfb383..0000000 Binary files a/infrasynth/features/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/registry.cpython-314.pyc b/infrasynth/features/__pycache__/registry.cpython-314.pyc deleted file mode 100644 index 3ee7df2..0000000 Binary files a/infrasynth/features/__pycache__/registry.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/serializers.cpython-314.pyc b/infrasynth/features/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 987a449..0000000 Binary files a/infrasynth/features/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/services.cpython-314.pyc b/infrasynth/features/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 2300c2e..0000000 Binary files a/infrasynth/features/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/urls.cpython-314.pyc b/infrasynth/features/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index fbfe8e1..0000000 Binary files a/infrasynth/features/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/__pycache__/views.cpython-314.pyc b/infrasynth/features/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 2fd36a6..0000000 Binary files a/infrasynth/features/__pycache__/views.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/apps.py b/infrasynth/features/apps.py index 96e9b05..4820333 100644 --- a/infrasynth/features/apps.py +++ b/infrasynth/features/apps.py @@ -7,6 +7,8 @@ class FeaturesConfig(AppConfig): label = "infrasynth_features" def ready(self): + from infrasynth.shared.settings_utils import get_setting + from .registry import FeatureRegistry FeatureRegistry.register( @@ -16,3 +18,7 @@ class FeaturesConfig(AppConfig): default=True, category="system", ) + if get_setting("INFRASYNTH_FEATURES", "AUTO_REGISTER_FROM_SETTINGS", True): + for slug, definition in (get_setting("INFRASYNTH_FEATURES", "FLAGS", {}) or {}).items(): + definition = dict(definition or {}) + FeatureRegistry.register(slug, **definition) diff --git a/infrasynth/features/migrations/0001_initial.py b/infrasynth/features/migrations/0001_initial.py index 787e1bf..5f11f7e 100644 --- a/infrasynth/features/migrations/0001_initial.py +++ b/infrasynth/features/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 @@ -10,6 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ("auth", "0012_alter_user_first_name_max_length"), + ("tenancy", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -26,17 +27,22 @@ class Migration(migrations.Migration): "rollout_percentage", models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = all users"), ), - ( - "tenant_id", - models.UUIDField(blank=True, help_text="Null = global. Value = tenant-specific", null=True), - ), ("environments", models.JSONField(default=list, help_text='["production", "staging"] or [] = all')), ("category", models.CharField(blank=True, help_text="Grouping for UI", max_length=50)), ("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": "features_flag", - "unique_together": {("slug", "tenant_id")}, }, ), migrations.CreateModel( @@ -62,6 +68,15 @@ class Migration(migrations.Migration): to="auth.group", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "user", models.ForeignKey( @@ -75,7 +90,24 @@ class Migration(migrations.Migration): ], options={ "db_table": "features_override", - "unique_together": {("flag", "group"), ("flag", "user")}, }, ), + migrations.AddConstraint( + model_name="featureflag", + constraint=models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_feature_flag_slug_per_tenant"), + ), + migrations.AddConstraint( + model_name="featureflag", + constraint=models.UniqueConstraint( + condition=models.Q(("tenant__isnull", True)), fields=("slug",), name="uniq_global_feature_flag_slug" + ), + ), + migrations.AddConstraint( + model_name="featureflagoverride", + constraint=models.UniqueConstraint(fields=("flag", "user"), name="uniq_override_flag_user"), + ), + migrations.AddConstraint( + model_name="featureflagoverride", + constraint=models.UniqueConstraint(fields=("flag", "group"), name="uniq_override_flag_group"), + ), ] diff --git a/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index e660de4..0000000 Binary files a/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 b24df67..0000000 Binary files a/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/features/models.py b/infrasynth/features/models.py index be579ce..80ca715 100644 --- a/infrasynth/features/models.py +++ b/infrasynth/features/models.py @@ -1,24 +1,35 @@ from django.conf import settings from django.db import models +from django.db.models import Q + +from infrasynth.tenancy.mixins import GlobalOrTenantModel, TenantOwnedModel -class FeatureFlag(models.Model): +class FeatureFlag(GlobalOrTenantModel): + """An operational toggle. ``tenant IS NULL`` is the global default; a tenant row overrides it.""" + slug = models.SlugField(max_length=100) name = models.CharField(max_length=200) description = models.TextField(blank=True) is_active = models.BooleanField(default=False) rollout_percentage = models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = all users") - tenant_id = models.UUIDField(null=True, blank=True, help_text="Null = global. Value = tenant-specific") environments = models.JSONField(default=list, help_text='["production", "staging"] or [] = all') category = models.CharField(max_length=50, blank=True, help_text="Grouping for UI") metadata = models.JSONField(default=dict) class Meta: db_table = "features_flag" - unique_together = [("slug", "tenant_id")] + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_feature_flag_slug_per_tenant"), + models.UniqueConstraint( + fields=["slug"], + condition=Q(tenant__isnull=True), + name="uniq_global_feature_flag_slug", + ), + ] -class FeatureFlagOverride(models.Model): +class FeatureFlagOverride(TenantOwnedModel): flag = models.ForeignKey(FeatureFlag, on_delete=models.CASCADE, related_name="overrides") user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -38,4 +49,7 @@ class FeatureFlagOverride(models.Model): class Meta: db_table = "features_override" - unique_together = [("flag", "user"), ("flag", "group")] + constraints = [ + models.UniqueConstraint(fields=["flag", "user"], name="uniq_override_flag_user"), + models.UniqueConstraint(fields=["flag", "group"], name="uniq_override_flag_group"), + ] diff --git a/infrasynth/features/services.py b/infrasynth/features/services.py index 69ed614..84aa641 100644 --- a/infrasynth/features/services.py +++ b/infrasynth/features/services.py @@ -1,5 +1,6 @@ +import hashlib + from django.conf import settings -from django.core.cache import cache from django.db.models import Q from .models import FeatureFlag, FeatureFlagOverride @@ -7,20 +8,26 @@ from .registry import FeatureRegistry class FeatureService: - """Evaluates feature flags with caching and multi-tenant support.""" + """Evaluates operational feature flags with tenant/user overrides and caching.""" def is_enabled( self, slug: str, *, user=None, - tenant_id: str | None = None, + tenant_id=None, default: bool | None = None, ttl_seconds: int = 60, ) -> 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 a9916d9..0000000 Binary files a/infrasynth/files/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/apps.cpython-314.pyc b/infrasynth/files/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 57cc910..0000000 Binary files a/infrasynth/files/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/models.cpython-314.pyc b/infrasynth/files/__pycache__/models.cpython-314.pyc deleted file mode 100644 index eb5064e..0000000 Binary files a/infrasynth/files/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/processing.cpython-314.pyc b/infrasynth/files/__pycache__/processing.cpython-314.pyc deleted file mode 100644 index 0e12b92..0000000 Binary files a/infrasynth/files/__pycache__/processing.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/serializers.cpython-314.pyc b/infrasynth/files/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index ef64b45..0000000 Binary files a/infrasynth/files/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/services.cpython-314.pyc b/infrasynth/files/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 215a030..0000000 Binary files a/infrasynth/files/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/signals.cpython-314.pyc b/infrasynth/files/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index db5c027..0000000 Binary files a/infrasynth/files/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/storage.cpython-314.pyc b/infrasynth/files/__pycache__/storage.cpython-314.pyc deleted file mode 100644 index c420315..0000000 Binary files a/infrasynth/files/__pycache__/storage.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/urls.cpython-314.pyc b/infrasynth/files/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 1b7a0db..0000000 Binary files a/infrasynth/files/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/__pycache__/views.cpython-314.pyc b/infrasynth/files/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 375843d..0000000 Binary files a/infrasynth/files/__pycache__/views.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/files/migrations/0001_initial.py b/infrasynth/files/migrations/0001_initial.py index 2e31b14..4d7ff7b 100644 --- a/infrasynth/files/migrations/0001_initial.py +++ b/infrasynth/files/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), ] @@ -16,7 +17,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name="FileCategory", fields=[ - ("slug", models.SlugField(max_length=100, primary_key=True, serialize=False)), + ("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)), ("description", models.TextField(blank=True)), ("storage_path", models.CharField(max_length=500)), @@ -24,6 +26,15 @@ class Migration(migrations.Migration): ("max_size_bytes", models.BigIntegerField(blank=True, null=True)), ("is_active", models.BooleanField(default=True)), ("storage_backend_override", models.CharField(blank=True, max_length=50)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "files_category", @@ -34,9 +45,19 @@ class Migration(migrations.Migration): fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("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)), + ( + "tenant", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ], options={ "db_table": "files_pipeline", @@ -65,6 +86,15 @@ class Migration(migrations.Migration): to="infrasynth_files.filecategory", ), ), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "uploaded_by", models.ForeignKey( @@ -100,6 +130,15 @@ class Migration(migrations.Migration): ("started_at", models.DateTimeField(blank=True, null=True)), ("completed_at", models.DateTimeField(blank=True, null=True)), ("error", models.TextField(blank=True)), + ( + "tenant", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="tenancy.tenant", + ), + ), ( "pipeline", models.ForeignKey( @@ -133,4 +172,26 @@ class Migration(migrations.Migration): "db_table": "files_pipeline_execution", }, ), + migrations.AddConstraint( + model_name="filecategory", + constraint=models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_file_category_slug_per_tenant"), + ), + migrations.AddConstraint( + model_name="processingpipeline", + constraint=models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_pipeline_slug_per_tenant"), + ), + migrations.AddConstraint( + model_name="processingpipeline", + constraint=models.UniqueConstraint( + condition=models.Q(("tenant__isnull", True)), fields=("slug",), name="uniq_global_pipeline_slug" + ), + ), + migrations.AddIndex( + model_name="storedfile", + index=models.Index(fields=["tenant_id", "created_at"], name="files_store_tenant__340374_idx"), + ), + migrations.AddIndex( + model_name="pipelineexecution", + index=models.Index(fields=["tenant_id", "status"], name="files_pipel_tenant__c309ab_idx"), + ), ] diff --git a/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index d81aeb0..0000000 Binary files a/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 e6e14ca..0000000 Binary files a/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 116ba5b..0000000 Binary files a/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/apps.cpython-314.pyc b/infrasynth/notifications/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 6d4faa0..0000000 Binary files a/infrasynth/notifications/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/filters.cpython-314.pyc b/infrasynth/notifications/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 7b1856e..0000000 Binary files a/infrasynth/notifications/__pycache__/filters.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/models.cpython-314.pyc b/infrasynth/notifications/__pycache__/models.cpython-314.pyc deleted file mode 100644 index f42f9a7..0000000 Binary files a/infrasynth/notifications/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc b/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc deleted file mode 100644 index 7bfea30..0000000 Binary files a/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc b/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 4c0e159..0000000 Binary files a/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/services.cpython-314.pyc b/infrasynth/notifications/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 6d363e8..0000000 Binary files a/infrasynth/notifications/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/signals.cpython-314.pyc b/infrasynth/notifications/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index ff174a4..0000000 Binary files a/infrasynth/notifications/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc b/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc deleted file mode 100644 index 7ef8bca..0000000 Binary files a/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/urls.cpython-314.pyc b/infrasynth/notifications/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index f5d233a..0000000 Binary files a/infrasynth/notifications/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/__pycache__/views.cpython-314.pyc b/infrasynth/notifications/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 24f36f2..0000000 Binary files a/infrasynth/notifications/__pycache__/views.cpython-314.pyc and /dev/null differ 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 b321336..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 50c6bdc..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc deleted file mode 100644 index c653adc..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc and /dev/null differ 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 2b038dd..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc deleted file mode 100644 index b1010fb..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc and /dev/null differ 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 7505cb3..0000000 Binary files a/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc and /dev/null differ 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 eea71bf..0000000 Binary files a/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 8fe2832..0000000 Binary files a/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 b0cb710..0000000 Binary files a/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc b/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index bead607..0000000 Binary files a/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc b/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 2bad126..0000000 Binary files a/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/models.cpython-314.pyc b/infrasynth/scheduler/__pycache__/models.cpython-314.pyc deleted file mode 100644 index b33472f..0000000 Binary files a/infrasynth/scheduler/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc b/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index ddb9bdb..0000000 Binary files a/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/services.cpython-314.pyc b/infrasynth/scheduler/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 4095f95..0000000 Binary files a/infrasynth/scheduler/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc b/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f517a08..0000000 Binary files a/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc b/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index c19e55d..0000000 Binary files a/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/__pycache__/views.cpython-314.pyc b/infrasynth/scheduler/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 410cb4e..0000000 Binary files a/infrasynth/scheduler/__pycache__/views.cpython-314.pyc and /dev/null differ 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 fe0f7c4..0000000 Binary files a/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 606cba2..0000000 Binary files a/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/scheduler/models.py b/infrasynth/scheduler/models.py index c40a383..b801952 100644 --- a/infrasynth/scheduler/models.py +++ b/infrasynth/scheduler/models.py @@ -1,14 +1,16 @@ from django.db import models +from infrasynth.tenancy.mixins import TenantOwnedModel -class ScheduledTask(models.Model): + +class ScheduledTask(TenantOwnedModel): class ScheduleType(models.TextChoices): CRON = "cron", "Cron" INTERVAL = "interval", "Interval" DATE = "date", "Date" MANUAL = "manual", "Manual" - name = models.CharField(max_length=200, unique=True) + name = models.CharField(max_length=200) task_path = models.CharField(max_length=500) schedule_type = models.CharField(max_length=20, choices=ScheduleType.choices) schedule_config = models.JSONField(default=dict) @@ -20,12 +22,13 @@ class ScheduledTask(models.Model): class Meta: db_table = "scheduler_task" + constraints = [models.UniqueConstraint(fields=["tenant", "name"], name="uniq_scheduled_task_name_per_tenant")] def __str__(self): return self.name -class TaskExecution(models.Model): +class TaskExecution(TenantOwnedModel): class Status(models.TextChoices): PENDING = "pending", "Pending" RUNNING = "running", "Running" @@ -44,6 +47,7 @@ class TaskExecution(models.Model): class Meta: db_table = "scheduler_execution" ordering = ["-started_at"] + indexes = [models.Index(fields=["tenant_id", "status"])] def __str__(self): return f"{self.task.name}#{self.pk}" diff --git a/infrasynth/scheduler/services.py b/infrasynth/scheduler/services.py index 8628f64..64dac6f 100644 --- a/infrasynth/scheduler/services.py +++ b/infrasynth/scheduler/services.py @@ -22,6 +22,7 @@ class TaskService: status=TaskExecution.Status.PENDING, started_at=timezone.now(), ) + self._prune_history(task) try: task_func = import_string(task.task_path) @@ -44,6 +45,23 @@ class TaskService: kwargs = dict(task.kwargs or {}) queue = task.queue or get_setting("INFRASYNTH_SCHEDULER", "DEFAULT_QUEUE", "default") + from infrasynth.tenancy.context import tenant_context + + with tenant_context(task.tenant): + return self._invoke(task, task_func, execution, args, kwargs, queue) + + def _invoke(self, task, task_func, execution, args, kwargs, queue): + import inspect + + def accepts_tenant_id(func) -> 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 15e08f2..0000000 Binary files a/infrasynth/security/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/apps.cpython-314.pyc b/infrasynth/security/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 7038025..0000000 Binary files a/infrasynth/security/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/models.cpython-314.pyc b/infrasynth/security/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 80ca907..0000000 Binary files a/infrasynth/security/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/permissions.cpython-314.pyc b/infrasynth/security/__pycache__/permissions.cpython-314.pyc deleted file mode 100644 index 479369e..0000000 Binary files a/infrasynth/security/__pycache__/permissions.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/serializers.cpython-314.pyc b/infrasynth/security/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 794ef72..0000000 Binary files a/infrasynth/security/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/services.cpython-314.pyc b/infrasynth/security/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 027828c..0000000 Binary files a/infrasynth/security/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/signals.cpython-314.pyc b/infrasynth/security/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index 056b378..0000000 Binary files a/infrasynth/security/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/urls.cpython-314.pyc b/infrasynth/security/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 66c804b..0000000 Binary files a/infrasynth/security/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/__pycache__/views.cpython-314.pyc b/infrasynth/security/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 81b2c58..0000000 Binary files a/infrasynth/security/__pycache__/views.cpython-314.pyc and /dev/null differ 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 3bbbf07..0000000 Binary files a/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc b/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc deleted file mode 100644 index 06a4c9a..0000000 Binary files a/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/altcha/services.py b/infrasynth/security/altcha/services.py index 005d525..a23864f 100644 --- a/infrasynth/security/altcha/services.py +++ b/infrasynth/security/altcha/services.py @@ -10,11 +10,17 @@ from ..models import ALTCHAChallenge class ALTCHAService: - """Proof-of-work challenge for anti-spam protection.""" + """Self-hosted proof-of-work challenge (anti-spam). + + The client must find an integer ``number`` such that the SHA-256 of + ``salt + number`` is divisible by ``difficulty``. Expected work is + ``difficulty`` hashes, so the default of 10 000 is a strong-enough speed + bump while staying cheap for a real browser. + """ def create_challenge(self) -> 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 cad0537..0000000 Binary files a/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 5889f93..0000000 Binary files a/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc and /dev/null differ 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 02ba1f4..0000000 Binary files a/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc and /dev/null differ 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 ab8d5e3..0000000 Binary files a/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc and /dev/null differ 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 f557bf7..0000000 Binary files a/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc and /dev/null differ 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 02ef98b..0000000 Binary files a/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc deleted file mode 100644 index 695fb5e..0000000 Binary files a/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc and /dev/null differ 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 f70c174..0000000 Binary files a/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 42b4661..0000000 Binary files a/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc deleted file mode 100644 index fafdb2c..0000000 Binary files a/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc and /dev/null differ 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 b1223c5..0000000 Binary files a/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc and /dev/null differ 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 97d886b..0000000 Binary files a/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc and /dev/null differ 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 0a688a9..0000000 Binary files a/infrasynth/shared/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/shared/__pycache__/crypto.cpython-314.pyc b/infrasynth/shared/__pycache__/crypto.cpython-314.pyc deleted file mode 100644 index b4a3a4d..0000000 Binary files a/infrasynth/shared/__pycache__/crypto.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/shared/__pycache__/enums.cpython-314.pyc b/infrasynth/shared/__pycache__/enums.cpython-314.pyc deleted file mode 100644 index bf643ae..0000000 Binary files a/infrasynth/shared/__pycache__/enums.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/shared/__pycache__/protocols.cpython-314.pyc b/infrasynth/shared/__pycache__/protocols.cpython-314.pyc deleted file mode 100644 index d593ac1..0000000 Binary files a/infrasynth/shared/__pycache__/protocols.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/shared/__pycache__/results.cpython-314.pyc b/infrasynth/shared/__pycache__/results.cpython-314.pyc deleted file mode 100644 index 6030677..0000000 Binary files a/infrasynth/shared/__pycache__/results.cpython-314.pyc and /dev/null differ 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 662bf04..0000000 Binary files a/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc and /dev/null differ 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 3a53e22..0000000 Binary files a/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc b/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index ce01c97..0000000 Binary files a/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc b/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc deleted file mode 100644 index 849ab05..0000000 Binary files a/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc b/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index da1dde2..0000000 Binary files a/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/models.cpython-314.pyc b/infrasynth/webhooks/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 56dc55b..0000000 Binary files a/infrasynth/webhooks/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc b/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc deleted file mode 100644 index d040d4c..0000000 Binary files a/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc b/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 563e021..0000000 Binary files a/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index f8288d8..0000000 Binary files a/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc deleted file mode 100644 index cf9e56c..0000000 Binary files a/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc b/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index d8225be..0000000 Binary files a/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/__pycache__/views.cpython-314.pyc b/infrasynth/webhooks/__pycache__/views.cpython-314.pyc deleted file mode 100644 index af02c48..0000000 Binary files a/infrasynth/webhooks/__pycache__/views.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/dispatch.py b/infrasynth/webhooks/dispatch.py index 8252eb4..ff83e0f 100644 --- a/infrasynth/webhooks/dispatch.py +++ b/infrasynth/webhooks/dispatch.py @@ -21,18 +21,28 @@ logger = logging.getLogger(__name__) max_retries=5, default_retry_delay=60, ) -def deliver_webhook(self, subscription_id, event_name, payload, payload_template): +def deliver_webhook(self, subscription_id, event_name, payload, payload_template, tenant_id=None): """Delivers an outbound webhook with HMAC signature and retry/backoff.""" + from infrasynth.tenancy.context import tenant_context + from infrasynth.tenancy.models import Tenant + try: - subscription = OutboundSubscription.objects.select_related("endpoint").get(pk=subscription_id) + subscription = OutboundSubscription.all_objects.select_related("endpoint").get(pk=subscription_id) except OutboundSubscription.DoesNotExist: logger.warning("Subscription %s not found", subscription_id) return None + tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else subscription.tenant + with tenant_context(tenant): + return _deliver_webhook_inner(self, subscription, event_name, payload, payload_template, tenant) + + +def _deliver_webhook_inner(self, subscription, event_name, payload, payload_template, tenant): endpoint = subscription.endpoint body = _build_payload(payload, payload_template) - delivery = OutboundDelivery.objects.create( + delivery = OutboundDelivery.all_objects.create( + tenant=tenant or subscription.tenant, subscription=subscription, payload=body, status=OutboundDelivery.Status.RETRYING, @@ -130,3 +140,58 @@ def _handle_failure(self, delivery, subscription, endpoint, error: str): ) logger.error("Webhook delivery gave up: %s", error) return None + + +@shared_task( + name="infrasynth.webhooks.process_inbound_event", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def process_inbound_event(self, event_id, tenant_id=None): + """Runs the endpoint's handler against a verified inbound event.""" + from django.utils.module_loading import import_string + + from infrasynth.tenancy.context import tenant_context + from infrasynth.tenancy.models import Tenant + + from .inbound.handlers import HMACInboundHandler + from .models import InboundEvent + from .signals import inbound_event_processed + + try: + event = InboundEvent.all_objects.select_related("endpoint").get(pk=event_id) + except InboundEvent.DoesNotExist: + logger.warning("Inbound event %s not found", event_id) + return None + + tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else event.tenant + with tenant_context(tenant): + handler = None + if event.endpoint.handler: + try: + handler = import_string(event.endpoint.handler)() + except (ImportError, TypeError): + handler = None + handler = handler or HMACInboundHandler() + try: + result = handler.process(event.event_type, event.raw_payload) + except Exception as exc: # noqa: BLE001 + logger.exception("Inbound event %s processing failed", event_id) + event.error = str(exc) + event.save(update_fields=["error"]) + raise self.retry(exc=exc) from exc + + event.result = result if isinstance(result, dict) else {"result": result} + event.is_processed = True + event.error = "" + event.processed_at = timezone.now() + event.save(update_fields=["result", "is_processed", "error", "processed_at"]) + inbound_event_processed.send( + sender=InboundEvent, + tenant_id=str(event.tenant_id) if event.tenant_id else None, + event_id=event.id, + event_type=event.event_type, + result=event.result, + ) + return event.id diff --git a/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index f616911..0000000 Binary files a/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 52e5738..0000000 Binary files a/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc and /dev/null differ 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 21a0e67..0000000 Binary files a/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 ec9d1bc..0000000 Binary files a/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/webhooks/models.py b/infrasynth/webhooks/models.py index 985dbc8..a587875 100644 --- a/infrasynth/webhooks/models.py +++ b/infrasynth/webhooks/models.py @@ -1,7 +1,9 @@ from django.db import models +from infrasynth.tenancy.mixins import TenantOwnedModel -class OutboundEndpoint(models.Model): + +class OutboundEndpoint(TenantOwnedModel): name = models.CharField(max_length=255) url = models.URLField(max_length=1000) secret = models.CharField(max_length=500) @@ -14,12 +16,13 @@ class OutboundEndpoint(models.Model): class Meta: db_table = "webhooks_outbound_endpoint" + indexes = [models.Index(fields=["tenant_id", "is_active"])] def __str__(self): return self.name -class OutboundSubscription(models.Model): +class OutboundSubscription(TenantOwnedModel): endpoint = models.ForeignKey(OutboundEndpoint, on_delete=models.CASCADE, related_name="subscriptions") event_name = models.CharField(max_length=255, db_index=True) is_active = models.BooleanField(default=True) @@ -29,13 +32,15 @@ class OutboundSubscription(models.Model): class Meta: db_table = "webhooks_outbound_subscription" - unique_together = [("endpoint", "event_name")] + constraints = [ + models.UniqueConstraint(fields=["endpoint", "event_name"], name="uniq_subscription_event_per_endpoint"), + ] def __str__(self): return f"{self.endpoint.name} / {self.event_name}" -class OutboundDelivery(models.Model): +class OutboundDelivery(TenantOwnedModel): class Status(models.TextChoices): SUCCESS = "success", "Success" FAILED = "failed", "Failed" @@ -53,12 +58,13 @@ class OutboundDelivery(models.Model): class Meta: db_table = "webhooks_outbound_delivery" + indexes = [models.Index(fields=["tenant_id", "status"])] def __str__(self): return f"Delivery {self.id} — {self.status}" -class InboundEndpoint(models.Model): +class InboundEndpoint(TenantOwnedModel): class Source(models.TextChoices): STRIPE = "stripe", "Stripe" GITHUB = "github", "GitHub" @@ -66,7 +72,7 @@ class InboundEndpoint(models.Model): CUSTOM = "custom", "Custom" name = models.CharField(max_length=255) - slug = models.SlugField(unique=True) + slug = models.SlugField() source = models.CharField(max_length=50, choices=Source.choices) secret = models.CharField(max_length=500) handler = models.CharField(max_length=500) @@ -76,15 +82,25 @@ class InboundEndpoint(models.Model): class Meta: db_table = "webhooks_inbound_endpoint" + constraints = [ + models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_inbound_endpoint_slug_per_tenant"), + ] def __str__(self): return self.name -class InboundEvent(models.Model): +class InboundEvent(TenantOwnedModel): endpoint = models.ForeignKey(InboundEndpoint, on_delete=models.CASCADE, related_name="events") event_type = models.CharField(max_length=255) + external_id = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Provider event id, used for idempotent re-delivery handling.", + ) raw_payload = models.JSONField() + result = models.JSONField(default=dict, blank=True) is_verified = models.BooleanField(default=False) is_processed = models.BooleanField(default=False) error = models.TextField(blank=True) @@ -93,6 +109,14 @@ class InboundEvent(models.Model): class Meta: db_table = "webhooks_inbound_event" + indexes = [models.Index(fields=["tenant_id", "is_processed"])] + constraints = [ + models.UniqueConstraint( + fields=["endpoint", "external_id"], + condition=~models.Q(external_id=""), + name="uniq_inbound_event_external_id_per_endpoint", + ), + ] def __str__(self): return f"{self.endpoint.slug} / {self.event_type}" diff --git a/infrasynth/webhooks/registry.py b/infrasynth/webhooks/registry.py index 8a1b3f2..4b48f83 100644 --- a/infrasynth/webhooks/registry.py +++ b/infrasynth/webhooks/registry.py @@ -1,7 +1,9 @@ +import functools import logging from dataclasses import dataclass, field from django.db import transaction +from django.db.models import Q logger = logging.getLogger(__name__) @@ -29,8 +31,10 @@ class EventRegistry: @classmethod def emit(cls, event_name, payload: dict): - from django.db.models import Q + from infrasynth.shared.settings_utils import get_setting + from infrasynth.tenancy.context import get_current_tenant + from . import dispatch as dispatch_module from .models import OutboundSubscription active_subs = OutboundSubscription.objects.filter( @@ -39,13 +43,23 @@ class EventRegistry: endpoint__is_active=True, ).select_related("endpoint") + tenant = get_current_tenant() + tenant_id = str(tenant.pk) if tenant is not None else None + backend = get_setting("INFRASYNTH_WEBHOOKS", "DELIVERY_BACKEND", "celery") + + def _deliver(subscription): + if backend == "sync": + dispatch_module.deliver_webhook.apply( + args=(subscription.id, event_name, payload, subscription.payload_template, tenant_id) + ) + else: + dispatch_module.deliver_webhook.delay( + subscription.id, event_name, payload, subscription.payload_template, tenant_id + ) + with transaction.atomic(): for sub in active_subs: - transaction.on_commit( - lambda sid=sub.id, en=event_name, pl=payload, pt=sub.payload_template: __import__( # type: ignore[misc] - "infrasynth.webhooks.dispatch", fromlist=["deliver_webhook"] - ).deliver_webhook.delay(sid, en, pl, pt) - ) + transaction.on_commit(functools.partial(_deliver, sub)) @classmethod def get_registered_events(cls) -> 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 cc623b9..0000000 Binary files a/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/apps.cpython-314.pyc b/infrasynth/workflows/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 505aa93..0000000 Binary files a/infrasynth/workflows/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/engine.cpython-314.pyc b/infrasynth/workflows/__pycache__/engine.cpython-314.pyc deleted file mode 100644 index 7e6dd8a..0000000 Binary files a/infrasynth/workflows/__pycache__/engine.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/filters.cpython-314.pyc b/infrasynth/workflows/__pycache__/filters.cpython-314.pyc deleted file mode 100644 index 9e6b32c..0000000 Binary files a/infrasynth/workflows/__pycache__/filters.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/models.cpython-314.pyc b/infrasynth/workflows/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 45d9eac..0000000 Binary files a/infrasynth/workflows/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc b/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc deleted file mode 100644 index 8a0c53c..0000000 Binary files a/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/signals.cpython-314.pyc b/infrasynth/workflows/__pycache__/signals.cpython-314.pyc deleted file mode 100644 index b8abd84..0000000 Binary files a/infrasynth/workflows/__pycache__/signals.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/urls.cpython-314.pyc b/infrasynth/workflows/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index a71772b..0000000 Binary files a/infrasynth/workflows/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/validators.cpython-314.pyc b/infrasynth/workflows/__pycache__/validators.cpython-314.pyc deleted file mode 100644 index 61ff781..0000000 Binary files a/infrasynth/workflows/__pycache__/validators.cpython-314.pyc and /dev/null differ diff --git a/infrasynth/workflows/__pycache__/views.cpython-314.pyc b/infrasynth/workflows/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 9fb6464..0000000 Binary files a/infrasynth/workflows/__pycache__/views.cpython-314.pyc and /dev/null differ 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 f0da871..0000000 Binary files a/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ 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 79e9dd9..0000000 Binary files a/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 3725e76..0000000 Binary files a/tests/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 869868d..0000000 Binary files a/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/__pycache__/helpers.cpython-314.pyc b/tests/__pycache__/helpers.cpython-314.pyc deleted file mode 100644 index de99d21..0000000 Binary files a/tests/__pycache__/helpers.cpython-314.pyc and /dev/null differ diff --git a/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 31d94c1..0000000 Binary files a/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 1fe0520..0000000 Binary files a/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 abaa119..0000000 Binary files a/tests/test_audit/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 8e01fa7..0000000 Binary files a/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 212e6af..0000000 Binary files a/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 1467bd3..0000000 Binary files a/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_audit/test_middleware.py b/tests/test_audit/test_middleware.py index 5737c78..d260b5e 100644 --- a/tests/test_audit/test_middleware.py +++ b/tests/test_audit/test_middleware.py @@ -26,11 +26,11 @@ def run_request(middleware, request, response): class TestAPILogging: def test_logs_api_interaction(self, middleware, db): - request = RequestFactory().get("/api/features/active/") + request = RequestFactory().get("/api/v1/features/active/") response = JsonResponse({"ok": True}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/features/active/") + log = APIInteractionLog.objects.get(path="/api/v1/features/active/") assert log.method == "GET" assert log.status_code == 200 assert log.request_id @@ -38,30 +38,30 @@ class TestAPILogging: def test_logs_request_body(self, middleware, db): request = RequestFactory().post( - "/api/auth/login/", + "/api/v1/auth/login/", data=json.dumps({"username": "a@b.com"}), content_type="application/json", ) response = JsonResponse({"detail": "ok"}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/auth/login/") + log = APIInteractionLog.objects.get(path="/api/v1/auth/login/") assert log.request_body == {"username": "a@b.com"} def test_logs_response_body(self, middleware, db): - request = RequestFactory().get("/api/audit/changes/") + request = RequestFactory().get("/api/v1/audit/changes/") response = Response({"count": 3, "results": []}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/audit/changes/") + log = APIInteractionLog.objects.get(path="/api/v1/audit/changes/") assert log.response_body == {"count": 3, "results": []} def test_captures_ip_and_user_agent(self, middleware, db): - request = RequestFactory().get("/api/audit/changes/", HTTP_USER_AGENT="pytest-agent", REMOTE_ADDR="10.0.0.5") + request = RequestFactory().get("/api/v1/audit/changes/", HTTP_USER_AGENT="pytest-agent", REMOTE_ADDR="10.0.0.5") response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/audit/changes/") + log = APIInteractionLog.objects.get(path="/api/v1/audit/changes/") assert log.ip_address == "10.0.0.5" assert log.user_agent == "pytest-agent" @@ -69,28 +69,28 @@ class TestAPILogging: class TestSensitiveData: def test_sensitive_request_body_not_stored(self, middleware, db): request = RequestFactory().post( - "/api/auth/login/", + "/api/v1/auth/login/", data=json.dumps({"username": "a@b.com", "password": "supersecret"}), content_type="application/json", ) response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/auth/login/") + log = APIInteractionLog.objects.get(path="/api/v1/auth/login/") assert log.request_body is None @override_settings(INFRASYNTH_AUDIT=audit_config(SENSITIVE_KEYS=["password", "my_secret"])) def test_custom_sensitive_keys(self, db): middleware = AuditAPIMiddleware(get_response=lambda request: None) request = RequestFactory().post( - "/api/test/", + "/api/v1/test/", data=json.dumps({"my_secret": "abc", "other": 1}), content_type="application/json", ) response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/test/") + log = APIInteractionLog.objects.get(path="/api/v1/test/") assert log.request_body is None @@ -105,7 +105,7 @@ class TestConfiguration: @override_settings(INFRASYNTH_AUDIT=audit_config(ENABLE_API_LOGGING=False)) def test_api_logging_disabled(self, db): middleware = AuditAPIMiddleware(get_response=lambda request: None) - request = RequestFactory().get("/api/features/active/") + request = RequestFactory().get("/api/v1/features/active/") response = JsonResponse({}) run_request(middleware, request, response) @@ -116,32 +116,32 @@ class TestConfiguration: middleware = AuditAPIMiddleware(get_response=lambda request: None) big_payload = {"data": "x" * 100} request = RequestFactory().post( - "/api/test/", + "/api/v1/test/", data=json.dumps(big_payload), content_type="application/json", ) response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/test/") + log = APIInteractionLog.objects.get(path="/api/v1/test/") assert log.request_body is None class TestActorCapture: def test_captures_authenticated_actor(self, middleware, db, user): - request = RequestFactory().get("/api/audit/changes/") + request = RequestFactory().get("/api/v1/audit/changes/") request.user = user response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/audit/changes/") + log = APIInteractionLog.objects.get(path="/api/v1/audit/changes/") assert log.actor == user def test_anonymous_actor_null(self, middleware, db, user): - request = RequestFactory().get("/api/audit/changes/") + request = RequestFactory().get("/api/v1/audit/changes/") request.user = type("AnonUser", (), {"is_authenticated": False})() response = JsonResponse({}) run_request(middleware, request, response) - log = APIInteractionLog.objects.get(path="/api/audit/changes/") + log = APIInteractionLog.objects.get(path="/api/v1/audit/changes/") assert log.actor is None diff --git a/tests/test_audit/test_receivers.py b/tests/test_audit/test_receivers.py index 3c810f8..528b6fe 100644 --- a/tests/test_audit/test_receivers.py +++ b/tests/test_audit/test_receivers.py @@ -58,15 +58,16 @@ class TestModelUpdateTracking: action="update", ).exists() - def test_no_previous_state_does_not_log(self, role): - role.name = "Changed Without Snapshot" + def test_update_logged_without_manual_snapshot(self, role): + role.name = "Changed Automatically" role.save() - assert not ModelChangeLog.objects.filter( + log = ModelChangeLog.objects.get( model_label="infrasynth_security.Role", object_id=str(role.pk), action="update", - ).exists() + ) + assert log.changes["name"] == ["Test Role", "Changed Automatically"] class TestModelDeleteTracking: diff --git a/tests/test_audit/test_views.py b/tests/test_audit/test_views.py index 0c72b34..5b50817 100644 --- a/tests/test_audit/test_views.py +++ b/tests/test_audit/test_views.py @@ -5,8 +5,9 @@ from infrasynth.audit.models import APIInteractionLog, ModelChangeLog, SecurityE @pytest.fixture -def change_log(db, user): +def change_log(db, user, tenant): return ModelChangeLog.objects.create( + tenant=tenant, model_label="infrasynth_security.Role", object_id="1", action="create", @@ -17,10 +18,11 @@ def change_log(db, user): @pytest.fixture -def api_log(db, user): +def api_log(db, user, tenant): return APIInteractionLog.objects.create( + tenant=tenant, method="GET", - path="/api/features/", + path="/api/v1/features/", status_code=200, actor=user, duration_ms=12, @@ -29,8 +31,9 @@ def api_log(db, user): @pytest.fixture -def security_event(db, user): +def security_event(db, user, tenant): return SecurityEvent.objects.create( + tenant=tenant, event_type="login_failed", actor=user, ip_address="127.0.0.1", @@ -41,22 +44,22 @@ def security_event(db, user): class TestModelChangeLogEndpoints: def test_list_changes(self, authenticated_client, change_log): - resp = authenticated_client.get("/api/audit/changes/", {"request_id": "req-1"}) + resp = authenticated_client.get("/api/v1/audit/changes/", {"request_id": "req-1"}) assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 assert resp.json()["results"][0]["action"] == "create" def test_retrieve_change(self, authenticated_client, change_log): - resp = authenticated_client.get(f"/api/audit/changes/{change_log.pk}/") + resp = authenticated_client.get(f"/api/v1/audit/changes/{change_log.pk}/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["changes"] == {"name": [None, "Test"]} def test_requires_auth(self, api_client, change_log): - resp = api_client.get("/api/audit/changes/") + resp = api_client.get("/api/v1/audit/changes/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED def test_list_does_not_allow_create(self, authenticated_client): - resp = authenticated_client.post("/api/audit/changes/", {"model_label": "x"}, format="json") + resp = authenticated_client.post("/api/v1/audit/changes/", {"model_label": "x"}, format="json") assert resp.status_code in ( status.HTTP_405_METHOD_NOT_ALLOWED, status.HTTP_403_FORBIDDEN, @@ -65,41 +68,42 @@ class TestModelChangeLogEndpoints: class TestAPIInteractionLogEndpoints: def test_list_api_logs(self, authenticated_client, api_log): - resp = authenticated_client.get("/api/audit/api-logs/") + resp = authenticated_client.get("/api/v1/audit/api-logs/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 assert resp.json()["results"][0]["method"] == "GET" def test_retrieve_api_log(self, authenticated_client, api_log): - resp = authenticated_client.get(f"/api/audit/api-logs/{api_log.pk}/") + resp = authenticated_client.get(f"/api/v1/audit/api-logs/{api_log.pk}/") assert resp.status_code == status.HTTP_200_OK - assert resp.json()["path"] == "/api/features/" + assert resp.json()["path"] == "/api/v1/features/" def test_requires_auth(self, api_client, api_log): - resp = api_client.get("/api/audit/api-logs/") + resp = api_client.get("/api/v1/audit/api-logs/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestSecurityEventEndpoints: def test_list_security_events(self, authenticated_client, security_event): - resp = authenticated_client.get("/api/audit/security-events/") + resp = authenticated_client.get("/api/v1/audit/security-events/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["count"] == 1 assert resp.json()["results"][0]["event_type"] == "login_failed" def test_retrieve_security_event(self, authenticated_client, security_event): - resp = authenticated_client.get(f"/api/audit/security-events/{security_event.pk}/") + resp = authenticated_client.get(f"/api/v1/audit/security-events/{security_event.pk}/") assert resp.status_code == status.HTTP_200_OK assert resp.json()["metadata"] == {"reason": "bad_password"} def test_requires_auth(self, api_client, security_event): - resp = api_client.get("/api/audit/security-events/") + resp = api_client.get("/api/v1/audit/security-events/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED class TestFiltering: - def test_filter_by_model_label(self, authenticated_client, db): + def test_filter_by_model_label(self, authenticated_client, db, tenant): ModelChangeLog.objects.create( + tenant=tenant, model_label="infrasynth_security.Role", object_id="10", action="create", @@ -107,24 +111,26 @@ class TestFiltering: request_id="x1", ) ModelChangeLog.objects.create( + tenant=tenant, model_label="infrasynth_security.Grant", object_id="99", action="create", changes={}, request_id="x2", ) - resp = authenticated_client.get("/api/audit/changes/", {"model_label": "infrasynth_security.Role"}) + resp = authenticated_client.get("/api/v1/audit/changes/", {"model_label": "infrasynth_security.Role"}) assert resp.json()["count"] == 1 - def test_filter_by_action(self, authenticated_client, change_log): + def test_filter_by_action(self, authenticated_client, change_log, tenant): ModelChangeLog.objects.create( + tenant=tenant, model_label="infrasynth_security.Role", object_id="77", action="delete", changes={}, request_id="req-4", ) - resp = authenticated_client.get("/api/audit/changes/", {"action": "create"}) + resp = authenticated_client.get("/api/v1/audit/changes/", {"action": "create"}) assert all(r["action"] == "create" for r in resp.json()["results"]) - resp = authenticated_client.get("/api/audit/changes/", {"action": "delete"}) + resp = authenticated_client.get("/api/v1/audit/changes/", {"action": "delete"}) assert all(r["action"] == "delete" for r in resp.json()["results"]) diff --git a/tests/test_billing/__pycache__/__init__.cpython-314.pyc b/tests/test_billing/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 7bae239..0000000 Binary files a/tests/test_billing/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 1d44ed5..0000000 Binary files a/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 61423a6..0000000 Binary files a/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 dea3bd5..0000000 Binary files a/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 6937b87..0000000 Binary files a/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 83a84b9..0000000 Binary files a/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 f9bd1f3..0000000 Binary files a/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 8218e4f..0000000 Binary files a/tests/test_features/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 ccb9c7b..0000000 Binary files a/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 8c73b00..0000000 Binary files a/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 b644c43..0000000 Binary files a/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 2c480b5..0000000 Binary files a/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 2f70cb2..0000000 Binary files a/tests/test_files/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 65c7d45..0000000 Binary files a/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 88b03db..0000000 Binary files a/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 d6af52c..0000000 Binary files a/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index e2ce4d5..0000000 Binary files a/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 07c7157..0000000 Binary files a/tests/test_notifications/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 7872fe2..0000000 Binary files a/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 12853c1..0000000 Binary files a/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 cfedec1..0000000 Binary files a/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 fe243a6..0000000 Binary files a/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 82c2930..0000000 Binary files a/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_notifications/test_models.py b/tests/test_notifications/test_models.py index 629c6bd..0bb9155 100644 --- a/tests/test_notifications/test_models.py +++ b/tests/test_notifications/test_models.py @@ -86,10 +86,11 @@ class TestChannelConfig: assert config.is_active is True assert config.priority == 0 - def test_pk_is_slug(self): + def test_slug_is_per_tenant(self): config = ChannelConfig.objects.create( slug="telegram", channel_type="telegram", display_name="Telegram", ) - assert config.pk == "telegram" + assert config.slug == "telegram" + assert config.pk != "telegram" diff --git a/tests/test_notifications/test_tasks.py b/tests/test_notifications/test_tasks.py new file mode 100644 index 0000000..60ceeea --- /dev/null +++ b/tests/test_notifications/test_tasks.py @@ -0,0 +1,57 @@ +from datetime import timedelta +from unittest import mock + +import pytest +from django.utils import timezone + +from infrasynth.notifications.models import NotificationDispatch +from infrasynth.notifications.tasks import purge_old_dispatches, retry_pending_dispatches + +pytestmark = pytest.mark.django_db + + +def _dispatch(tenant, **kwargs): + defaults = { + "tenant": tenant, + "recipient": "user@example.com", + "channel": "email", + "subject": "S", + "body": "B", + "status": NotificationDispatch.Status.RETRYING, + } + defaults.update(kwargs) + return NotificationDispatch.all_objects.create(**defaults) + + +class TestRetryPendingDispatches: + def test_requeues_due_dispatches(self, tenant): + due = _dispatch(tenant, next_retry_at=timezone.now() - timedelta(minutes=1)) + _dispatch(tenant, next_retry_at=timezone.now() + timedelta(hours=1)) + + with mock.patch("infrasynth.notifications.tasks.deliver_notification.delay") as delay: + count = retry_pending_dispatches() + + assert count == 1 + delay.assert_called_once_with(due.id, str(tenant.pk)) + + def test_ignores_sent_dispatches(self, tenant): + _dispatch( + tenant, + status=NotificationDispatch.Status.SENT, + next_retry_at=timezone.now() - timedelta(minutes=1), + ) + with mock.patch("infrasynth.notifications.tasks.deliver_notification.delay") as delay: + assert retry_pending_dispatches() == 0 + delay.assert_not_called() + + +class TestPurgeOldDispatches: + def test_deletes_only_finished_and_old(self, tenant): + old_sent = _dispatch(tenant, status=NotificationDispatch.Status.SENT) + NotificationDispatch.all_objects.filter(pk=old_sent.pk).update(created_at=timezone.now() - timedelta(days=400)) + pending = _dispatch(tenant, status=NotificationDispatch.Status.PENDING) + + purge_old_dispatches() + + assert not NotificationDispatch.all_objects.filter(pk=old_sent.pk).exists() + assert NotificationDispatch.all_objects.filter(pk=pending.pk).exists() diff --git a/tests/test_notifications/test_views.py b/tests/test_notifications/test_views.py index 7426e56..373fa6b 100644 --- a/tests/test_notifications/test_views.py +++ b/tests/test_notifications/test_views.py @@ -8,9 +8,9 @@ from infrasynth.notifications.models import ( pytestmark = pytest.mark.django_db -TEMPLATES_URL = "/api/notifications/templates/" -DISPATCHES_URL = "/api/notifications/dispatches/" -CHANNELS_URL = "/api/notifications/channels/" +TEMPLATES_URL = "/api/v1/notifications/templates/" +DISPATCHES_URL = "/api/v1/notifications/dispatches/" +CHANNELS_URL = "/api/v1/notifications/channels/" @pytest.fixture diff --git a/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc b/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index ff8a0f5..0000000 Binary files a/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 293ba7e..0000000 Binary files a/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 4751066..0000000 Binary files a/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_scheduler/test_views.py b/tests/test_scheduler/test_views.py index 9454f9b..ff24e27 100644 --- a/tests/test_scheduler/test_views.py +++ b/tests/test_scheduler/test_views.py @@ -7,9 +7,9 @@ from infrasynth.scheduler.models import ScheduledTask, TaskExecution pytestmark = pytest.mark.django_db -TASKS_URL = "/api/scheduler/tasks/" -EXECUTIONS_URL = "/api/scheduler/executions/" -STATUS_URL = "/api/scheduler/status/" +TASKS_URL = "/api/v1/scheduler/tasks/" +EXECUTIONS_URL = "/api/v1/scheduler/executions/" +STATUS_URL = "/api/v1/scheduler/status/" @pytest.fixture diff --git a/tests/test_security/__pycache__/__init__.cpython-314.pyc b/tests/test_security/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 4840f36..0000000 Binary files a/tests/test_security/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 5ee7e6b..0000000 Binary files a/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 c8e62e4..0000000 Binary files a/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 97e567f..0000000 Binary files a/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 699211e..0000000 Binary files a/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 720abb7..0000000 Binary files a/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 872784e..0000000 Binary files a/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 bdc3a8d..0000000 Binary files a/tests/test_shared/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 47c385b..0000000 Binary files a/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 977e65b..0000000 Binary files a/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 f9382ab..0000000 Binary files a/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 ddccea8..0000000 Binary files a/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 5b0134d..0000000 Binary files a/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 18c2f95..0000000 Binary files a/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 e59e368..0000000 Binary files a/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 8e61572..0000000 Binary files a/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 1208436..0000000 Binary files a/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index c7179e1..0000000 Binary files a/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index dd2f1e0..0000000 Binary files a/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 21f28bb..0000000 Binary files a/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 7c45e1e..0000000 Binary files a/tests/test_workflows/__pycache__/__init__.cpython-314.pyc and /dev/null differ 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 0c33c2a..0000000 Binary files a/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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 eee2297..0000000 Binary files a/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 3a374ff..0000000 Binary files a/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc deleted file mode 100644 index 4336939..0000000 Binary files a/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc and /dev/null differ 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