All skills

Compliance & Security

multi-tenant-scan

Scans all ViewSets, querysets, and API endpoints for missing tenant isolation filters. Catches data leaks where one tenant can see another's data. Framework-aware for Django, FastAPI, and Rails.

View raw .md →skills.sh →245 lines

Multi-Tenant Isolation Scanner

Systematically scan an entire codebase for missing tenant isolation filters — the #1 security vulnerability in multi-tenant SaaS applications.

When to Use

  • After adding a new ViewSet, endpoint, or queryset
  • Before any release or security audit
  • When onboarding a new module to an existing multi-tenant system
  • After refactoring queryset logic
  • Periodically (monthly) as a hygiene check

The Problem

In shared-database multi-tenancy, every query that returns data MUST filter by the tenant identifier. A single missing filter means Tenant A can see Tenant B's data.

DANGEROUS:
    Model.objects.all()                        ← returns ALL tenants' data
    Model.objects.filter(status='active')      ← still returns all tenants

SAFE:
    Model.objects.filter(operation=request.user.current_operation)
    Model.objects.filter(org_id=tenant_context.org_id)

Workflow

Step 1: Detect Tenant Architecture

Read the project's main documentation or settings to identify:

┌──────────────────────────────────────────────┐
│ Tenant Configuration                          │
├──────────────────────────────────────────────┤
│ Framework:     Django / FastAPI / Rails        │
│ Tenant field:  operation / org_id / tenant_id  │
│ Context source: middleware / dependency / scope │
│ Exempt models: User, SiteContent, etc.         │
│ Exempt paths:  /admin/, /health/, /public/     │
└──────────────────────────────────────────────┘

Auto-detection patterns:

FrameworkLook forTenant field
DjangoOperationContextMiddleware, current_operationoperation
FastAPIget_tenant_context, Depends(get_org)org_id
RailsActsAsTenant, current_tenanttenant_id

Step 2: Discover All Data Access Points

Scan for every location that queries the database:

Django patterns to find:

# ViewSet querysets (HIGHEST PRIORITY — these are API-exposed)
grep -rn "def get_queryset" --include="*.py"
grep -rn "\.objects\." --include="*.py"

# Direct model access in services
grep -rn "Model\.objects\.(all|filter|get|exclude|annotate)" --include="*.py"

# Raw SQL (rare but dangerous)
grep -rn "raw\(|cursor\(\)" --include="*.py"

# Serializer querysets (nested serializers can leak)
grep -rn "queryset\s*=" --include="*.py" | grep -i serializer

FastAPI patterns:

grep -rn "session\.(query|execute|scalars)" --include="*.py"
grep -rn "select\(Model\)" --include="*.py"

Rails patterns:

grep -rn "\.all\|\.where\|\.find" --include="*.rb"

Step 3: Classify Each Access Point

For each discovered query, classify it:

CLASSIFICATION RULES
====================

✅ SAFE — Query filters by tenant field:
    Model.objects.filter(operation=request.user.current_operation)
    session.query(Model).filter(Model.org_id == ctx.org_id)

✅ EXEMPT — Model is tenant-agnostic:
    User.objects.get(id=user_id)           ← Users span tenants
    SiteContent.objects.first()            ← Singleton, public data
    CMEPrice.objects.filter(...)           ← Shared market data

⚠️ INDIRECT — Filters via FK chain (verify chain is tenant-safe):
    FeedEvent.objects.filter(lot__operation=op)   ← Safe IF lot.operation is guaranteed
    TreatmentEventDrug.objects.filter(
        treatment_event__operation=op              ← Safe IF FK enforces tenant
    )

❌ MISSING — No tenant filter found:
    Model.objects.all()
    Model.objects.filter(status='active')  ← Filters exist but not tenant

❌ BYPASS — Intentionally skips tenant filter (needs justification):
    Model.all_objects.filter(...)          ← Soft-delete bypass — is tenant filter present?
    Model.objects.using('admin_db')        ← Cross-tenant admin query

