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

TenantShield is published to public PyPI as a beta pre-release. Use the --pre flag because pip does not install pre-releases by default:

pip install --pre tenantshield

Adapter-specific extras:

pip install --pre "tenantshield[django]"       # Django + DRF adapter
pip install --pre "tenantshield[sqlalchemy]"   # SQLAlchemy adapter
pip install --pre "tenantshield[jwt]"          # JWT strategy support
pip install --pre "tenantshield[drf]"          # Django REST Framework adapter
pip install --pre "tenantshield[all]"          # all adapters at once

TestPyPI continues in parallel for release-candidate testing:

pip install --pre --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ tenantshield

Once v1.0.0 ships, pip install tenantshield (without --pre) will prefer the stable release automatically.

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.0b2.tar.gz (358.4 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.0b2-py3-none-any.whl (101.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: tenantshield-0.7.0b2.tar.gz
  • Upload date:
  • Size: 358.4 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.0b2.tar.gz
Algorithm Hash digest
SHA256 f6d1c5c8c637e83a189211c5e39f71601ab73848a2c39a1ef212172e4085b40e
MD5 e9bb0a91d90c043563ac52fa8e4c063e
BLAKE2b-256 c24709c692be58a1ca40f9474a8ecb4275b0df5454c06ccaaf0f429a8c13abd7

See more details on using hashes here.

Provenance

The following attestation bundles were made for tenantshield-0.7.0b2.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.0b2-py3-none-any.whl.

File metadata

  • Download URL: tenantshield-0.7.0b2-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.0b2-py3-none-any.whl
Algorithm Hash digest
SHA256 b7b4e66924413ffc43f4b46e42f5834132a6a7ecdb905579f30bd3ee3a1e0ebb
MD5 b4d1f27f7342559972e0c6308209d521
BLAKE2b-256 4b82b2656b6a0d293bae3a76e3a407a90e444fa45eccfb6e68eaa2ff40c4410d

See more details on using hashes here.

Provenance

The following attestation bundles were made for tenantshield-0.7.0b2-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