infrasynth-backend-kit/AGENTS.md
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

21 KiB

AGENTS.md — InfraSynth Base

Project Overview

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.

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.


Stack

Component Technology
Language Python 3.12+
Framework Django 5.2+
API Django REST Framework 3.16+
Database PostgreSQL 16
Cache/Broker Redis
Task Queue Celery 5.4+ (with django-celery-results + django-celery-beat)
Auth JWT via HTTP-Only cookies (encrypted with Fernet) + API Keys
File Storage S3, Cloudinary, GCS, local (via django-storages)
Payments Stripe, MercadoPago, Wompi
2FA TOTP (pyotp + qrcode)
Anti-spam ALTCHA (proof-of-work, self-hosted)
Monitoring Flower (Celery dashboard)
Tests pytest + pytest-django + factory-boy
Linting ruff + mypy + pre-commit

Package Structure

infrasynth-base/
├── pyproject.toml                 # Root package metadata
├── docker-compose.yml
├── PLAN.md                        # Architecture blueprint
├── AGENTS.md                      # This file
│
├── 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'
│   ├── notifications/             # Django app: 'infrasynth.notifications'
│   ├── webhooks/                  # Django app: 'infrasynth.webhooks'
│   ├── workflows/                 # Django app: 'infrasynth.workflows'
│   ├── scheduler/                 # Django app: 'infrasynth.scheduler'
│   ├── features/                  # Django app: 'infrasynth.features'
│   └── billing/                   # Django app: 'infrasynth.billing'
│
└── tests/
    ├── conftest.py
    ├── test_audit/
    ├── test_security/
    ├── test_files/
    ├── test_notifications/
    ├── test_webhooks/
    ├── test_workflows/
    ├── test_scheduler/
    ├── test_features/
    └── test_billing/

Architecture Principles

1. Zero Cross-App Import Rule

No Django app that depends on another Django app may import from it directly. The only allowed intra-app imports are:

  • 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
Settings dict Configure which concrete class/backend to use INFRASYNTH_NOTIFICATIONS["CHANNELS"]["email"] points to SMTPChannel
Signals Loose async communication between apps billing emits payment_succeeded, App B's receiver sends email via notifications
Registries Apps self-register capabilities at startup EventRegistry.register("helpdesk.ticket.created") in apps.py:ready()
ABCs/Protocols Define swappable interfaces BasePaymentGateway, BaseChannel, DataValidatorProtocol
ForeignKey (SET_NULL) Weak model coupling StoredFile referenced by any model, on_delete=SET_NULL, related_name="+"
AUTH_USER_MODEL Reference the user model Always settings.AUTH_USER_MODEL, never auth.User directly
FeatureService Cross-cutting enable/disable FeatureService().is_enabled("billing") gates billing views

3. Dependency Graph

infrasynth.shared          ← Zero deps (protocols, enums, crypto)
    ↑
infrasynth.audit           ← shared 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)

4. Feature Flags Are the Orchestrator

infrasynth.features is the only app that is always active. Every other feature (module, endpoint, UI element) should be gated behind a feature flag. The frontend consumes GET /api/features/active/ once at boot and renders conditionally.

Each app registers its flags in apps.py:ready():

class MyAppConfig(AppConfig):
    def ready(self):
        from infrasynth.features.registry import FeatureRegistry
        FeatureRegistry.register("my_app.feature_x", default=True)

Development Conventions

Django App Structure

Every Django app follows this layout:

app_name/
├── __init__.py
├── apps.py               # AppConfig: name, feature_flag, ready() for registry registrations
├── models.py             # Django models
├── services.py           # Business logic (pure Python, no DRF)
├── urls.py               # URL patterns
├── serializers.py        # DRF serializers
├── views.py              # DRF views
├── filters.py            # DRF filtersets
├── signals.py            # Signal definitions (Signal() instances)
├── middleware.py          # Django middleware (if needed)
├── tasks.py              # Celery tasks (if needed)
└── migrations/           # Django migrations

Model Conventions

  1. All models use db_table prefix: audit_model_change_log, security_api_key, files_stored_file, etc.
  2. ForeignKey always uses SET_NULL with null=True, blank=True unless cascade is semantically required.
  3. related_name="+" on FK to other apps' models to avoid reverse relation clutter.
  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

  1. FK fields need {field}_info read-only serialized representations (for frontend display).
  2. Audit fields (created_by, created_at, updated_by, updated_at) when present must be in read_only_fields and are populated by signals (not in ModeloAuditable base class since we avoid model inheritance).
  3. JSONField fields need explicit serialization handling (the frontend expects objects, not strings).
  4. Use SerializerMethodField sparingly — prefer annotations in the queryset.

View Conventions

  1. All views are ModelViewSet unless they have no model backing.
  2. Always set permission_classes = [IsAuthenticated] plus specific permission classes.
  3. Always use select_related()/prefetch_related() in get_queryset() to avoid N+1 queries.
  4. Feature flag check in initial() method for gated views:
def initial(self, request, *args, **kwargs):
    if not FeatureService().is_enabled("billing", user=request.user):
        raise NotFound()
    super().initial(request, *args, **kwargs)
  1. Pagination: All list views use the standard CustomPagination class. Query param ?page_size= (default 25, max 100).
  2. Filtering: Use DjangoFilterBackend with a FilterSet class per view.
  3. 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

  1. Define signals in signals.py as module-level Signal() instances.
  2. Receiver functions go in receivers.py or apps.py:ready() (for connecting signals across apps).
  3. Always use sender= parameter when connecting to specific model signals.
  4. Use @receiver(signal_name) decorator pattern.

Registry Conventions