Step 4: Audit Indirect Filters

For ⚠️ INDIRECT classifications, trace the FK chain:

FeedEvent.objects.filter(lot__operation=op)
    │
    ├── FeedEvent.lot → FK to Lot (CASCADE)
    ├── Lot.operation → FK to Operation (CASCADE)
    └── ✅ Chain is tenant-safe: every intermediate FK enforces tenant
InvoiceLineItem.objects.filter(invoice__customers__operation=op)
    │
    ├── InvoiceLineItem.invoice → FK to Invoice
    ├── Invoice.customers → M2M to Customer
    ├── Customer.operation → FK to Operation
    └── ⚠️ M2M traversal: verify no cross-tenant customers in M2M

Step 5: Generate Scan Report

MULTI-TENANT ISOLATION SCAN
============================
Project: cattle-management
Tenant field: operation (via OperationContextMiddleware)
Files scanned: 47
Queries found: 189

RESULTS BY STATUS:
  ✅ Safe:      142 (75.1%)
  ✅ Exempt:     23 (12.2%)
  ⚠️ Indirect:   18 (9.5%)
  ❌ Missing:     4 (2.1%)
  ❌ Bypass:      2 (1.1%)

MISSING TENANT FILTERS (CRITICAL):
  File                          Line  Query
  ─────────────────────────────────────────────────────────
  reports/analytics_views.py    45    Lot.objects.filter(status='active')
  feeds/services.py             312   FeedLoad.objects.filter(is_depleted=False)
  health/tasks.py               78    ScheduledTreatment.objects.filter(due_date__lte=today)
  inventory/signals.py          23    Ration.objects.filter(ingredients__feed_commodity=commodity)

BYPASS (NEEDS JUSTIFICATION):
  File                          Line  Query                              Justification
  ──────────────────────────────────────────────────────────────────────────────────
  lots/views.py                 89    Lot.all_objects.filter(...)         Soft-delete admin view — HAS tenant filter ✅
  risk/tasks.py                 45    CMEPrice.objects.all()              Shared market data — no tenant needed ✅

INDIRECT FILTERS (VERIFY FK CHAIN):
  18 queries use FK traversal — all chains verified tenant-safe ✅

Configuration

SettingDefaultDescription
Tenant fieldoperationThe field name used for tenant isolation
Context accessorrequest.user.current_operationHow tenant context is accessed
Exempt modelsUser, SiteContent, CMEPriceModels that don't need tenant filters
Exempt paths/admin/, /health/URL paths that skip tenant checks
File patterns*.pyFiles to scan
Exclude dirsmigrations/, tests/, management/Directories to skip

Framework-Specific Patterns

Django — ViewSet Pattern

# REQUIRED in every ModelViewSet
def get_queryset(self):
    return Model.objects.filter(
        operation=self.request.user.current_operation
    )

Django — Service Pattern

# Services should receive operation as parameter
def calculate_cost(lot, operation):
    expenses = MiscExpense.objects.filter(lot=lot, lot__operation=operation)

FastAPI — Dependency Pattern

async def get_items(
    tenant: TenantContext = Depends(get_tenant_context),
    session: AsyncSession = Depends(get_session),
):
    return await session.scalars(
        select(Item).where(Item.org_id == tenant.org_id)
    )

Rails — ActsAsTenant Pattern

class ApplicationController < ActionController::Base
  set_current_tenant_through_filter
  before_action :set_tenant

  def set_tenant
    set_current_tenant(current_user.organization)
  end
end

Key Principles

  1. Default deny — assume every query is unsafe until proven safe
  2. Scan services too — ViewSets get attention, but services and tasks often skip tenant filters
  3. Celery/background tasks are high-risk — they don't have request context, so tenant must be passed explicitly
  4. Signals cross boundaries — a signal handler in module B triggered by module A may not have tenant context
  5. Admin views need tenant filters too — unless explicitly designed for cross-tenant admin access
  6. Test with 2 tenants — create test data for 2 tenants, query as tenant 1, assert tenant 2's data is absent