Skip to main content

Multi-tenant enforcement engine for Python. Prevents cross-tenant data leaks by construction.

Project description

TenantShield

Multi-tenant enforcement engine for Python — prevent cross-tenant data leaks by construction, not convention.

The Problem

Multi-tenant SaaS applications can leak data across tenant boundaries when a single forgotten .filter(tenant_id=...) slips into production. The leak is silent: queries succeed, responses look normal, and the breach is often detected only when affected tenants report seeing data that isn't theirs.

The root cause is structural: tenant scoping is typically enforced by convention (developers remembering to filter), not by the system. One missed filter, one new query path, one ORM method that bypasses the scoped manager — and the boundary is gone.

Before TenantShield

# Django: tenant scoping by convention -- one forgotten filter leaks.
class InvoiceView(View):
    def get(self, request):
        # Correct: scoped to current tenant
        invoices = Invoice.objects.filter(tenant_id=request.tenant.id)
        return JsonResponse({"invoices": list(invoices.values())})

    def export_all(self, request):
        # LEAK: no tenant filter -- returns ALL tenants' invoices.
        invoices = Invoice.objects.all()
        return JsonResponse({"invoices": list(invoices.values())})

The bug compiles. Tests pass unless they explicitly cover cross-tenant isolation. Production traffic returns wrong-tenant data. No exception is raised.

After TenantShield

# Same model, with TenantShield: scoping is enforced by the system.
from tenantshield.adapters.django import tenant_aware

@tenant_aware
class Invoice(models.Model):
    tenant_id = models.CharField(max_length=64)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    # ... rest of model

class InvoiceView(View):
    def get(self, request):
        invoices = Invoice.objects.all()  # auto-scoped to request.tenant
        return JsonResponse({"invoices": list(invoices.values())})

    def export_all(self, request):
        # STILL auto-scoped -- no way to forget.
        invoices = Invoice.objects.all()
        return JsonResponse({"invoices": list(invoices.values())})

Outside a tenant context (e.g., a misconfigured background worker), the same query raises MissingTenantContextError instead of silently returning unscoped data. Cross-tenant writes raise CrossTenantAccessError before the SQL executes.

What was a convention developers had to remember at every call site becomes a constraint the system enforces by default.

TenantShield is a layer over existing ORMs (Django, SQLAlchemy) and request frameworks (Django, FastAPI, Flask, any ASGI/WSGI application) that enforces tenant scoping automatically. The default policy is deny-by-default: any query against a tenant-aware model without an explicit tenant context fails loudly. Cross-tenant writes are detected and rejected. Tenant context propagates across async boundaries via Python's native contextvars.

Adoption is incremental — adapters are activated explicitly and there is no import-time monkey-patching.

Status

🔵 Beta — version 0.7.0b0. The architectural arc from foundation through production hardening + first cohort-validated maturity is complete (Phases 0 → 6). The Django adapter is validated in production by a reference adopter (Counterbook, ~196 commits over 6 sprint cycles, 0 architectural findings introduced). SQLAlchemy and DRF adapters are functional with known limitations documented in the adapters guide; Celery integration is on the roadmap, not yet shipped.

Distribution: install from PyPI with pip install --pre tenantshield (pre-releases require --pre; pip install tenantshield without --pre will not install beta versions). TestPyPI continues in parallel for release-candidate testing. A v1.0.0 stable GA release is planned once a second adapter is validated in production and the Phase 7 architectural gaps are closed.

Features

Framework adapters

  • Django ORM (validated in production by reference adopter, 6 sprint cycles, 0 findings): tenant_aware manager + signal enforcement + TenantContextMiddleware.
  • Django REST Framework (functional; test coverage expanding): triple defense (permissions + viewset mixin + serializer validation).
  • SQLAlchemy 2.x sync + async (functional; Phase 7 gaps documented — _unsafe_unscoped and auto_propagate_from_parent_fk parity with Django pending; see Known Limitations): @tenant_aware declarative decorator + event-based write enforcement (before_insert / before_update / before_delete) + read filtering via do_orm_execute + scope managers (SessionScope / AsyncSessionScope).
  • ASGI middleware: TenantSessionMiddleware (sync ctx mgr with dual-mode resolver) + AsyncTenantSessionMiddleware (async-native; Phase 5A) for FastAPI / Starlette / any ASGI 3.0 framework.
  • WSGI middleware: TenantSessionMiddlewareWSGI with generator-safe scope (Flask / Django WSGI / Gunicorn).

Core capabilities

  • Tenant scope contracts: tenant_scope(ctx) + atenant_scope(ctx) for explicit binding; bind_session_to_tenant / bind_async_session_to_tenant for SA session binding.
  • Policy engine: DenyByDefaultPolicy, AllowListPolicy, RequireScope, ChainPolicy. Composable.
  • Audit bus: pluggable sinks (StructLogSink, InMemorySink, NullSink, or custom). 6 AuditEventType values (CONTEXT_BOUND / CONTEXT_RELEASED / POLICY_ALLOW / POLICY_DENY / ENFORCEMENT_VIOLATION / SINK_FAILURE).
  • Cross-adapter extraction strategies: HeaderStrategy, HostStrategy, JWTStrategy, CallableStrategy operating on a minimal RequestProtocol abstraction. Same strategy instance works across Django + ASGI.