Registries are singleton classes (not instances) with @classmethod methods. They live in a registry.py file in their owning app:

  • infrasynth.features.registry.FeatureRegistry — feature flag definitions
  • infrasynth.webhooks.registry.EventRegistry — event definitions
  • infrasynth.notifications.resolvers.VariableResolverRegistry — template variable resolvers
  • infrasynth.workflows.validators.DataValidatorRegistry — workflow data validators

Pattern:

class MyRegistry:
    _items: dict = {}

    @classmethod
    def register(cls, key, **kwargs):
        cls._items[key] = kwargs

    @classmethod
    def get(cls, key):
        return cls._items.get(key)

    @classmethod
    def get_all(cls):
        return dict(cls._items)

Testing Conventions

  1. Use pytest with pytest-django (pytest.mark.django_db).

  2. Use factory-boy for model factories (tests/factories.py in each app test directory).

  3. API tests use APIClient from DRF with JWT cookies set manually.

  4. Test structure:

    • test_models.py — model creation, validation, constraints
    • test_services.py — business logic
    • test_views.py — API endpoints (auth, permissions, CRUD, edge cases)
    • test_signals.py — signal emission and receiver behavior
    • test_integration.py — cross-app communication (registries, signals)
  5. Conftest fixtures:

    • api_client — DRF APIClient
    • 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

  1. All InfraSynth settings use the prefix INFRASYNTH_ followed by the app name in uppercase.
  2. Settings are dicts, not flat keys: INFRASYNTH_SECURITY = {"COOKIE_SECURE": True}.
  3. Every setting has a sensible default — the system must run with zero configuration in development.
  4. Read settings with the helper (not getattr directly):
from infrasynth.shared.settings_utils import get_setting
cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
  1. 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.
  2. Encrypt secrets at rest: API keys, SMTP passwords, payment gateway credentials.
  3. Never log encrypted values — log the fact of encryption, not the ciphertext or plaintext.
  4. CRYPTO_KEY must be set in environment. Auto-generate in dev if missing (warn loudly).

Migration Conventions

  1. Apps are namespaced in migrations to avoid collisions:
    • infrasynth.audit.migrations
    • infrasynth.security.migrations
  2. Never use RunPython with model imports — use apps.get_model().
  3. Data migrations go in separate migration files from schema migrations.

Integration Examples for External App B

App B needs: custom notification channel

# helpdesk/channels.py
from infrasynth.notifications.channels.base import BaseChannel

class SlackChannel(BaseChannel):
    channel_type = "slack"

    def send(self, recipient, subject, body, is_html=True, attachments=None):
        # Send to Slack webhook
        ...
        return Result.ok(True)

# settings.py
INFRASYNTH_NOTIFICATIONS = {
    "CHANNELS": {
        "slack": {
            "primary": "helpdesk.channels.SlackChannel",
        },
    },
}

App B needs: webhook handler for a new external service

# helpdesk/webhook_handlers.py
from infrasynth.webhooks.inbound.handlers import BaseInboundHandler

class JiraWebhookHandler(BaseInboundHandler):
    def verify(self, payload, headers, secret):
        # Verify Jira HMAC
        ...

    def process(self, event_type, payload):
        # Sync Jira issue to local Ticket model
        ...

# Register via admin or data migration: InboundEndpoint(slug="jira", handler="helpdesk.webhook_handlers.JiraWebhookHandler")

App B needs: workflow data validation for its domain

# helpdesk/validators.py
class TicketDataValidator:
    def validate(self, node, data, context):
        if not data.get("resolution_note"):
            raise ValidationError({"resolution_note": "Required when resolving."})
        return data

# helpdesk/apps.py → ready():
DataValidatorRegistry.register("ticket_approval", TicketDataValidator())

App B needs: a tenant-owned model

# 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

# 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

✅ DO

  • Use settings.AUTH_USER_MODEL for all user references
  • Use signals for cross-app communication
  • Register events/resolvers/validators in apps.py:ready()
  • Gate views/endpoints behind feature flags
  • Use on_delete=SET_NULL with null=True, blank=True for cross-app FKs
  • Use related_name="+" for FKs to models in other apps
  • Use db_table prefix for all models
  • 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

  • Don't import models from one Django app into another Django app
  • Don't hardcode auth.User — use settings.AUTH_USER_MODEL
  • Don't use on_delete=CASCADE on cross-app FKs
  • Don't bypass the FeatureRegistry — always register flags
  • Don't hardcode channel URLs, gateway credentials, or SMTP settings in code
  • Don't log plaintext secrets, tokens, or passwords
  • 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

Key Files Reference

File Purpose
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
infrasynth/features/registry.py Feature flag registry
infrasynth/features/services.py Feature flag evaluation
infrasynth/webhooks/registry.py Event registry
infrasynth/notifications/resolvers.py Template variable resolvers
infrasynth/notifications/channels/base.py Channel ABC
infrasynth/billing/gateways/base.py Payment gateway ABC
infrasynth/workflows/validators.py Data validator protocol + registry
infrasynth/workflows/models.py WorkflowAwareModel abstract mixin
infrasynth/security/services.py AuthorizationService
infrasynth/security/auth/cookies.py CookieJWTAuthentication
infrasynth/security/auth/api_keys.py APIKeyAuthentication
infrasynth/security/permissions.py HybridPermission + require_permission
infrasynth/files/services.py FileService (upload, signed_url, delete)
infrasynth/scheduler/services.py TaskService

Setup for Development

# Clone
git clone <repo-url> && cd infrasynth-base

# Virtual environment
python -m venv .venv && source .venv/bin/activate

# Install with dev dependencies
pip install -e ".[dev]"

# Start services
docker compose up -d db redis

# Run migrations
python manage.py migrate

# Run tests
pytest

# Run linter
ruff check .

# Run type checker
mypy infrasynth/