Production hardening (Phase 5)

  • Structured observability: tenantshield.observability module with a 9-event taxonomy across scope lifecycle + enforcement + middleware boundaries. structlog-based emission with ~6 ns/call disabled-default gate.
  • Audit-observability dual-pattern: policy-level audit events + operation- level observability events emit at independent gates (sink registry vs is_enabled() flag). Decision 7-A separation verified empirically.
  • Adopter-extensible processor chain: OpenTelemetry trace context + Prometheus metrics integrate as adopter-prepended structlog processors; zero TenantShield-side coupling.

Install

Stage 1 — Local wheel distribution

TenantShield is currently distributed via local wheel/sdist artifacts to a validation cohort. To build and install from the repository:

uv build
pip install dist/tenantshield-0.5.0a0-py3-none-any.whl

Or install a provided wheel artifact directly:

pip install tenantshield-0.5.0a0-py3-none-any.whl

Adapter-specific extras:

pip install "tenantshield[django]"       # Django + DRF adapter
pip install "tenantshield[sqlalchemy]"   # SQLAlchemy adapter
pip install "tenantshield[jwt]"          # JWT strategy support
pip install "tenantshield[drf]"          # Django REST Framework adapter

Stage 2 — PyPI distribution (upcoming)

Public PyPI distribution is planned after validation cohort feedback.

Quickstart

The canonical minimum: register an audit sink, define a policy, enter a tenant scope, and evaluate operations.

from tenantshield import (
    DenyByDefaultPolicy,
    Operation,
    OperationType,
    StructLogSink,
    TenantId,
    bind_tenant,
    evaluate_and_audit,
    register_sink,
    tenant_scope,
)

# 1. Register a sink so audit events go somewhere.
register_sink(StructLogSink())

# 2. Define a policy.
policy = DenyByDefaultPolicy()

# 3. Enter a tenant scope.
ctx = bind_tenant(TenantId("acme"))
with tenant_scope(ctx):
    # 4. Evaluate an operation.
    operation = Operation(
        model="app.Invoice",
        operation_type=OperationType.READ,
        tenant_context=ctx,
    )
    decision = evaluate_and_audit(policy, operation)
    print(decision)  # Allow()

Outside a tenant scope, the same evaluation returns Deny(reason="No tenant context active for read on 'app.Invoice'").

For framework-specific quickstarts (Django, SQLAlchemy + FastAPI, SQLAlchemy + Flask), see the Adapters documentation and the Examples below.

Enable observability (Phase 5, opt-in)

from tenantshield.observability import configure

configure(emit_events=True)

Disabled by default — the gate adds ~6 ns/call when off and zero log volume. See Observability Quick Start for adopter integration patterns.

Documentation

Examples

Runnable adopter examples lives in examples/:

Compatibility

Versions
Python 3.11, 3.12, 3.13
Django 4.2 LTS, 5.x
SQLAlchemy 2.x (sync + async)
FastAPI >=0.115
Flask recent versions (WSGI standard)

structlog>=25.0,<26.0 is the only required runtime dependency.

Architectural maturity

  • Phases shipped: 0 (foundation) → 1 (core API) → 2 (Django + DRF) → 3 (SQLAlchemy sync) → 4 (SQLAlchemy async + cross-adapter strategies) → 5 (production hardening).
  • 557 tests (541 library + 16 example) with 99.63% library coverage.
  • 12 ADRs + 43 active Decision Records + 73 normative Rules.
  • 0 architectural BLOCKERs in Phase 5 (best Phase profile sustained from Phase 4).

See the Documentation for comprehensive guides, API reference, and architectural decisions.

License

Released under the Apache License 2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tenantshield-0.7.0b0.tar.gz (356.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tenantshield-0.7.0b0-py3-none-any.whl (101.1 kB view details)

Uploaded Python 3

File details

Details for the file tenantshield-0.7.0b0.tar.gz.

File metadata

  • Download URL: tenantshield-0.7.0b0.tar.gz
  • Upload date:
  • Size: 356.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tenantshield-0.7.0b0.tar.gz
Algorithm Hash digest
SHA256 5db9825337b3946ecf5393fe3e32da4f925315fdf11a4a8992abeb6ed2332b30
MD5 c7629ec92a58a497935d36a751faf04c
BLAKE2b-256 96bc0dde8e2d42c12ce19f1eaca29560676ab3fdf981e28dfeca06442f1408b1

See more details on using hashes here.

Provenance

The following attestation bundles were made for tenantshield-0.7.0b0.tar.gz:

Publisher: publish-pypi.yml on Jhoelperaltap/tenantshield

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tenantshield-0.7.0b0-py3-none-any.whl.

File metadata

  • Download URL: tenantshield-0.7.0b0-py3-none-any.whl
  • Upload date:
  • Size: 101.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tenantshield-0.7.0b0-py3-none-any.whl
Algorithm Hash digest
SHA256 10a7f62193df78d8ebe1b96c64625ca7dcbefdfac3ee7448c5bf732c69c4ead9
MD5 6a59b52e022dfd1d725ce7b32bda4bc0
BLAKE2b-256 3b30a302d00195a6dbf375629649db8babd99dc7941aefecfe973bed354620cf

See more details on using hashes here.

Provenance

The following attestation bundles were made for tenantshield-0.7.0b0-py3-none-any.whl:

Publisher: publish-pypi.yml on Jhoelperaltap/tenantshield

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page